diff --git a/.github/workflows/adhoc-mac-build.yml b/.github/workflows/adhoc-mac-build.yml index 59d705bf67e..d7dd6d5ffb6 100644 --- a/.github/workflows/adhoc-mac-build.yml +++ b/.github/workflows/adhoc-mac-build.yml @@ -401,27 +401,51 @@ jobs: echo "::warning::Could not discard draft $TAG; remove it manually." - name: Prune expired adhoc releases + # Only after a live publish: $TAG is then a non-draft this step must not + # delete, and a run that failed before publishing has nothing to retire. + if: steps.publish_live.outcome == 'success' shell: bash env: GH_TOKEN: ${{ steps.app_token.outputs.token }} + # Protect the tag this run just shipped, so no filter mistake can delete + # a build minutes after the person who cut it was told it exists. + TAG: ${{ steps.release.outputs.tag }} run: | set -euo pipefail # Why compute the cutoff in bash rather than with jq's `now`: this runs # once per dispatch, and a fixed epoch makes the threshold visible in the # log when someone asks where their build went. cutoff=$(( $(date -u +%s) - ADHOC_RETAIN_DAYS * 86400 )) - echo "Pruning adhoc releases created before $(date -u -r "$cutoff" '+%Y-%m-%dT%H:%M:%SZ')" + echo "Pruning adhoc releases published before $(date -u -r "$cutoff" '+%Y-%m-%dT%H:%M:%SZ')" # --cleanup-tag so pruning does not leave orphan tags with no release or # assets attached. Drafts are excluded: a stale draft is the failure # path's business, not the retention window's. - stale="$(gh release list --repo "$ADHOC_REPO" --limit 200 --json tagName,createdAt,isDraft \ - --jq "map(select(.isDraft | not)) | map(select((.createdAt | fromdateiso8601) < $cutoff)) | .[].tagName")" + # + # Age comes from publishedAt, never createdAt. GitHub reports createdAt + # as the date of the *commit* a release's tag points at, and every tag + # here is cut from this repo's one seed commit — so all of them carry + # that same createdAt, and the day the window rolled past it the entire + # channel expired at once and a single run deleted it. A release with no + # publishedAt is kept rather than aged by guesswork. + jq_filter='map(select(.isDraft | not))' + if [[ -n "${TAG:-}" ]]; then + jq_filter+=" | map(select(.tagName != \"${TAG//\"/\\\"}\"))" + fi + jq_filter+=" | map(select((.publishedAt // \"\") != \"\"))" + jq_filter+=" | map(select((.publishedAt | fromdateiso8601) < $cutoff)) | .[].tagName" + stale="$(gh release list --repo "$ADHOC_REPO" --limit 200 --json tagName,publishedAt,isDraft \ + --jq "$jq_filter")" if [[ -z "$stale" ]]; then echo "Nothing to prune." exit 0 fi while read -r tag; do [[ -n "$tag" ]] || continue + # Belt-and-suspenders: the filter above should already exclude $TAG. + if [[ -n "${TAG:-}" && "$tag" == "$TAG" ]]; then + echo "::warning::Prune list still included just-published $tag after protect; skipping delete." + continue + fi echo "Pruning $tag" gh release delete "$tag" --repo "$ADHOC_REPO" --yes --cleanup-tag || \ echo "::warning::Could not prune $tag" diff --git a/config/electron-builder.config.cjs b/config/electron-builder.config.cjs index 1a31af9dfaf..13633ed8e01 100644 --- a/config/electron-builder.config.cjs +++ b/config/electron-builder.config.cjs @@ -105,6 +105,11 @@ const winSpeechNativeResource = { to: 'node_modules/sherpa-onnx-win-x64' } +// Why mirrored, not imported: this config is CJS loaded by electron-builder outside the TS build. +// Keep in sync with isMarkdownDocumentName() in src/main/ipc/markdown-documents.ts and with +// config/nsis/orca-installer-hooks.nsh, which registers the same set on Windows. +const MARKDOWN_FILE_EXTENSIONS = ['md', 'markdown', 'mdx'] + /** @type {import('electron-builder').Configuration} */ module.exports = { appId, @@ -376,12 +381,24 @@ module.exports = { shortcutName: '${productName}', uninstallDisplayName: '${productName}', createDesktopShortcut: 'always', - // Why: on a real uninstall, stop and remove the relocated terminal daemon - // (which lives outside the install dir under LOCALAPPDATA by design). Guarded - // by ${isUpdated} inside so it never runs during an update's uninstallOldVersion. - include: resolve(__dirname, 'nsis', 'daemon-host-uninstall.nsh') + // Why: electron-builder allows one include, so both Windows installer hooks live in it - + // the relocated-daemon uninstall sweep (guarded by ${isUpdated} so it never runs during an + // update's uninstallOldVersion) and the additive markdown "Open with" registration. + // Windows markdown association is deliberately NOT done via `fileAssociations`; see the + // header comment in that file for why that would steal the user's default .md handler. + include: resolve(__dirname, 'nsis', 'orca-installer-hooks.nsh') }, mac: { + // Why rank Alternate: Orca joins Finder's "Open With" list for Markdown without claiming + // LSHandlerRank ownership, so whichever editor the user already prefers stays the default. + // Why one entry per extension: app-builder-lib globs `*.${ext}`, which an array would break. + fileAssociations: MARKDOWN_FILE_EXTENSIONS.map((ext) => ({ + ext, + name: 'Markdown Document', + description: 'Markdown Document', + role: 'Editor', + rank: 'Alternate' + })), icon: 'resources/build/icon.icns', entitlements: 'resources/build/entitlements.mac.plist', entitlementsInherit: 'resources/build/entitlements.mac.plist', @@ -468,6 +485,12 @@ module.exports = { artifactName: 'orca-macos-${arch}.${ext}' }, linux: { + // Why mimeTypes and not fileAssociations: shared-mime-info already maps *.md/*.markdown to + // text/markdown, so reusing that type puts Orca in the Open With list without shipping a glob + // override. A desktop entry's MimeType only adds a handler - mimeapps.list still owns the + // default. .mdx is deliberately absent: Ubuntu 24.04's mime database maps it to + // application/x-genesis-32x-rom, so claiming it here would need a glob override. + mimeTypes: ['text/markdown'], // Why: Ubuntu desktop ships GNOME Orca as the `orca` package and /usr/bin/orca. // The Linux installer should not claim those system package/file names. executableName: 'orca-ide', diff --git a/config/max-lines-baseline.txt b/config/max-lines-baseline.txt index 9ff781a6068..4bc75a5a132 100644 --- a/config/max-lines-baseline.txt +++ b/config/max-lines-baseline.txt @@ -2,32 +2,13 @@ # This is a RATCHET: the list may only SHRINK. Do NOT add entries to get CI green — # split the oversized file instead (AGENTS.md → "Do Not Disable Max Lines"). # Regenerate/prune: pnpm check:max-lines-ratchet --prune (removes stale entries only) -inline src/main/agent-hooks/server.ts -inline src/main/browser/agent-browser-bridge.ts -inline src/main/browser/browser-cookie-import.ts -inline src/main/browser/browser-manager.ts -inline src/main/codex-accounts/runtime-home-service.ts -inline src/main/index.ts -inline src/main/ipc/filesystem.ts inline src/main/ipc/worktree-remote.ts -inline src/main/rate-limits/service.ts -inline src/main/runtime/rpc/methods/orchestration.ts inline src/main/ssh/ssh-channel-multiplexer.ts inline src/main/ssh/ssh-connection.ts inline src/main/ssh/ssh-relay-deploy.ts inline src/main/ssh/ssh-relay-session.ts -inline src/main/updater.ts -inline src/preload/index.ts inline src/relay/pty-handler.ts inline src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts -inline src/renderer/src/runtime/sync-runtime-graph.ts -inline src/renderer/src/runtime/web-session-tabs-sync.ts -inline src/renderer/src/store/slices/agent-status.ts -inline src/renderer/src/store/slices/browser.ts -inline src/renderer/src/store/slices/linear.ts -inline src/renderer/src/store/slices/tabs.ts -inline src/renderer/src/store/slices/ui.ts -inline src/shared/keybindings.ts mobile-config app/h/*/files/*.tsx mobile-config app/h/*/source-control/*.tsx mobile-config app/index.tsx diff --git a/config/nsis/daemon-host-uninstall.nsh b/config/nsis/daemon-host-uninstall.nsh deleted file mode 100644 index dc3a497ce67..00000000000 --- a/config/nsis/daemon-host-uninstall.nsh +++ /dev/null @@ -1,23 +0,0 @@ -; Clean up the relocated terminal daemon on a REAL uninstall. -; -; Why: the daemon host is deliberately copied to a distinct image name -; (orca-terminal-daemon.exe) under %LOCALAPPDATA%\Orca\daemon-host so that app -; UPDATES cannot kill it — that relocation is what keeps terminals alive across -; updates. The same design means a normal uninstall's process sweep and file -; removal both miss it, leaving an orphaned daemon plus its runtime copy behind. -; -; The ${isUpdated} guard is essential: electron-builder runs this uninstaller as -; part of uninstallOldVersion on EVERY update, and killing the daemon there would -; defeat the whole feature. Only clean up on a genuine uninstall. -; -; The image name and the LOCALAPPDATA folder name must stay in sync with -; DAEMON_HOST_EXE_NAME and LOCAL_HOST_ROOT_NAME in -; src/main/daemon/daemon-host-relocation.ts. -!macro customUnInstall - ${ifNot} ${isUpdated} - nsExec::Exec 'taskkill /F /IM orca-terminal-daemon.exe' - ; Give the OS a moment to release the image lock before removing the tree. - Sleep 500 - RMDir /r "$LOCALAPPDATA\Orca\daemon-host" - ${endIf} -!macroend diff --git a/config/nsis/orca-installer-hooks.nsh b/config/nsis/orca-installer-hooks.nsh new file mode 100644 index 00000000000..ca80c99fc6d --- /dev/null +++ b/config/nsis/orca-installer-hooks.nsh @@ -0,0 +1,79 @@ +; electron-builder NSIS hooks for the Orca Windows installer. +; +; electron-builder accepts exactly ONE `nsis.include` file, so every customInstall / +; customUnInstall hook Orca needs lives here. + +; --------------------------------------------------------------------------- +; Markdown "Open with Orca" (issue #10138) +; +; Why hand-rolled instead of electron-builder's `fileAssociations` on Windows: +; app-builder-lib emits !insertmacro APP_ASSOCIATE, whose first line is +; WriteRegStr SHELL_CONTEXT "Software\Classes\.md" "" "" +; That overwrites whichever editor currently owns .md, with no backup, for every +; existing user on their next UPDATE - and APP_UNASSOCIATE never restores it, so +; uninstalling Orca would leave .md pointing at a deleted ProgID. +; +; These writes are additive only. Registering a ProgID plus an OpenWithProgids +; hint and an Applications\\SupportedTypes entry puts Orca in Explorer's +; "Open with" list and in "Choose another app", while the default handler stays +; exactly where the user left it. Never add a `Software\Classes\.` default +; value here. +; +; MARKDOWN_PROGID must stay in sync with the extension list handled by +; isMarkdownDocumentName() in src/main/ipc/markdown-documents.ts. +; --------------------------------------------------------------------------- +!define MARKDOWN_PROGID "Orca.Markdown" + +!macro ORCA_REGISTER_MARKDOWN_OPEN_WITH EXT + WriteRegNone SHELL_CONTEXT "Software\Classes\${EXT}\OpenWithProgids" "${MARKDOWN_PROGID}" + WriteRegStr SHELL_CONTEXT "Software\Classes\Applications\${APP_EXECUTABLE_FILENAME}\SupportedTypes" "${EXT}" "" +!macroend + +!macro ORCA_UNREGISTER_MARKDOWN_OPEN_WITH EXT + DeleteRegValue SHELL_CONTEXT "Software\Classes\${EXT}\OpenWithProgids" "${MARKDOWN_PROGID}" + DeleteRegValue SHELL_CONTEXT "Software\Classes\Applications\${APP_EXECUTABLE_FILENAME}\SupportedTypes" "${EXT}" +!macroend + +!macro customInstall + WriteRegStr SHELL_CONTEXT "Software\Classes\${MARKDOWN_PROGID}" "" "Markdown Document" + WriteRegStr SHELL_CONTEXT "Software\Classes\${MARKDOWN_PROGID}\DefaultIcon" "" "$appExe,0" + WriteRegStr SHELL_CONTEXT "Software\Classes\${MARKDOWN_PROGID}\shell\open" "" "Open with ${PRODUCT_NAME}" + WriteRegStr SHELL_CONTEXT "Software\Classes\${MARKDOWN_PROGID}\shell\open\command" "" '"$appExe" "%1"' + !insertmacro ORCA_REGISTER_MARKDOWN_OPEN_WITH ".md" + !insertmacro ORCA_REGISTER_MARKDOWN_OPEN_WITH ".markdown" + !insertmacro ORCA_REGISTER_MARKDOWN_OPEN_WITH ".mdx" + ; Why: Explorer caches the association list until told otherwise. + System::Call "shell32::SHChangeNotify(i,i,i,i) (0x08000000, 0x1000, 0, 0)" +!macroend + +; --------------------------------------------------------------------------- +; Clean up the relocated terminal daemon on a REAL uninstall. +; +; Why: the daemon host is deliberately copied to a distinct image name +; (orca-terminal-daemon.exe) under %LOCALAPPDATA%\Orca\daemon-host so that app +; UPDATES cannot kill it — that relocation is what keeps terminals alive across +; updates. The same design means a normal uninstall's process sweep and file +; removal both miss it, leaving an orphaned daemon plus its runtime copy behind. +; +; The ${isUpdated} guard is essential: electron-builder runs this uninstaller as +; part of uninstallOldVersion on EVERY update, and killing the daemon there would +; defeat the whole feature. Only clean up on a genuine uninstall. +; +; The image name and the LOCALAPPDATA folder name must stay in sync with +; DAEMON_HOST_EXE_NAME and LOCAL_HOST_ROOT_NAME in +; src/main/daemon/daemon-host-relocation.ts. +!macro customUnInstall + ${ifNot} ${isUpdated} + nsExec::Exec 'taskkill /F /IM orca-terminal-daemon.exe' + ; Give the OS a moment to release the image lock before removing the tree. + Sleep 500 + RMDir /r "$LOCALAPPDATA\Orca\daemon-host" + ${endIf} + ; Why outside the ${isUpdated} guard: customInstall rewrites these on every update, so + ; dropping them during uninstallOldVersion is correct and keeps the pair symmetric. + DeleteRegKey SHELL_CONTEXT "Software\Classes\${MARKDOWN_PROGID}" + !insertmacro ORCA_UNREGISTER_MARKDOWN_OPEN_WITH ".md" + !insertmacro ORCA_UNREGISTER_MARKDOWN_OPEN_WITH ".markdown" + !insertmacro ORCA_UNREGISTER_MARKDOWN_OPEN_WITH ".mdx" + System::Call "shell32::SHChangeNotify(i,i,i,i) (0x08000000, 0x1000, 0, 0)" +!macroend diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index 9959618da51..0c2337ba36f 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -2493,30 +2493,31 @@ "https://github.com/stablyai/orca/issues/11298", "https://github.com/stablyai/orca/pull/11300" ], - "invariant": "Every accepted runtime socket installs message, pong, close, and error ownership before any heartbeat probe. With uninterrupted timer delivery, the first socket that arms an idle heartbeat is probed immediately and an unresponsive socket is reaped within one interval. Later sockets join the existing shared cadence without another timer or immediate sweep and are reaped within two intervals. Responsive sockets survive, pause recovery grants a fresh probe, and close or error-to-close releases connection listeners and timers.", - "oracle": "With one fake clock and exact socket identities, accept the first socket at 0 ms and require an immediate owned probe plus reaping at 100 ms when unresponsive. Keep a responsive first socket, accept an unresponsive later socket at 50 ms, require the same shared timer, its first probe at 100 ms, no early reap, and termination at 200 ms. Inject synchronous message, pong, close, and error events, then require exact heartbeat membership and zero retained timers/listeners after final close. Production transport tests independently cover real socket round trips, pre-auth and capacity bounds, revocation, shutdown, and half-open cleanup.", + "invariant": "Every accepted runtime socket installs message, pong, close, and error ownership before any heartbeat probe. Unauthenticated sockets receive no heartbeat control frames during E2EE and are bounded by the pre-auth timeout; authenticated sockets share one periodic cadence, tolerate missed probes and event-loop pause, and release listeners and timers on close or error.", + "oracle": "With one fake clock and exact socket identities, accept an authenticated first socket at 0 ms and require its first probe on the 100 ms tick. Accept an unauthenticated later socket at 50 ms and require no probe at the 100 ms tick; authenticate it, then require its first probe on the next shared tick and termination only after the configured consecutive-miss budget. Inject synchronous message, pong, close, and error events, then require exact heartbeat membership and zero retained timers/listeners after final close. Production transport tests independently cover real socket round trips, pre-auth and capacity bounds, revocation, shutdown, and half-open cleanup.", "commands": [ - "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/ws-transport-accept-order.test.ts src/main/runtime/rpc/remote-runtime-server-heartbeat.test.ts src/main/runtime/rpc/ws-transport.test.ts" + "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/ws-transport-accept-order.test.ts src/main/runtime/rpc/remote-runtime-server-heartbeat.test.ts src/main/runtime/rpc/ws-transport.test.ts src/main/runtime/rpc/ws-transport-transient-packet-loss.test.ts" ], "testFiles": [ "src/main/runtime/rpc/ws-transport-accept-order.test.ts", "src/main/runtime/rpc/remote-runtime-server-heartbeat.test.ts", - "src/main/runtime/rpc/ws-transport.test.ts" + "src/main/runtime/rpc/ws-transport.test.ts", + "src/main/runtime/rpc/ws-transport-transient-packet-loss.test.ts" ], "assertionRefs": [ { "file": "src/main/runtime/rpc/ws-transport-accept-order.test.ts", "assertions": [ - "the first synchronous probe observes message, pong, close, and error ownership", - "the first unresponsive socket is reaped at one interval", - "a later socket keeps the original shared timer, is first probed on the shared tick, and is reaped within two intervals", + "the first periodic probe observes message, pong, close, and error ownership", + "an authenticated unresponsive socket is reaped on the configured consecutive-miss budget", + "an unauthenticated later socket is not probed before authentication, then joins the original shared timer", "final close releases heartbeat membership, listeners, and timers" ] }, { "file": "src/main/runtime/rpc/remote-runtime-server-heartbeat.test.ts", "assertions": [ - "one missed probe reaps only the unresponsive client", + "a single missed probe does not reap the unresponsive client", "event-loop resume grants clients a fresh probe" ] }, @@ -2527,6 +2528,12 @@ "pre-auth, raw TCP, and accepted WebSocket resource bounds remain enforced", "error and close races finalize membership once" ] + }, + { + "file": "src/main/runtime/rpc/ws-transport-transient-packet-loss.test.ts", + "assertions": [ + "an authenticated real WebSocket survives one swallowed pong and responds to later probes" + ] } ], "evidenceRuns": [ @@ -2534,10 +2541,10 @@ "date": "2026-07-29", "runner": "local", "platform": "macos", - "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/ws-transport-accept-order.test.ts src/main/runtime/rpc/remote-runtime-server-heartbeat.test.ts src/main/runtime/rpc/ws-transport.test.ts", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/ws-transport-accept-order.test.ts src/main/runtime/rpc/remote-runtime-server-heartbeat.test.ts src/main/runtime/rpc/ws-transport.test.ts src/main/runtime/rpc/ws-transport-transient-packet-loss.test.ts", "result": "passed", "durationSeconds": 0.91, - "summary": "Three runtime transport files and 35 tests passed with deterministic first- and later-socket cadence, exact listener/timer ownership, pause recovery, security bounds, and cleanup." + "summary": "Four runtime transport files and 42 tests passed with deterministic authenticated heartbeat cadence, handshake grace for later sockets, exact listener/timer ownership, pause recovery, transient packet-loss tolerance, security bounds, and cleanup." } ], "runtimeBudget": { @@ -2550,7 +2557,7 @@ }, "redGreenEvidence": { "status": "complete", - "evidence": "On latest main and with the listener-order fix disabled, the first synchronous probe observed no message, pong, close, or error owner. The structural listener-order fix passed those assertions. The published delayed-first-sweep alternative missed the first-socket one-interval cleanup bound. A later-socket oracle now separately pins the intended shared cadence at a 100 ms first probe and 200 ms reap after acceptance at 50 ms." + "evidence": "On the candidate before this review fix, an authenticated first socket kept the shared timer alive while an unauthenticated socket accepted at 50 ms was pinged at the 100 ms tick; the new oracle failed. After filtering heartbeat clients to the authenticated transport map, the same byte-identical oracle passes: no pre-auth ping, first post-auth probe on the next shared tick, and bounded cleanup." }, "performanceBudget": { "required": true, diff --git a/config/scripts/__fixtures__/websocket-server-wildcard-bind-allowlist.txt b/config/scripts/__fixtures__/websocket-server-wildcard-bind-allowlist.txt new file mode 100644 index 00000000000..4b76622a9f2 --- /dev/null +++ b/config/scripts/__fixtures__/websocket-server-wildcard-bind-allowlist.txt @@ -0,0 +1,14 @@ +# Files allowed to construct a `ws` server that binds a port without pinning `host`. +# +# `ws` accepts `{ port }` alone and silently binds the wildcard address. A server +# reached over 127.0.0.1 must pin `host: '127.0.0.1'`, or a foreign loopback +# listener can hold the same port and answer in its place -- which is how +# relay-control-client.test.ts came to fail with a real HTTP 401 in a test that +# was simulating silence. +# +# This list only shrinks. Adding a line also requires raising the pin in +# websocket-server-loopback-bind.test.ts, which is deliberate friction. + +# Deliberate, not drift: this mock is dialled by a phone on the LAN, so it has to +# be reachable on a real interface. A loopback bind would make it unreachable. +mobile/scripts/mock-server.ts diff --git a/config/scripts/call-site-option-keys.ts b/config/scripts/call-site-option-keys.ts new file mode 100644 index 00000000000..dff3a971c45 --- /dev/null +++ b/config/scripts/call-site-option-keys.ts @@ -0,0 +1,204 @@ +/** + * Read the top-level option keys of a call's object-literal argument out of raw + * source text. + * + * Text rather than an AST because typescript@7 no longer ships the classic + * compiler API and every installed parser is a transitive dependency. The + * tradeoff is handled by refusing to guess: any shape this cannot read comes + * back as `unreadable` with a reason, and callers must treat that as a failure + * rather than as an absence of keys. + */ + +export type CallOptionKeys = + | { readonly readable: true; readonly keys: readonly string[] } + | { readonly readable: false; readonly reason: string } + +type ScanState = 'code' | 'line' | 'block' | 'single' | 'double' | 'template' + +function closesString(state: ScanState, current: string): boolean { + return ( + (state === 'single' && current === "'") || + (state === 'double' && current === '"') || + (state === 'template' && current === '`') + ) +} + +function opensNonCode(current: string, next: string | undefined): ScanState | null { + if (current === '/' && next === '/') { + return 'line' + } + if (current === '/' && next === '*') { + return 'block' + } + if (current === "'") { + return 'single' + } + if (current === '"') { + return 'double' + } + if (current === '`') { + return 'template' + } + return null +} + +/** + * Text between an open paren and its match, tracking strings and comments so a + * brace inside either cannot unbalance the count. Null when it never closes. + */ +function balancedArguments(text: string, openIndex: number): string | null { + let depth = 0 + let state: ScanState = 'code' + for (let index = openIndex; index < text.length; index++) { + const current = text[index] + const next = text[index + 1] + if (state === 'code') { + const opened = opensNonCode(current, next) + if (opened) { + state = opened + if (opened === 'line' || opened === 'block') { + index++ + } + } else if (current === '(' || current === '{' || current === '[') { + depth++ + } else if (current === ')' || current === '}' || current === ']') { + depth-- + if (depth === 0) { + return text.slice(openIndex + 1, index) + } + if (depth < 0) { + return null + } + } + continue + } + if (state === 'line') { + if (current === '\n') { + state = 'code' + } + continue + } + if (state === 'block') { + if (current === '*' && next === '/') { + state = 'code' + index++ + } + continue + } + if (current === '\\') { + index++ + continue + } + // Brace tracking inside `${}` would need its own depth; templates never + // appear as options, so report one as unreadable instead of guessing. + if (state === 'template' && current === '$' && next === '{') { + return null + } + if (closesString(state, current)) { + state = 'code' + } + } + return null +} + +/** Keys at depth 0 of an object literal body, with anything non-identifier kept verbatim. */ +function objectLiteralKeys(body: string): string[] { + const keys: string[] = [] + let depth = 0 + let state: ScanState = 'code' + let inValue = false + let token = '' + const flush = (): void => { + const name = token.trim() + token = '' + if (name && depth === 0) { + keys.push(name) + } + } + for (let index = 0; index < body.length; index++) { + const current = body[index] + const next = body[index + 1] + if (state === 'code') { + const opened = opensNonCode(current, next) + if (opened) { + state = opened + if (opened === 'line' || opened === 'block') { + index++ + } + } else if (current === '(' || current === '{' || current === '[') { + depth++ + if (!inValue) { + token += current + } + } else if (current === ')' || current === '}' || current === ']') { + depth-- + if (!inValue) { + token += current + } + } else if (current === ':' && depth === 0 && !inValue) { + flush() + inValue = true + } else if (current === ',' && depth === 0) { + // A shorthand or a spread ends here having never seen a colon. + if (inValue) { + inValue = false + token = '' + } else { + flush() + } + } else if (!inValue) { + token += current + } + continue + } + if (state === 'line') { + if (current === '\n') { + state = 'code' + } + continue + } + if (state === 'block') { + if (current === '*' && next === '/') { + state = 'code' + index++ + } + continue + } + if (current === '\\') { + index++ + continue + } + if (closesString(state, current)) { + state = 'code' + } + } + if (!inValue) { + flush() + } + return keys +} + +/** + * Option keys of the call whose argument list opens at `parenIndex`, or the + * reason the shape could not be read. Spreads and computed keys land in the + * latter: either can carry a key this would otherwise report as absent. + */ +export function readCallOptionKeys(text: string, parenIndex: number): CallOptionKeys { + const args = balancedArguments(text, parenIndex) + if (args === null) { + return { readable: false, reason: 'argument list never closes' } + } + if (!args.trim()) { + return { readable: false, reason: 'called with no options argument' } + } + const trimmed = args.trim() + if (!trimmed.startsWith('{') || !trimmed.endsWith('}')) { + return { readable: false, reason: 'options are not an object literal' } + } + const keys = objectLiteralKeys(trimmed.slice(1, -1)) + const unreadable = keys.find((key) => !/^[A-Za-z_$][\w$]*$/.test(key)) + if (unreadable !== undefined) { + return { readable: false, reason: `unreadable option key \`${unreadable}\`` } + } + return { readable: true, keys } +} diff --git a/config/scripts/electron-builder-markdown-associations.test.mjs b/config/scripts/electron-builder-markdown-associations.test.mjs new file mode 100644 index 00000000000..7ae3b1c9428 --- /dev/null +++ b/config/scripts/electron-builder-markdown-associations.test.mjs @@ -0,0 +1,116 @@ +import { existsSync } from 'node:fs' +import { readFile } from 'node:fs/promises' +import { createRequire } from 'node:module' +import { basename } from 'node:path' +import { describe, expect, it } from 'vitest' + +const require = createRequire(import.meta.url) +const electronBuilderConfig = require('../electron-builder.config.cjs') + +const MARKDOWN_EXTENSIONS = ['md', 'markdown', 'mdx'] + +// The exact shape app-builder-lib's APP_ASSOCIATE emits: a write to the DEFAULT ("") +// value of Software\Classes\.. Additive `WriteRegNone ...\OpenWithProgids` must not +// match, or the guard below would be unfalsifiable. +const DEFAULT_HANDLER_WRITE = /WriteRegStr\s+SHELL_CONTEXT\s+"Software\\Classes\\\.[a-z]+"\s+""/i + +// The hooks file documents the forbidden line in prose, so match executable script only. +const stripNsisCommentLines = (source) => + source + .split('\n') + .filter((line) => !/^\s*[;#]/.test(line)) + .join('\n') + +const readInstallerHooks = () => readFile(electronBuilderConfig.nsis.include, 'utf8') + +describe('electron-builder markdown file associations', () => { + // Why: any top-level (or `win.`) fileAssociations entry makes app-builder-lib's NSIS + // packager emit `!insertmacro APP_ASSOCIATE`, whose first line writes that DEFAULT value + // — silently taking .md from whichever editor owns it, for every existing user on their + // next UPDATE, with APP_UNASSOCIATE never restoring it. `rank: 'Alternate'` cannot + // prevent this; it is LSHandlerRank and applies to macOS only. So the mac block must + // stay under `mac.` — hoisting it up "to share it with Windows" is what this test blocks. + it('never claims the Windows default markdown handler', () => { + expect(electronBuilderConfig.fileAssociations).toBeUndefined() + expect(electronBuilderConfig.win?.fileAssociations).toBeUndefined() + }) + + it('joins the macOS Open With list for every markdown extension without owning it', () => { + const associations = electronBuilderConfig.mac.fileAssociations + // One entry per extension: an array `ext` would break the Linux packager's `*.${ext}` glob. + expect([...associations].map((association) => association.ext).sort()).toEqual( + [...MARKDOWN_EXTENSIONS].sort() + ) + for (const association of associations) { + expect(association).toMatchObject({ role: 'Editor', rank: 'Alternate' }) + } + }) + + // Why mimeTypes and not linux.fileAssociations: shared-mime-info already maps markdown to + // text/markdown, so the desktop entry only adds a handler and mimeapps.list keeps owning + // the default. A fileAssociations entry would ship a redundant glob override instead. + it('reuses the existing shared-mime-info markdown type on Linux', () => { + expect(electronBuilderConfig.linux.mimeTypes).toContain('text/markdown') + expect(electronBuilderConfig.linux.fileAssociations).toBeUndefined() + }) + + it('points the single NSIS include at the installer hooks file on disk', () => { + const includePath = electronBuilderConfig.nsis.include + expect(existsSync(includePath)).toBe(true) + expect(basename(includePath)).toBe('orca-installer-hooks.nsh') + }) + + // Guard for the guard: proves DEFAULT_HANDLER_WRITE really matches a takeover line, so + // the assertion below is a live check rather than a regex that can never fire. + it('recognizes an APP_ASSOCIATE-style default-handler write', () => { + for (const takeover of [ + ' WriteRegStr SHELL_CONTEXT "Software\\Classes\\.md" "" "Orca.Markdown"', + 'WriteRegStr SHELL_CONTEXT "Software\\Classes\\.markdown" "" "$0"' + ]) { + expect(takeover).toMatch(DEFAULT_HANDLER_WRITE) + } + expect( + 'WriteRegNone SHELL_CONTEXT "Software\\Classes\\.md\\OpenWithProgids" "Orca.Markdown"' + ).not.toMatch(DEFAULT_HANDLER_WRITE) + // Comment stripping must drop prose that quotes the bad line without swallowing a real + // one that happens to carry a trailing comment. + const stripped = stripNsisCommentLines( + [ + '; WriteRegStr SHELL_CONTEXT "Software\\Classes\\.md" "" ""', + ' WriteRegStr SHELL_CONTEXT "Software\\Classes\\.md" "" "$0" ; oops' + ].join('\n') + ) + expect(stripped.split('\n')).toHaveLength(1) + expect(stripped).toMatch(DEFAULT_HANDLER_WRITE) + }) + + it('registers Windows markdown Open With additively, never as the default', async () => { + const hooks = await readInstallerHooks() + + expect(stripNsisCommentLines(hooks)).not.toMatch(DEFAULT_HANDLER_WRITE) + // The additive hint that puts Orca in Explorer's "Open with" list. + expect(hooks).toMatch( + /WriteRegNone\s+SHELL_CONTEXT\s+"Software\\Classes\\\$\{EXT\}\\OpenWithProgids"/ + ) + expect(hooks).toMatch(/!macro\s+ORCA_REGISTER_MARKDOWN_OPEN_WITH\s+EXT/) + for (const ext of MARKDOWN_EXTENSIONS) { + expect(hooks).toContain(`ORCA_REGISTER_MARKDOWN_OPEN_WITH ".${ext}"`) + expect(hooks).toContain(`ORCA_UNREGISTER_MARKDOWN_OPEN_WITH ".${ext}"`) + } + expect(hooks).toMatch(/!macro\s+customInstall\b/) + expect(hooks).toMatch(/!macro\s+customUnInstall\b/) + }) + + // Why: this include was renamed from daemon-host-uninstall.nsh to carry the markdown + // hooks too. electron-builder allows only one include, so a merge that drops the daemon + // sweep would silently orphan a running orca-terminal-daemon.exe on every uninstall. + it('keeps the daemon-host uninstall sweep across the include rename', async () => { + const hooks = await readInstallerHooks() + + expect(hooks).toContain('orca-terminal-daemon.exe') + expect(hooks).toContain('$LOCALAPPDATA\\Orca\\daemon-host') + // Without this guard, uninstallOldVersion would kill the daemon on every update — + // defeating the relocation that keeps terminals alive across updates. + expect(hooks).toMatch(/\$\{ifNot\}\s+\$\{isUpdated\}/) + }) +}) diff --git a/config/scripts/git-diff-blob-concurrency-benchmark.mjs b/config/scripts/git-diff-blob-concurrency-benchmark.mjs index 602efd00e1b..8b7e9503e4a 100644 --- a/config/scripts/git-diff-blob-concurrency-benchmark.mjs +++ b/config/scripts/git-diff-blob-concurrency-benchmark.mjs @@ -87,7 +87,7 @@ const parent = `${head}~1` const CANDIDATES = [ 'src/main/git/status.ts', 'src/shared/agent-hook-listener.ts', - 'src/renderer/src/components/TaskPage.tsx' + 'src/renderer/src/components/task-page/TaskPage.tsx' ] const files = [] diff --git a/config/scripts/package-electron-runtime-contract.test.mjs b/config/scripts/package-electron-runtime-contract.test.mjs index 8945c7b9f8a..9f62802c84f 100644 --- a/config/scripts/package-electron-runtime-contract.test.mjs +++ b/config/scripts/package-electron-runtime-contract.test.mjs @@ -363,6 +363,10 @@ describe('Electron runtime package contract', () => { expect(afterInstallScript).toContain('chrome-sandbox') expect(afterInstallScript).toContain('chmod 4755 "$sandbox"') expect(afterInstallScript).not.toContain('chmod 0755 "$sandbox"') + expect(afterInstallScript).toContain('is_owned_link()') + expect(afterInstallScript).toContain('readlink -f -- "$link"') + expect(afterInstallScript).toContain('[ ! -e "$link" ] && [ ! -L "$link" ]') + expect(afterInstallScript).not.toContain('[ ! -e "$link" ] || [ -L "$link" ]') }) it('advances only the skill release ledger in a taggable release-cut commit', () => { diff --git a/config/scripts/space-sharing-copy.mjs b/config/scripts/space-sharing-copy.mjs index 191bd8bffdc..01e9c8ac5ef 100644 --- a/config/scripts/space-sharing-copy.mjs +++ b/config/scripts/space-sharing-copy.mjs @@ -110,32 +110,60 @@ export function makeTreeReadOnly(targetPath, chmod = chmodSync) { chmod(targetPath, 0o755) } +/** + * Restore owner write permission across a private copy. + * + * Counterpart to `makeTreeReadOnly`: clonefile, reflink and `cpSync` all carry the source's mode + * across, so a tree copied from the write-protected shared cache lands read-only and every patch + * the caller then makes -- `plutil -replace`, `codesign` -- fails with EACCES. Only the owner bit + * comes back; group and other stay as the source left them. + */ +export function makeTreeWritable(targetPath, chmod = chmodSync) { + for (const entry of readdirSync(targetPath, { withFileTypes: true })) { + const entryPath = join(targetPath, entry.name) + if (entry.isDirectory()) { + makeTreeWritable(entryPath, chmod) + } else if (!entry.isSymbolicLink()) { + const mode = statSync(entryPath, { throwIfNoEntry: false })?.mode + chmod(entryPath, mode === undefined ? 0o644 : mode | 0o200) + } + } + chmod(targetPath, 0o755) +} + /** * Share storage when possible, otherwise copy the bytes. * * Never hardlinks: this is for trees the caller goes on to patch, where shared inodes would write - * through into the source. + * through into the source. The copy is unprotected on the way out for the same reason -- a private + * tree the caller cannot write to is useless to it. */ export function copyPrivateTree(sourcePath, destinationPath, options = {}) { const platform = options.platform ?? process.platform const copy = options.copy ?? copyTreeVerbatim + const unprotect = options.unprotect ?? makeTreeWritable const privateMechanisms = new Set(['clone', 'reflink']) + let result = { mechanism: null, copyError: null } if (getShareMechanisms(platform).some((mechanism) => privateMechanisms.has(mechanism))) { try { - const mechanism = shareTree(sourcePath, destinationPath, { - ...options, - hardlink: () => { - throw new Error('hardlinks would not be private') - } - }) - return { mechanism, copyError: null } + result = { + mechanism: shareTree(sourcePath, destinationPath, { + ...options, + hardlink: () => { + throw new Error('hardlinks would not be private') + } + }), + copyError: null + } } catch (copyError) { copy(sourcePath, destinationPath) - return { mechanism: null, copyError } + result = { mechanism: null, copyError } } + } else { + copy(sourcePath, destinationPath) } - copy(sourcePath, destinationPath) - return { mechanism: null, copyError: null } + unprotect(destinationPath) + return result } function copyTreeVerbatim(sourcePath, destinationPath) { diff --git a/config/scripts/space-sharing-copy.test.ts b/config/scripts/space-sharing-copy.test.ts index 3ce35aae25e..351f44b286e 100644 --- a/config/scripts/space-sharing-copy.test.ts +++ b/config/scripts/space-sharing-copy.test.ts @@ -19,6 +19,7 @@ import { copyPrivateTree, hardlinkTree, makeTreeReadOnly, + makeTreeWritable, shareTree } from './space-sharing-copy.mjs' @@ -170,7 +171,41 @@ describe('makeTreeReadOnly', () => { ) }) +describe('makeTreeWritable', () => { + it.runIf(process.platform !== 'win32')('undoes makeTreeReadOnly for the owner', () => { + const { source } = makeTree() + makeTreeReadOnly(source) + makeTreeWritable(source) + const file = path.join(source, 'nested', 'file') + expect(statSync(file).mode & 0o200).toBe(0o200) + expect(() => writeFileSync(file, 'mutated')).not.toThrow() + }) + + it.runIf(process.platform !== 'win32')('adds no write permission beyond the owner', () => { + const { source } = makeTree() + const executable = path.join(source, 'electron') + writeFileSync(executable, 'binary') + chmodSync(executable, 0o555) + makeTreeWritable(source) + expect(statSync(executable).mode & 0o777).toBe(0o755) + }) +}) + describe('copyPrivateTree', () => { + it.runIf(process.platform !== 'win32')( + 'hands back a tree the caller can patch, even from a write-protected source', + () => { + const { root, source } = makeTree() + const destination = path.join(root, 'private') + makeTreeReadOnly(source) + copyPrivateTree(source, destination) + // The regression this guards: the shared Electron dist is read-only, clonefile/reflink/cpSync + // all carry that across, and `pn dev` then died patching the copied bundle's Info.plist. + expect(() => writeFileSync(path.join(destination, 'nested', 'file'), 'patched')).not.toThrow() + expect(readFileSync(path.join(source, 'nested', 'file'), 'utf8')).toBe('contents') + } + ) + it('never hardlinks, because the caller patches what it gets back', () => { const { root, source } = makeTree() const destination = path.join(root, 'private') diff --git a/config/scripts/websocket-server-bind-scan.ts b/config/scripts/websocket-server-bind-scan.ts new file mode 100644 index 00000000000..9054f8cc38e --- /dev/null +++ b/config/scripts/websocket-server-bind-scan.ts @@ -0,0 +1,172 @@ +import { readFileSync, readdirSync } from 'node:fs' +import { join, relative } from 'node:path' +import { readCallOptionKeys } from './call-site-option-keys' + +/** + * Locate every `new WebSocketServer(...)` in the tree and say, for each, whether + * it pins a bind address. + * + * `ws` accepts `{ port }` alone and silently binds the wildcard address, so a + * server the caller then dials on 127.0.0.1 sits at a port a foreign loopback + * listener can also hold -- and the more specific listener wins the connection, + * answering in that server's place. + * + * Anything unreadable is reported as `opaque` rather than skipped. A matcher + * that silently exempts the shapes it fails to parse is worse than no matcher, + * because it reads as coverage. + */ + +export type BindSite = { path: string; line: number } +export type OpaqueSite = BindSite & { reason: string } + +export type WebSocketServerBindScan = { + filesScanned: number + /** Every construction recognized, however it was then classified. */ + constructions: number + /** Binds a port with no `host`: reachable at an address the dialer never named. */ + wildcardBound: BindSite[] + /** Shape that could not be read; never treated as safe. */ + opaque: OpaqueSite[] + /** Binds a port and pins `host`. */ + loopbackBound: BindSite[] + /** No `port`: attaches to a server that owns the bind itself. */ + attached: BindSite[] +} + +const IGNORED_DIRECTORIES = new Set([ + 'node_modules', + 'dist', + 'out', + 'build', + '.git', + '__fixtures__', + 'coverage', + // Full snapshots of older releases; their bind sites are not this tree's to fix. + '.cross-version-checkouts' +]) +const SCANNED_EXTENSIONS = /\.(?:ts|tsx|mts|cts)$/ +const SCANNED_ROOTS = ['src', 'mobile', 'config', 'tests'] +const WS_IMPORT_HINT = /from\s*['"]ws['"]/ + +function collectSourceFiles(root: string, found: string[] = []): string[] { + let entries: ReturnType> + try { + entries = readdirSync(root, { withFileTypes: true }) + } catch { + return found + } + for (const entry of entries) { + if (IGNORED_DIRECTORIES.has(entry.name)) { + continue + } + const full = join(root, entry.name) + if (entry.isDirectory()) { + collectSourceFiles(full, found) + } else if (SCANNED_EXTENSIONS.test(entry.name)) { + found.push(full) + } + } + return found +} + +/** Local names bound to ws's server class, following `as` aliases and namespace imports. */ +function webSocketServerNames(text: string): { direct: Set; namespaces: Set } { + const direct = new Set() + const namespaces = new Set() + // One statement at a time: a pattern reaching for `from 'ws'` would swallow + // every import above it and lose the specifier names in the blob. + for (const match of text.matchAll(/\bimport\b([\s\S]*?)\bfrom\s*(['"])([^'"]+)\2/g)) { + if (match[3] !== 'ws') { + continue + } + const clause = match[1] + if (/^\s*type\b/.test(clause)) { + continue + } + const namespace = clause.match(/\*\s+as\s+([A-Za-z_$][\w$]*)/) + if (namespace) { + namespaces.add(namespace[1]) + } + const named = clause.match(/\{([\s\S]*)\}/) + if (!named) { + continue + } + for (const specifier of named[1].split(',')) { + const trimmed = specifier.trim() + if (!trimmed || /^type\s/.test(trimmed)) { + continue + } + const parts = trimmed.split(/\s+as\s+/) + // `Server` is ws's own alias for WebSocketServer. + if (parts[0].trim() === 'WebSocketServer' || parts[0].trim() === 'Server') { + direct.add((parts[1] ?? parts[0]).trim()) + } + } + } + return { direct, namespaces } +} + +function classify( + scan: WebSocketServerBindScan, + site: BindSite, + text: string, + paren: number +): void { + const options = readCallOptionKeys(text, paren) + if (!options.readable) { + scan.opaque.push({ ...site, reason: options.reason }) + return + } + if (!options.keys.includes('port')) { + scan.attached.push(site) + return + } + if (!options.keys.includes('host')) { + scan.wildcardBound.push(site) + return + } + scan.loopbackBound.push(site) +} + +export function scanWebSocketServerBinds(repoRoot: string): WebSocketServerBindScan { + const files = SCANNED_ROOTS.flatMap((directory) => collectSourceFiles(join(repoRoot, directory))) + const scan: WebSocketServerBindScan = { + filesScanned: files.length, + constructions: 0, + wildcardBound: [], + opaque: [], + loopbackBound: [], + attached: [] + } + for (const file of files) { + const text = readFileSync(file, 'utf8') + // Filter on the import, not on the class name: `Server as Wss` never spells + // WebSocketServer, and keying on that name silently skipped the whole alias. + if (!WS_IMPORT_HINT.test(text)) { + continue + } + const { direct, namespaces } = webSocketServerNames(text) + if (!direct.size && !namespaces.size) { + continue + } + const path = relative(repoRoot, file).split('\\').join('/') + const patterns = [ + ...[...direct].map((name) => new RegExp(`\\bnew\\s+${name}\\s*\\(`, 'g')), + ...[...namespaces].map( + (name) => new RegExp(`\\bnew\\s+${name}\\.(?:WebSocketServer|Server)\\s*\\(`, 'g') + ) + ] + for (const pattern of patterns) { + for (const match of text.matchAll(pattern)) { + scan.constructions++ + const line = text.slice(0, match.index).split('\n').length + classify(scan, { path, line }, text, match.index + match[0].length - 1) + } + } + } + return scan +} + +export function formatSites(sites: readonly BindSite[]): string[] { + return sites.map((site) => `${site.path}:${site.line}`) +} diff --git a/config/scripts/websocket-server-loopback-bind.test.ts b/config/scripts/websocket-server-loopback-bind.test.ts new file mode 100644 index 00000000000..9f32f8eda1a --- /dev/null +++ b/config/scripts/websocket-server-loopback-bind.test.ts @@ -0,0 +1,106 @@ +import { readFileSync } from 'node:fs' +import { join, resolve } from 'node:path' +import { describe, expect, it } from 'vitest' +import { formatSites, scanWebSocketServerBinds } from './websocket-server-bind-scan' + +/** + * Hold the bind address at the tree level rather than per call site. + * + * Every one of the ~30 `.listen(0, ...)` calls in this repo already passes + * '127.0.0.1'; 7 of 7 `new WebSocketServer({ port })` calls did not. Authors know + * the convention -- `ws` just never asks, because `{ port }` alone binds the + * wildcard without a word. That silence is what this test replaces. + * + * The allowlist only shrinks. A new wildcard bind fails here even where it looks + * harmless today, because harmless-looking is exactly what the seven were. + */ +/** The ratchet, held as data so it reads as the list it is. */ +const WILDCARD_BIND_ALLOWLIST: readonly string[] = readFileSync( + join(__dirname, '__fixtures__', 'websocket-server-wildcard-bind-allowlist.txt'), + 'utf8' +) + .split('\n') + .map((line) => line.trim()) + .filter((line) => line.length > 0 && !line.startsWith('#')) + +/** + * The true count of constructions that bind a port without pinning a host. + * + * May only ever be DECREASED, and only by pinning a host. Raising it is never + * the fix. + */ +const WILDCARD_BIND_PIN = 1 + +/** + * A floor under the constructions the scanner still recognizes. + * + * This is the guard against the scanner going blind: an import pattern it stops + * following reports zero offenders and reads exactly like a clean tree. During + * development a single wrong regex dropped this from 24 to 3. + */ +const RECOGNIZED_CONSTRUCTION_FLOOR = 20 + +describe('WebSocketServer loopback bind boundary', () => { + const repoRoot = resolve(__dirname, '..', '..') + const scan = scanWebSocketServerBinds(repoRoot) + const offenders = scan.wildcardBound.map((site) => site.path) + + it('scans a plausible number of files', () => { + // A broken root or extension list would make the guard silently vacuous. + expect(scan.filesScanned).toBeGreaterThan(5_000) + }) + + it('still recognizes the known construction sites', () => { + expect( + scan.constructions, + `Only ${scan.constructions} WebSocketServer constructions were recognized; the floor is ` + + `${RECOGNIZED_CONSTRUCTION_FLOOR}. The scanner has probably stopped following an import ` + + 'shape rather than the tree having lost that many servers.' + ).toBeGreaterThanOrEqual(RECOGNIZED_CONSTRUCTION_FLOOR) + }) + + it('can read the options of every construction it found', () => { + // An unreadable shape is never assumed safe: it could be hiding a host, or + // hiding the absence of one. Rewrite it as a plain object literal. + expect( + scan.opaque.map((site) => `${site.path}:${site.line} -- ${site.reason}`), + 'WebSocketServer options that this guard cannot read.' + ).toEqual([]) + }) + + it('has no wildcard-bound server outside the allowlist', () => { + const unlisted = scan.wildcardBound.filter( + (site) => !WILDCARD_BIND_ALLOWLIST.includes(site.path) + ) + expect( + formatSites(unlisted), + "New WebSocketServer that binds a port without a host. Pass host: '127.0.0.1' so a foreign " + + 'loopback listener cannot claim the port and answer in its place.' + ).toEqual([]) + }) + + it('has no stale allowlist entry', () => { + // Why this direction matters too: an entry left behind after the file was + // fixed hides the next regression in that same path. + const stale = WILDCARD_BIND_ALLOWLIST.filter((path) => !offenders.includes(path)) + expect(stale, 'Allowlist entry no longer binds the wildcard — delete the line.').toEqual([]) + }) + + it('holds the wildcard-bind count at the pin', () => { + // Bounding by the allowlist's own length would prove nothing: the two move + // together, so appending a line to silence a failure would keep the bound + // satisfied. The pin is a literal so that widening takes a second edit. + expect( + scan.wildcardBound.length, + `${scan.wildcardBound.length} constructions bind the wildcard; the pin is ` + + `${WILDCARD_BIND_PIN}. Never raise the pin -- pass host: '127.0.0.1' instead.` + ).toBeLessThanOrEqual(WILDCARD_BIND_PIN) + // A pin left above reality is how a ratchet rots: it re-opens room for the + // next wildcard bind to land for free. + expect( + scan.wildcardBound.length, + `Only ${scan.wildcardBound.length} constructions bind the wildcard. Lower ` + + `WILDCARD_BIND_PIN to ${scan.wildcardBound.length} to keep the ground you just took.` + ).toBeGreaterThanOrEqual(WILDCARD_BIND_PIN) + }) +}) diff --git a/config/tsconfig.tc.web.json b/config/tsconfig.tc.web.json index ea5b5f31a5c..afe5e83024c 100644 --- a/config/tsconfig.tc.web.json +++ b/config/tsconfig.tc.web.json @@ -6,6 +6,17 @@ "../src/renderer/src/**/*.tsx", "../src/preload/api-types.ts", "../src/preload/api/**/*", + "../src/preload/browser-client-page-renderer-requests.ts", + "../src/preload/browser-find-subscriptions.ts", + "../src/preload/close-active-tab-payload-admission.ts", + "../src/preload/e2e-config.ts", + "../src/preload/gitlab.ts", + "../src/preload/preload-runtime-support.ts", + "../src/preload/renderer-heap-statistics-reader.ts", + "../src/preload/renderer-process-memory-reader.ts", + "../src/preload/renderer-restart-wiring.ts", + "../src/preload/runtime-environment-subscriptions.ts", + "../src/preload/usage-provider-api.ts", "../src/shared/**/*", "../src/main/gitlab/mappers.ts", "../src/main/ipc/worktree-branch-name.ts", diff --git a/docs/assets/readme-downloads.svg b/docs/assets/readme-downloads.svg index d44f97bb520..0bde5e2704a 100644 --- a/docs/assets/readme-downloads.svg +++ b/docs/assets/readme-downloads.svg @@ -1,5 +1,5 @@ - - downloads: 34m + + downloads: 35m @@ -15,7 +15,7 @@ downloads downloads - 34m - 34m + 35m + 35m diff --git a/docs/reference/git-compatibility.md b/docs/reference/git-compatibility.md index 3004b8888ab..0e8b1f257d3 100644 --- a/docs/reference/git-compatibility.md +++ b/docs/reference/git-compatibility.md @@ -42,6 +42,17 @@ authority. | `merge-tree-write-tree` | Derive real-merge conflicts and no-op tree proofs | Omit the conflict summary and keep conservative branch cleanup behavior before Git 2.38 | | `merge-tree-merge-base` | Supply the already-resolved merge base | Use the older two-commit `merge-tree --write-tree` form | +### Placeholders That Fail Open + +`GitCapabilityCache` records commands Git *rejects*. A `git log --format` +placeholder Git does not know is not rejected: Git echoes it verbatim and exits +zero, so there is no error to remember and no probe to cache. Ask for both forms +in one record and pick at parse time. + +| Placeholder | Preferred behavior | Compatibility behavior | +| ---------------- | ------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ | +| `%(decorate:…)` | Git 2.43 separates commit decorations with `\x1f`, so ref names containing commas survive | The same record also carries `%D` (Git 2.10); an unexpanded `%(decorate` placeholder selects it, at the cost of comma-splitting | + ## Why Not `simple-git` `simple-git` is a process wrapper around the installed Git binary. Its custom diff --git a/docs/reference/headless-linux-server.md b/docs/reference/headless-linux-server.md index dc3fdf31da0..3d7db834e8e 100644 --- a/docs/reference/headless-linux-server.md +++ b/docs/reference/headless-linux-server.md @@ -56,11 +56,25 @@ of the libraries installed. On Ubuntu 20.04 and 22.04, install `libfuse2` to execute the AppImage through FUSE. On Ubuntu 24.04 and Debian 13 the package is `libfuse2t64`, though the plain `libfuse2` name also resolves there because nothing else provides it. FUSE is -optional: without it, use the AppImage's supported extraction path: +optional: without it, use the AppImage's supported extraction path. CLI +registration does this once automatically, so registered commands do not need +FUSE. + +Download and make the AppImage executable: + +```bash +sudo mkdir -p /opt/orca +sudo curl -L https://github.com/stablyai/orca/releases/latest/download/orca-linux.AppImage \ + -o /opt/orca/orca-linux.AppImage +sudo chmod +x /opt/orca/orca-linux.AppImage +``` + +To extract it without FUSE, run the extraction as root because the installation +directory is root-owned: ```bash cd /opt/orca -./orca-linux.AppImage --appimage-extract +sudo ./orca-linux.AppImage --appimage-extract sudo chmod -R a+rX /opt/orca/squashfs-root /opt/orca/squashfs-root/AppRun serve --port 6768 ``` @@ -76,15 +90,6 @@ extract-and-run wrapper can print extracted paths before Orca starts, so automation that requires stdout to contain only the ready JSON should extract once and invoke `squashfs-root/AppRun`. -Download and make the AppImage executable: - -```bash -sudo mkdir -p /opt/orca -sudo curl -L https://github.com/stablyai/orca/releases/latest/download/orca-linux.AppImage \ - -o /opt/orca/orca-linux.AppImage -sudo chmod +x /opt/orca/orca-linux.AppImage -``` - If `Xvfb` was installed somewhere other than `/usr/bin`, confirm systemd can find it later: diff --git a/docs/reference/wsl-command-execution.md b/docs/reference/wsl-command-execution.md index 92afcf344ad..3a2de549233 100644 --- a/docs/reference/wsl-command-execution.md +++ b/docs/reference/wsl-command-execution.md @@ -2,6 +2,8 @@ Two properties of `wsl.exe` decide how every guest invocation has to be written. Both are silent when you get them wrong: the command still runs and still exits 0, it just returns the wrong bytes. +Those are sections 1 and 2. A closing section answers the question that running Orca's writes +inside a distro raises next: what happens to the distro's disk image. ## 1. Always `--exec`, never `--` @@ -70,3 +72,102 @@ wsl.exe -d --exec /usr/bin/env PATH=… HOME=… /usr/bin/git -C This is what the direct-git read path does. It is immune to both problems above by construction and avoids paying login-shell startup on every call, which also sidesteps profiles that block or print. Resolve the PATH/HOME once through a fenced probe, cache it per distro, then use this form. + +## Disk: the distro VHDX only grows + +Everything above puts Orca's writes inside the distro, which raises a separate question. WSL2 keeps +the entire guest filesystem in a single dynamically-expanding `ext4.vhdx`. Deleting files inside +the distro does free the blocks — for ext4 to reuse — but the host-visible `.vhdx` does not shrink +on its own. + +### Finding the file + +The path depends on how the distro was installed, so do not assume one: + +| Install method | `ext4.vhdx` lives under | +| ---------------------- | --------------------------------------------------------- | +| recent `wsl --install` | `%LOCALAPPDATA%\wsl\{guid}\` | +| Microsoft Store | `%LOCALAPPDATA%\Packages\\LocalState\` | +| `wsl --import` | wherever the operator pointed it | + +The install-agnostic answer is the registry, which records every distro's directory as `BasePath`: + +```powershell +Get-ChildItem HKCU:\Software\Microsoft\Windows\CurrentVersion\Lxss | + ForEach-Object { Get-ItemProperty $_.PSPath } | + Select-Object DistributionName, BasePath +``` + +### Measured behavior + +WSL 2.7.11.0 / Ubuntu-24.04, one machine. Sizes are **size on disk** — allocated bytes, via +`GetCompressedFileSize`, not the logical file length. That distinction matters below: on this +machine the vhdx was not sparse, so the two numbers were identical, but on a sparse vhdx the +logical size stays pinned at the high-water mark while only size on disk falls when space is +reclaimed. Measure the wrong one and reclaim looks like it did nothing. + +| Step | `ext4.vhdx` size on disk (bytes) | +| ---------------------------------- | -------------------------------- | +| baseline | 21,673,017,344 | +| write 1 GiB | 22,746,759,168 | +| delete it | 22,746,759,168 | +| write a fresh incompressible 1 GiB | 22,746,759,168 | +| hold 3 GiB live at once | 24,894,242,816 | + +The fourth row is the point: the second gigabyte cost zero growth, because ext4 handed it the +blocks the first one freed. Only exceeding the previous peak moved the file. + +### Reclaiming space + +Sparse mode lets the guest hand freed blocks back to the host, so the file can shrink instead of +only growing. Two preconditions, both easy to miss: the distro has to be stopped (the vhdx cannot +be converted while it is mounted), and `wsl --manage` exists only on WSL 2.5 and newer — check with +`wsl --version`. + +``` +wsl --terminate +wsl --manage --set-sparse true +``` + +The equivalent for distros not yet created is `sparseVhd = true` under `[experimental]` in +`%UserProfile%\.wslconfig` — that file lives in the Windows user profile, **not** inside the distro +and not at `~/.wslconfig`, and does not exist until you create it. + +Neither touches slack that already exists. For that, shut WSL down and compact the file by hand +from an **elevated** prompt. `compact vdisk` on its own fails because no virtual disk is selected, +so the `select` and the read-only `attach` are required, not optional: + +``` +wsl --shutdown +diskpart +DISKPART> select vdisk file="C:\path\to\ext4.vhdx" +DISKPART> attach vdisk readonly +DISKPART> compact vdisk +DISKPART> detach vdisk +DISKPART> exit +``` + +Two caveats, neither verified here: field reports say `compact vdisk` is a no-op on a vhdx that is +already sparse (convert back with `--set-sparse false` first), and sparse mode's runtime cost was +not measured. Microsoft's [disk-space guide](https://learn.microsoft.com/windows/wsl/disk-space) +carries the current locate/expand/compact procedure and the `--manage` version floor; +[`.wslconfig`](https://learn.microsoft.com/windows/wsl/wsl-config) carries `sparseVhd`. Enabling +sparse mode and compacting are both per-machine decisions; Orca does not make either. + +On the measured machine the vhdx was **not** sparse: `fsutil sparse queryflag` reported "NOT set as +sparse", and no `%UserProfile%\.wslconfig` existed to opt in. Microsoft documents `sparseVhd` as +defaulting to `false`, so that is the expected state rather than a local quirk — but the flag is +per-vhdx, set when the disk is created or by an explicit conversion, so check your own distro +rather than assuming either way. + +### What this means for Orca + +A vhdx that grows as speculative worktree preparation and mirrored worktrees write into the distro +is expected. Its size is monotonically non-decreasing and roughly tracks peak concurrent usage — +but it can drift above peak, and the measurement above is the best case for reuse: the second +gigabyte was allocated immediately after the first was freed, out of the same block group. Under +sustained churn — many worktrees created and removed over weeks, no `fstrim`/discard, sparse off — +allocation spreads and the file settles higher than live peak. + +So growth on its own is not evidence of a leak. Growth well above live peak usage is worth +investigating, and is the case the reclaim steps above address. diff --git a/docs/site/package.json b/docs/site/package.json index 9b5311fa04a..50ea76760fc 100644 --- a/docs/site/package.json +++ b/docs/site/package.json @@ -18,7 +18,7 @@ "fumadocs-mdx": "^14.3.1", "fumadocs-ui": "^16.8.4", "lucide-react": "^1.6.0", - "next": "16.2.1", + "next": "16.3.4", "react": "19.2.4", "react-dom": "19.2.4", "tailwind-merge": "^3.5.0", @@ -32,10 +32,10 @@ "@types/react": "^19", "@types/react-dom": "^19", "eslint": "^9", - "eslint-config-next": "16.2.1", + "eslint-config-next": "16.3.4", "tailwindcss": "^4", "typescript": "^5", - "vercel": "50.37.0" + "vercel": "59.11.1" }, "engines": { "node": "22.x" @@ -46,6 +46,13 @@ "esbuild", "sharp", "unrs-resolver" - ] + ], + "overrides": { + "@vercel/fun>tar": "7.5.22", + "@vercel/fun>@tootallnate/once": "2.0.1", + "@vercel/node>undici": "5.29.0", + "@vercel/python-analysis>js-yaml": "4.3.2", + "@vercel/python-analysis>minimatch": "10.2.6" + } } } diff --git a/docs/site/pnpm-lock.yaml b/docs/site/pnpm-lock.yaml index 7cfd0ceee6f..4320f1bd617 100644 --- a/docs/site/pnpm-lock.yaml +++ b/docs/site/pnpm-lock.yaml @@ -4,6 +4,13 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false +overrides: + '@vercel/fun>tar': 7.5.22 + '@vercel/fun>@tootallnate/once': 2.0.1 + '@vercel/node>undici': 5.29.0 + '@vercel/python-analysis>js-yaml': 4.3.2 + '@vercel/python-analysis>minimatch': 10.2.6 + importers: .: @@ -13,19 +20,19 @@ importers: version: 2.1.1 fumadocs-core: specifier: ^16.8.4 - version: 16.12.1(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.26.0(react@19.2.4))(next@16.2.1(@babel/core@7.29.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.4.3) + version: 16.12.1(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.26.0(react@19.2.4))(next@16.3.4(@babel/core@7.29.7)(@types/node@20.19.43)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.4.3) fumadocs-mdx: specifier: ^14.3.1 - version: 14.3.2(@types/mdast@4.0.4)(@types/mdx@2.0.14)(@types/react@19.2.17)(fumadocs-core@16.12.1(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.26.0(react@19.2.4))(next@16.2.1(@babel/core@7.29.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.4.3))(next@16.2.1(@babel/core@7.29.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4) + version: 14.3.2(@types/mdast@4.0.4)(@types/mdx@2.0.14)(@types/react@19.2.17)(fumadocs-core@16.12.1(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.26.0(react@19.2.4))(next@16.3.4(@babel/core@7.29.7)(@types/node@20.19.43)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.4.3))(next@16.3.4(@babel/core@7.29.7)(@types/node@20.19.43)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4) fumadocs-ui: specifier: ^16.8.4 - version: 16.12.1(@types/mdx@2.0.14)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(fumadocs-core@16.12.1(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.26.0(react@19.2.4))(next@16.2.1(@babel/core@7.29.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.4.3))(next@16.2.1(@babel/core@7.29.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(tailwindcss@4.3.3) + version: 16.12.1(@types/mdx@2.0.14)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(fumadocs-core@16.12.1(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.26.0(react@19.2.4))(next@16.3.4(@babel/core@7.29.7)(@types/node@20.19.43)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.4.3))(next@16.3.4(@babel/core@7.29.7)(@types/node@20.19.43)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(tailwindcss@4.3.3) lucide-react: specifier: ^1.6.0 version: 1.26.0(react@19.2.4) next: - specifier: 16.2.1 - version: 16.2.1(@babel/core@7.29.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + specifier: 16.3.4 + version: 16.3.4(@babel/core@7.29.7)(@types/node@20.19.43)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) react: specifier: 19.2.4 version: 19.2.4 @@ -61,8 +68,8 @@ importers: specifier: ^9 version: 9.39.5(jiti@2.7.0) eslint-config-next: - specifier: 16.2.1 - version: 16.2.1(@typescript-eslint/parser@8.65.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3) + specifier: 16.3.4 + version: 16.3.4(@typescript-eslint/parser@8.65.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3) tailwindcss: specifier: ^4 version: 4.3.3 @@ -70,8 +77,8 @@ importers: specifier: ^5 version: 5.9.3 vercel: - specifier: 50.37.0 - version: 50.37.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.2)(typescript@5.9.3) + specifier: 59.11.1 + version: 59.11.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3) packages: @@ -175,8 +182,8 @@ packages: '@emnapi/runtime@1.10.0': resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==} - '@emnapi/runtime@1.11.2': - resolution: {integrity: sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==} + '@emnapi/runtime@1.11.3': + resolution: {integrity: sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==} '@emnapi/wasi-threads@1.2.1': resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} @@ -499,6 +506,12 @@ packages: peerDependencies: eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + '@eslint-community/eslint-utils@4.9.1': + resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + '@eslint-community/regexpp@4.12.2': resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} @@ -588,154 +601,152 @@ packages: resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} engines: {node: '>=18.18'} - '@iarna/toml@2.2.5': - resolution: {integrity: sha512-trnsAYxU3xnS1gPHPyU961coFyLkh4gAD/0zQ5mymY4yOZ+CYvsPqUbOFSw0aDM4y0tV7tiFxL/1XfXPNC6IPg==} - '@img/colour@1.1.0': resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} engines: {node: '>=18'} - '@img/sharp-darwin-arm64@0.34.5': - resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-darwin-arm64@0.35.4': + resolution: {integrity: sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==} + engines: {node: '>=20.9.0'} cpu: [arm64] os: [darwin] - '@img/sharp-darwin-x64@0.34.5': - resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-darwin-x64@0.35.4': + resolution: {integrity: sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==} + engines: {node: '>=20.9.0'} cpu: [x64] os: [darwin] - '@img/sharp-libvips-darwin-arm64@1.2.4': - resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==} + '@img/sharp-freebsd-wasm32@0.35.4': + resolution: {integrity: sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==} + engines: {node: '>=20.9.0'} + os: [freebsd] + + '@img/sharp-libvips-darwin-arm64@1.3.3': + resolution: {integrity: sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==} cpu: [arm64] os: [darwin] - '@img/sharp-libvips-darwin-x64@1.2.4': - resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==} + '@img/sharp-libvips-darwin-x64@1.3.3': + resolution: {integrity: sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==} cpu: [x64] os: [darwin] - '@img/sharp-libvips-linux-arm64@1.2.4': - resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} + '@img/sharp-libvips-linux-arm64@1.3.3': + resolution: {integrity: sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==} cpu: [arm64] os: [linux] - '@img/sharp-libvips-linux-arm@1.2.4': - resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} + '@img/sharp-libvips-linux-arm@1.3.3': + resolution: {integrity: sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==} cpu: [arm] os: [linux] - '@img/sharp-libvips-linux-ppc64@1.2.4': - resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} + '@img/sharp-libvips-linux-ppc64@1.3.3': + resolution: {integrity: sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==} cpu: [ppc64] os: [linux] - '@img/sharp-libvips-linux-riscv64@1.2.4': - resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} + '@img/sharp-libvips-linux-riscv64@1.3.3': + resolution: {integrity: sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==} cpu: [riscv64] os: [linux] - '@img/sharp-libvips-linux-s390x@1.2.4': - resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} + '@img/sharp-libvips-linux-s390x@1.3.3': + resolution: {integrity: sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==} cpu: [s390x] os: [linux] - '@img/sharp-libvips-linux-x64@1.2.4': - resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} + '@img/sharp-libvips-linux-x64@1.3.3': + resolution: {integrity: sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==} cpu: [x64] os: [linux] - '@img/sharp-libvips-linuxmusl-arm64@1.2.4': - resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} + '@img/sharp-libvips-linuxmusl-arm64@1.3.3': + resolution: {integrity: sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==} cpu: [arm64] os: [linux] - '@img/sharp-libvips-linuxmusl-x64@1.2.4': - resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} + '@img/sharp-libvips-linuxmusl-x64@1.3.3': + resolution: {integrity: sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==} cpu: [x64] os: [linux] - '@img/sharp-linux-arm64@0.34.5': - resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-linux-arm64@0.35.4': + resolution: {integrity: sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==} + engines: {node: '>=20.9.0'} cpu: [arm64] os: [linux] - '@img/sharp-linux-arm@0.34.5': - resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-linux-arm@0.35.4': + resolution: {integrity: sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==} + engines: {node: '>=20.9.0'} cpu: [arm] os: [linux] - '@img/sharp-linux-ppc64@0.34.5': - resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-linux-ppc64@0.35.4': + resolution: {integrity: sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==} + engines: {node: '>=20.9.0'} cpu: [ppc64] os: [linux] - '@img/sharp-linux-riscv64@0.34.5': - resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-linux-riscv64@0.35.4': + resolution: {integrity: sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==} + engines: {node: '>=20.9.0'} cpu: [riscv64] os: [linux] - '@img/sharp-linux-s390x@0.34.5': - resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-linux-s390x@0.35.4': + resolution: {integrity: sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==} + engines: {node: '>=20.9.0'} cpu: [s390x] os: [linux] - '@img/sharp-linux-x64@0.34.5': - resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-linux-x64@0.35.4': + resolution: {integrity: sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==} + engines: {node: '>=20.9.0'} cpu: [x64] os: [linux] - '@img/sharp-linuxmusl-arm64@0.34.5': - resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-linuxmusl-arm64@0.35.4': + resolution: {integrity: sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==} + engines: {node: '>=20.9.0'} cpu: [arm64] os: [linux] - '@img/sharp-linuxmusl-x64@0.34.5': - resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-linuxmusl-x64@0.35.4': + resolution: {integrity: sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==} + engines: {node: '>=20.9.0'} cpu: [x64] os: [linux] - '@img/sharp-wasm32@0.34.5': - resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-wasm32@0.35.4': + resolution: {integrity: sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==} + engines: {node: '>=20.9.0'} + + '@img/sharp-webcontainers-wasm32@0.35.4': + resolution: {integrity: sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==} + engines: {node: '>=20.9.0'} cpu: [wasm32] - '@img/sharp-win32-arm64@0.34.5': - resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-win32-arm64@0.35.4': + resolution: {integrity: sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==} + engines: {node: '>=20.9.0'} cpu: [arm64] os: [win32] - '@img/sharp-win32-ia32@0.34.5': - resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-win32-ia32@0.35.4': + resolution: {integrity: sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==} + engines: {node: ^20.9.0} cpu: [ia32] os: [win32] - '@img/sharp-win32-x64@0.34.5': - resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-win32-x64@0.35.4': + resolution: {integrity: sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==} + engines: {node: '>=20.9.0'} cpu: [x64] os: [win32] - '@isaacs/balanced-match@4.0.1': - resolution: {integrity: sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==} - engines: {node: 20 || >=22} - - '@isaacs/brace-expansion@5.0.1': - resolution: {integrity: sha512-WMz71T1JS624nWj2n2fnYAuPovhv7EUhk69R6i9dsVyzxt5eM3bjwvgk9L+APE1TRscGysAVMANkB0jh0LQZrQ==} - engines: {node: 20 || >=22} - '@isaacs/fs-minipass@4.0.1': resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==} engines: {node: '>=18.0.0'} @@ -764,62 +775,138 @@ packages: '@mdx-js/mdx@3.1.1': resolution: {integrity: sha512-f6ZO2ifpwAQIpzGWaBQT2TXxPv6z3RBzQKpVftEWN78Vl/YweF1uwussDx8ECAXVtr3Rs89fKyG9YlzUs9DyGQ==} + '@napi-rs/keyring-darwin-arm64@1.2.0': + resolution: {integrity: sha512-CA83rDeyONDADO25JLZsh3eHY8yTEtm/RS6ecPsY+1v+dSawzT9GywBMu2r6uOp1IEhQs/xAfxgybGAFr17lSA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@napi-rs/keyring-darwin-x64@1.2.0': + resolution: {integrity: sha512-dBHjtKRCj4ByfnfqIKIJLo3wueQNJhLRyuxtX/rR4K/XtcS7VLlRD01XXizjpre54vpmObj63w+ZpHG+mGM8uA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@napi-rs/keyring-freebsd-x64@1.2.0': + resolution: {integrity: sha512-DPZFr11pNJSnaoh0dzSUNF+T6ORhy3CkzUT3uGixbA71cAOPJ24iG8e8QrLOkuC/StWrAku3gBnth2XMWOcR3Q==} + engines: {node: '>= 10'} + cpu: [x64] + os: [freebsd] + + '@napi-rs/keyring-linux-arm-gnueabihf@1.2.0': + resolution: {integrity: sha512-8xv6DyEMlvRdqJzp4F39RLUmmTQsLcGYYv/3eIfZNZN1O5257tHxTrFYqAsny659rJJK2EKeSa7PhrSibQqRWQ==} + engines: {node: '>= 10'} + cpu: [arm] + os: [linux] + + '@napi-rs/keyring-linux-arm64-gnu@1.2.0': + resolution: {integrity: sha512-Pu2V6Py+PBt7inryEecirl+t+ti8bhZphjP+W68iVaXHUxLdWmkgL9KI1VkbRHbx5k8K5Tew9OP218YfmVguIA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@napi-rs/keyring-linux-arm64-musl@1.2.0': + resolution: {integrity: sha512-8TDymrpC4P1a9iDEaegT7RnrkmrJN5eNZh3Im3UEV5PPYGtrb82CRxsuFohthCWQW81O483u1bu+25+XA4nKUw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@napi-rs/keyring-linux-riscv64-gnu@1.2.0': + resolution: {integrity: sha512-awsB5XI1MYL7fwfjMDGmKOWvNgJEO7mM7iVEMS0fO39f0kVJnOSjlu7RHcXAF0LOx+0VfF3oxbWqJmZbvRCRHw==} + engines: {node: '>= 10'} + cpu: [riscv64] + os: [linux] + + '@napi-rs/keyring-linux-x64-gnu@1.2.0': + resolution: {integrity: sha512-8E+7z4tbxSJXxIBqA+vfB1CGajpCDRyTyqXkBig5NtASrv4YXcntSo96Iah2QDR5zD3dSTsmbqJudcj9rKKuHQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@napi-rs/keyring-linux-x64-musl@1.2.0': + resolution: {integrity: sha512-8RZ8yVEnmWr/3BxKgBSzmgntI7lNEsY7xouNfOsQkuVAiCNmxzJwETspzK3PQ2FHtDxgz5vHQDEBVGMyM4hUHA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@napi-rs/keyring-win32-arm64-msvc@1.2.0': + resolution: {integrity: sha512-AoqaDZpQ6KPE19VBLpxyORcp+yWmHI9Xs9Oo0PJ4mfHma4nFSLVdhAubJCxdlNptHe5va7ghGCHj3L9Akiv4cQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@napi-rs/keyring-win32-ia32-msvc@1.2.0': + resolution: {integrity: sha512-EYL+EEI6bCsYi3LfwcQdnX3P/R76ENKNn+3PmpGheBsUFLuh0gQuP7aMVHM4rTw6UVe+L3vCLZSptq/oeacz0A==} + engines: {node: '>= 10'} + cpu: [ia32] + os: [win32] + + '@napi-rs/keyring-win32-x64-msvc@1.2.0': + resolution: {integrity: sha512-xFlx/TsmqmCwNU9v+AVnEJgoEAlBYgzFF5Ihz1rMpPAt4qQWWkMd4sCyM1gMJ1A/GnRqRegDiQpwaxGUHFtFbA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@napi-rs/keyring@1.2.0': + resolution: {integrity: sha512-d0d4Oyxm+v980PEq1ZH2PmS6cvpMIRc17eYpiU47KgW+lzxklMu6+HOEOPmxrpnF/XQZ0+Q78I2mgMhbIIo/dg==} + engines: {node: '>= 10'} + '@napi-rs/wasm-runtime@1.1.6': resolution: {integrity: sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==} peerDependencies: '@emnapi/core': ^1.7.1 '@emnapi/runtime': ^1.7.1 - '@next/env@16.2.1': - resolution: {integrity: sha512-n8P/HCkIWW+gVal2Z8XqXJ6aB3J0tuM29OcHpCsobWlChH/SITBs1DFBk/HajgrwDkqqBXPbuUuzgDvUekREPg==} + '@next/env@16.3.4': + resolution: {integrity: sha512-cjWZnUUa6jZq2kFaNe/ZyJdZonOZ/QoN0Zka2nz/FLOrfx14pQuM9c5RaSVkWMqgdt4ksgPAMWPyHSs/CyV48Q==} - '@next/eslint-plugin-next@16.2.1': - resolution: {integrity: sha512-r0epZGo24eT4g08jJlg2OEryBphXqO8aL18oajoTKLzHJ6jVr6P6FI58DLMug04MwD3j8Fj0YK0slyzneKVyzA==} + '@next/eslint-plugin-next@16.3.4': + resolution: {integrity: sha512-szW9y2Aumu4z88YXfTzcFsgUAg2k64uzbtcO5L9f1AKS4w/GUKJcbFllRflROVyNPgJtGOnvNxiyp3v6b+prIA==} - '@next/swc-darwin-arm64@16.2.1': - resolution: {integrity: sha512-BwZ8w8YTaSEr2HIuXLMLxIdElNMPvY9fLqb20LX9A9OMGtJilhHLbCL3ggyd0TwjmMcTxi0XXt+ur1vWUoxj2Q==} + '@next/swc-darwin-arm64@16.3.4': + resolution: {integrity: sha512-iBr3I5LZNk5/bgl5//iTgD2tcym14MX0Xo7fD//u9dYAEgGzza1y9oywluPtf74YnOswVdH1908aK9xVz7zQTw==} engines: {node: '>= 10'} cpu: [arm64] os: [darwin] - '@next/swc-darwin-x64@16.2.1': - resolution: {integrity: sha512-/vrcE6iQSJq3uL3VGVHiXeaKbn8Es10DGTGRJnRZlkNQQk3kaNtAJg8Y6xuAlrx/6INKVjkfi5rY0iEXorZ6uA==} + '@next/swc-darwin-x64@16.3.4': + resolution: {integrity: sha512-2dpiSyl2Jw/NrBPaU2MAKGSa+2MR82pJIn4Sm5Rjr+gxAeuh0z158Su3Z2O8zn7UNNq+ej4bToed6RcRN/Lydg==} engines: {node: '>= 10'} cpu: [x64] os: [darwin] - '@next/swc-linux-arm64-gnu@16.2.1': - resolution: {integrity: sha512-uLn+0BK+C31LTVbQ/QU+UaVrV0rRSJQ8RfniQAHPghDdgE+SlroYqcmFnO5iNjNfVWCyKZHYrs3Nl0mUzWxbBw==} + '@next/swc-linux-arm64-gnu@16.3.4': + resolution: {integrity: sha512-+t+U8HZT+fApePCS5h89CSH3datz29MkzyfCn+6fpsZBG/oiEOhINcb9rtkv6sdpToLGFn2e6146NzaKCXkqrA==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - '@next/swc-linux-arm64-musl@16.2.1': - resolution: {integrity: sha512-ssKq6iMRnHdnycGp9hCuGnXJZ0YPr4/wNwrfE5DbmvEcgl9+yv97/Kq3TPVDfYome1SW5geciLB9aiEqKXQjlQ==} + '@next/swc-linux-arm64-musl@16.3.4': + resolution: {integrity: sha512-mx03GNs1ocQA5JQ4FxDMmIsNkdrZh8cuezKCrId28e5/gIPU/l7Kcy2+vmCCzdjnnmXJy+iOAu+7K0QppO6Urg==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - '@next/swc-linux-x64-gnu@16.2.1': - resolution: {integrity: sha512-HQm7SrHRELJ30T1TSmT706IWovFFSRGxfgUkyWJZF/RKBMdbdRWJuFrcpDdE5vy9UXjFOx6L3mRdqH04Mmx0hg==} + '@next/swc-linux-x64-gnu@16.3.4': + resolution: {integrity: sha512-YIhGY6fSMfha52bnVxnzc9zaVBzJg+cqQTOD8tXIBSx4fuv0pVMxQTE0PaS59YhnMOiYiG09IMwxJAf/CFm/Dw==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - '@next/swc-linux-x64-musl@16.2.1': - resolution: {integrity: sha512-aV2iUaC/5HGEpbBkE+4B8aHIudoOy5DYekAKOMSHoIYQ66y/wIVeaRx8MS2ZMdxe/HIXlMho4ubdZs/J8441Tg==} + '@next/swc-linux-x64-musl@16.3.4': + resolution: {integrity: sha512-+eaaX6axpDb0yF1GCpiERe6njplvdC+nks/fKfcHu3XPGRrald8P3/X7yv7QLdjA51knnxwl9pxdIJsg+w1L+Q==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - '@next/swc-win32-arm64-msvc@16.2.1': - resolution: {integrity: sha512-IXdNgiDHaSk0ZUJ+xp0OQTdTgnpx1RCfRTalhn3cjOP+IddTMINwA7DXZrwTmGDO8SUr5q2hdP/du4DcrB1GxA==} + '@next/swc-win32-arm64-msvc@16.3.4': + resolution: {integrity: sha512-0jcXW7Xs/uzICrmgV3MhDYDeRy++1CqnpDIerlPIqYO4bhzB4WNbX/aRnQclustsAyTkFKB0z6rbcjmNg5tR8A==} engines: {node: '>= 10'} cpu: [arm64] os: [win32] - '@next/swc-win32-x64-msvc@16.2.1': - resolution: {integrity: sha512-qvU+3a39Hay+ieIztkGSbF7+mccbbg1Tk25hc4JDylf8IHjYmY/Zm64Qq1602yPyQqvie+vf5T/uPwNxDNIoeg==} + '@next/swc-win32-x64-msvc@16.3.4': + resolution: {integrity: sha512-vvBzwu1pYQCp92maZCFCIw/XgOTMR5tur9GjakwIo2cmwRTMKajRZZDS9+e4KsUZWKu1E007WUeAFXRRjZeuzw==} engines: {node: '>= 10'} cpu: [x64] os: [win32] @@ -844,9 +931,131 @@ packages: resolution: {integrity: sha512-a61ljmRVVyG5MC/698C8/FfFDw5a8LOIvyOLW5fztgUXqUpc1jOfQzOitSCbge657OgXXThmY3Tk8fpiDb4UcA==} engines: {node: '>= 20.0.0'} + '@oxc-parser/binding-android-arm-eabi@0.121.0': + resolution: {integrity: sha512-n07FQcySwOlzap424/PLMtOkbS7xOu8nsJduKL8P3COGHKgKoDYXwoAHCbChfgFpHnviehrLWIPX0lKGtbEk/A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@oxc-parser/binding-android-arm64@0.121.0': + resolution: {integrity: sha512-/Dd1xIXboYAicw+twT2utxPD7bL8qh7d3ej0qvaYIMj3/EgIrGR+tSnjCUkiCT6g6uTC0neSS4JY8LxhdSU/sA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@oxc-parser/binding-darwin-arm64@0.121.0': + resolution: {integrity: sha512-A0jNEvv7QMtCO1yk205t3DWU9sWUjQ2KNF0hSVO5W9R9r/R1BIvzG01UQAfmtC0dQm7sCrs5puixurKSfr2bRQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@oxc-parser/binding-darwin-x64@0.121.0': + resolution: {integrity: sha512-SsHzipdxTKUs3I9EOAPmnIimEeJOemqRlRDOp9LIj+96wtxZejF51gNibmoGq8KoqbT1ssAI5po/E3J+vEtXGA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@oxc-parser/binding-freebsd-x64@0.121.0': + resolution: {integrity: sha512-v1APOTkCp+RWOIDAHRoaeW/UoaHF15a60E8eUL6kUQXh+i4K7PBwq2Wi7jm8p0ymID5/m/oC1w3W31Z/+r7HQw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@oxc-parser/binding-linux-arm-gnueabihf@0.121.0': + resolution: {integrity: sha512-PmqPQuqHZyFVWA4ycr0eu4VnTMmq9laOHZd+8R359w6kzuNZPvmmunmNJ8ybkm769A0nCoVp3TJ6dUz7B3FYIQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxc-parser/binding-linux-arm-musleabihf@0.121.0': + resolution: {integrity: sha512-vF24htj+MOH+Q7y9A8NuC6pUZu8t/C2Fr/kDOi2OcNf28oogr2xadBPXAbml802E8wRAVfbta6YLDQTearz+jw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxc-parser/binding-linux-arm64-gnu@0.121.0': + resolution: {integrity: sha512-wjH8cIG2Lu/3d64iZpbYr73hREMgKAfu7fqpXjgM2S16y2zhTfDIp8EQjxO8vlDtKP5Rc7waZW72lh8nZtWrpA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@oxc-parser/binding-linux-arm64-musl@0.121.0': + resolution: {integrity: sha512-qT663J/W8yQFw3dtscbEi9LKJevr20V7uWs2MPGTnvNZ3rm8anhhE16gXGpxDOHeg9raySaSHKhd4IGa3YZvuw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@oxc-parser/binding-linux-ppc64-gnu@0.121.0': + resolution: {integrity: sha512-mYNe4NhVvDBbPkAP8JaVS8lC1dsoJZWH5WCjpw5E+sjhk1R08wt3NnXYUzum7tIiWPfgQxbCMcoxgeemFASbRw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + + '@oxc-parser/binding-linux-riscv64-gnu@0.121.0': + resolution: {integrity: sha512-+QiFoGxhAbaI/amqX567784cDyyuZIpinBrJNxUzb+/L2aBRX67mN6Jv40pqduHf15yYByI+K5gUEygCuv0z9w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + + '@oxc-parser/binding-linux-riscv64-musl@0.121.0': + resolution: {integrity: sha512-9ykEgyTa5JD/Uhv2sttbKnCfl2PieUfOjyxJC/oDL2UO0qtXOtjPLl7H8Kaj5G7p3hIvFgu3YWvAxvE0sqY+hQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + + '@oxc-parser/binding-linux-s390x-gnu@0.121.0': + resolution: {integrity: sha512-DB1EW5VHZdc1lIRjOI3bW/wV6R6y0xlfvdVrqj6kKi7Ayu2U3UqUBdq9KviVkcUGd5Oq+dROqvUEEFRXGAM7EQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + + '@oxc-parser/binding-linux-x64-gnu@0.121.0': + resolution: {integrity: sha512-s4lfobX9p4kPTclvMiH3gcQUd88VlnkMTF6n2MTMDAyX5FPNRhhRSFZK05Ykhf8Zy5NibV4PbGR6DnK7FGNN6A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@oxc-parser/binding-linux-x64-musl@0.121.0': + resolution: {integrity: sha512-P9KlyTpuBuMi3NRGpJO8MicuGZfOoqZVRP1WjOecwx8yk4L/+mrCRNc5egSi0byhuReblBF2oVoDSMgV9Bj4Hw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@oxc-parser/binding-openharmony-arm64@0.121.0': + resolution: {integrity: sha512-R+4jrWOfF2OAPPhj3Eb3U5CaKNAH9/btMveMULIrcNW/hjfysFQlF8wE0GaVBr81dWz8JLgQlsxwctoL78JwXw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@oxc-parser/binding-wasm32-wasi@0.121.0': + resolution: {integrity: sha512-5TFISkPTymKvsmIlKasPVTPuWxzCcrT8pM+p77+mtQbIZDd1UC8zww4CJcRI46kolmgrEX6QpKO8AvWMVZ+ifw==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + + '@oxc-parser/binding-win32-arm64-msvc@0.121.0': + resolution: {integrity: sha512-V0pxh4mql4XTt3aiEtRNUeBAUFOw5jzZNxPABLaOKAWrVzSr9+XUaB095lY7jqMf5t8vkfh8NManGB28zanYKw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@oxc-parser/binding-win32-ia32-msvc@0.121.0': + resolution: {integrity: sha512-4Ob1qvYMPnlF2N9rdmKdkQFdrq16QVcQwBsO8yiPZXof0fHKFF+LmQV501XFbi7lHyrKm8rlJRfQ/M8bZZPVLw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ia32] + os: [win32] + + '@oxc-parser/binding-win32-x64-msvc@0.121.0': + resolution: {integrity: sha512-BOp1KCzdboB1tPqoCPXgntgFs0jjeSyOXHzgxVFR7B/qfr3F8r4YDacHkTOUNXtDgM8YwKnkf3rE5gwALYX7NA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + '@oxc-project/types@0.110.0': resolution: {integrity: sha512-6Ct21OIlrEnFEJk5LT4e63pk3btsI6/TusD/GStLi7wYlGJNOl1GI9qvXAnRAxQU9zqA2Oz+UwhfTOU2rPZVow==} + '@oxc-project/types@0.121.0': + resolution: {integrity: sha512-CGtOARQb9tyv7ECgdAlFxi0Fv7lmzvmlm2rpD/RdijOO9rfk/JvB1CjT8EnoD+tjna/IYgKKw3IV7objRb+aYw==} + '@oxc-transform/binding-android-arm-eabi@0.111.0': resolution: {integrity: sha512-NdFLicvorfHYu0g2ftjVJaH7+Dz27AQUNJOq8t/ofRUoWmczOodgUCHx8C1M1htCN4ZmhS/FzfSy6yd/UngJGg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1455,8 +1664,8 @@ packages: '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} - '@swc/helpers@0.5.15': - resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} + '@swc/helpers@0.5.23': + resolution: {integrity: sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==} '@tailwindcss/node@4.3.3': resolution: {integrity: sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==} @@ -1546,13 +1755,10 @@ packages: '@tailwindcss/postcss@4.3.3': resolution: {integrity: sha512-JTSZZGQi1AyKirbLN3azmjVzef92tcX7h+iSqPdaeStyFpGpDlKvvpxeOE8njhbUanbRwr3z8DyzhICWnMtQeg==} - '@tootallnate/once@2.0.0': - resolution: {integrity: sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==} + '@tootallnate/once@2.0.1': + resolution: {integrity: sha512-HqmEUIGRJ5fSXchkVgR5F7qn48bDBzv0kWj/Kfu5e6uci4UlEeng4331LnBkWffb++Ei3FOVLxo8JJWMFBDMeQ==} engines: {node: '>= 10'} - '@tootallnate/quickjs-emscripten@0.23.0': - resolution: {integrity: sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==} - '@ts-morph/common@0.11.1': resolution: {integrity: sha512-7hWZS0NRpEsNV8vWJzg7FEz6V8MaLNeJOmwmghqUXTpzk16V1LLZhdo+4QvE/+zv4cVci0OviuJFnqhEfoV3+g==} @@ -1778,105 +1984,186 @@ packages: cpu: [x64] os: [win32] - '@vercel/backends@0.0.51': - resolution: {integrity: sha512-rGuyw79vubB9VyXhb5eMvm3uNe7D3avDHNm4zXbF99m54346KNOvRp0jJ39rBgh6I4wQ1SUal/vUYcs+nDI3DQ==} + '@vercel/backends@7.0.0': + resolution: {integrity: sha512-he5zmmtKzzaoizZhPXHxd9bOge3pOL90uz33b9IzmGnImWBlkSOPtGQr5r+NX2ad8HS5HWBdUchwAEcnxYz66w==} peerDependencies: - typescript: ^4.0.0 || ^5.0.0 + '@vercel/build-utils': 14.9.0 - '@vercel/blob@2.3.0': - resolution: {integrity: sha512-oYWiJbWRQ7gz9Mj0X/NHFJ3OcLMOBzq/2b3j6zeNrQmtFo6dHwU8FAwNpxVIYddVMd+g8eqEi7iRueYx8FtM0Q==} + '@vercel/blob@2.8.0': + resolution: {integrity: sha512-Nu+HWKpkgovCh/ezlG7wCVwF7RErTzLzZMbGKFBdGBCbTKyK+s5VXPLl+0+TpNEQPH8AVaGzOpIsXUOtkqylCQ==} engines: {node: '>=20.0.0'} - '@vercel/build-utils@13.10.0': - resolution: {integrity: sha512-i+fF1EvEzZhrifP1qUYklTmrUTmndPfsvWGLsv9TaDm5WqX8VOp8irUAhOwc5gc5RHEOs4Ox0GtiPhJ7oZ59cw==} + '@vercel/build-utils@14.9.0': + resolution: {integrity: sha512-czxOQSyZgFYZoD72gRSkRSokGghDyh5pMBPiuy0bjq2I4t7vjiENF1FN0NI/em5S/ITh2hKN1kzXHFwQy9jg6g==} - '@vercel/cervel@0.0.38': - resolution: {integrity: sha512-1/802XtFsfOmZH7hNTQqlMv/8rSNwTOkJPY0uU1CgXk7yYMG9nq2uOTF1eumx+0TwMc8cO9X9azOi35lGP++yw==} + '@vercel/cervel@0.1.59': + resolution: {integrity: sha512-zFpD1AWHher/SYpwJAZTnw9ncf3qVdPWGG0fTUz0dTlO7nkdV67zXLej46y+PrbML1LtHXMqBQRauM9Y8uH+wg==} hasBin: true - peerDependencies: - typescript: ^4.0.0 || ^5.0.0 - '@vercel/detect-agent@1.2.1': - resolution: {integrity: sha512-U/BJCltQSTFTHwaiCQQTQG3GonTbRoEewjV+OU2mMjcHLAoPOh6CP1SXA2XNmqiqI3c82nkRNJ7piZ14RqmTXw==} + '@vercel/cli-auth@0.3.5': + resolution: {integrity: sha512-DSkTWamJrhgCUrymOSCWysbiVeLsvPINhoKVTw+mypoyIJxKQxDgQaafvZ0OYGS2qg8wVUY30HgDugzaEdwo6Q==} + + '@vercel/cli-config@0.2.4': + resolution: {integrity: sha512-kZ5SojbrV06GHoU6QIWGwDXLov+s9rWZ7QqdqKfJfBGCNUieGfgaCjeeenNy8Y+QC0bwC0dZ2B4l5Hvdmrgpdw==} + + '@vercel/cli-exec@1.0.1': + resolution: {integrity: sha512-g9XerViJ/paZujufXYcu5XYI2vU2rtB4sgdpjUHde5RnOkdmpu0ngH46LCFGHoPXO/C+qDPSczIHIRN+8Q2YKQ==} + engines: {node: '>= 18'} + + '@vercel/container@7.0.0': + resolution: {integrity: sha512-VhQAJv8j6/ufedWYAbwjJ94p3R0MfNYiJmamr68NeFVGJlv6sFNm1SJb1Rzl+6udK44BcQp3M64qKFO4ARNLFA==} + peerDependencies: + '@vercel/build-utils': 14.9.0 + + '@vercel/detect-agent@1.2.5': + resolution: {integrity: sha512-0krENrjuitlW8s6TJu0MlqCevyCU7K7JK63jZAf7xZ6n17tx+vUEwzHT3sTxawtwZxaW21hu+oFUpoOrm49FsQ==} engines: {node: '>=14'} - '@vercel/elysia@0.1.53': - resolution: {integrity: sha512-SdQP06NbODpTOBc6NRV9y/5vpV4wfBZFpWZiJ8oUj5F9POPR7tz+FempLi/YfTv5OuYiA76K3mugNFFEOtpyrg==} + '@vercel/elysia@7.0.0': + resolution: {integrity: sha512-EZgu9HXQVf1af7sElg3tws+0TTT/k1DgPyDftJnXfk1L3W8M31pqbBY3a6I1hcnWxNaNceF2VtSWMPNrmsTOVQ==} + peerDependencies: + '@vercel/build-utils': 14.9.0 - '@vercel/error-utils@2.0.3': - resolution: {integrity: sha512-CqC01WZxbLUxoiVdh9B/poPbNpY9U+tO1N9oWHwTl5YAZxcqXmmWJ8KNMFItJCUUWdY3J3xv8LvAuQv2KZ5YdQ==} + '@vercel/error-utils@2.2.1': + resolution: {integrity: sha512-9DhP8jP7raLML4hGsBemxX5fXuQnu5xxMV+HjGygGbzEmVK/+KyJ3QP2Cw7PdF0uXdb9N0Qa4c3tRGH34ZX6vw==} - '@vercel/express@0.1.63': - resolution: {integrity: sha512-hUQk+rVo+26NH8DAqXrxiC9j2ajvMlz6iwY+KQYFwHxh9mI+NmeBRD8ELJdl54Z5tEeec8BQ/9giKmbu+NpzkA==} + '@vercel/express@7.0.0': + resolution: {integrity: sha512-dhGOtQk3HJXQBeFyfiDP0/nIWqwzrvN02rr/tF8zEk45Z6xqLlSkvH47lVPlvVQdoPy93MOlwKA7/gYapi1chg==} + peerDependencies: + '@vercel/build-utils': 14.9.0 - '@vercel/fastify@0.1.56': - resolution: {integrity: sha512-4LXxtieB+UVPLeGgw0q48g4PX+uquBIJPVlgs1ABySI5X4IVJXm3KTAABuaZ0mJ/yAryIc4FXD3pYzU7hSCfQg==} + '@vercel/fastify@7.0.0': + resolution: {integrity: sha512-tcB2pd0DdQls884nS70bkZgdrYRFceST1zZO/073o1iJG0VVoFcIPuDdePGlIIetegGJ+S68Wc47gr4TU/UQcQ==} + peerDependencies: + '@vercel/build-utils': 14.9.0 '@vercel/fun@1.3.0': resolution: {integrity: sha512-8erw9uPe0dFg45THkNxmjtvMX143SkZebmjgSVbcM3XCkXu3RIiBaJMcMNG8aaS+rnTuw8+d4De9HVT0M/r3wg==} engines: {node: '>= 18'} - '@vercel/gatsby-plugin-vercel-analytics@1.0.11': - resolution: {integrity: sha512-iTEA0vY6RBPuEzkwUTVzSHDATo1aF6bdLLspI68mQ/BTbi5UQEGjpjyzdKOVcSYApDtFU6M6vypZ1t4vIEnHvw==} + '@vercel/gatsby-plugin-vercel-analytics@1.0.12': + resolution: {integrity: sha512-Ejlhwxr7EBYJxtwYnlh6Vm6A2dPsUSPxMdYrfv0koZkgAzVwo7cG4aDQiy7iASMaGzwuI/PAnwlY2sJBF5b4OA==} - '@vercel/gatsby-plugin-vercel-builder@2.1.4': - resolution: {integrity: sha512-PONQs8DAc/P/tWGHGluDWE4aD0WfjDkod9sr8ksJJUpvkhanPpTQAFj8CTCbnr9Tu/9FWa1ZdwB4Q2+pXCWTEA==} + '@vercel/gatsby-plugin-vercel-builder@2.2.52': + resolution: {integrity: sha512-PYl9TBsYsP0a7qB4kgrF0a5YBUt7caiZwrxd4dl/uPdWHZlogTrnw2Cf2GS/kgij4rsew5jhc9xMnE4eHvQ1zg==} - '@vercel/go@3.4.6': - resolution: {integrity: sha512-ig91qRY+f4lLXWoRvnhEvu5DcT7LXtALBo/jJqxvlyOsHRBP4mdAvUgg9sygu2NOeMSV/cy249yJqQniP32HWw==} + '@vercel/go@10.0.0': + resolution: {integrity: sha512-G2tHOrb64snuckAdfq+OjMqE8fKIB4rq3FpbmaQ1vjvnyK/PqyWnvX9gCoIigThM49P0RkGQVp76DCt1RFGh4Q==} + peerDependencies: + '@vercel/build-utils': 14.9.0 - '@vercel/h3@0.1.62': - resolution: {integrity: sha512-0gzAyyf3rZf6ZG0ZTE33TczpX5ioR3dF8p4i78wVH+xEmaQ4u1o8yvVMK49CcMoQ1qjJVdpwQfI41BAfKQk7ig==} + '@vercel/h3@7.0.0': + resolution: {integrity: sha512-GYhMAK/XgDAQmgXSaiokz2kjc8QeE4azNg9aajcAm2q16Yd+t4a+VZutHppfk4I5SaLf5sKAGhGB8hkCHkbTPA==} + peerDependencies: + '@vercel/build-utils': 14.9.0 - '@vercel/hono@0.2.56': - resolution: {integrity: sha512-fB7zOx9B+eYzUDBmwZHqKn3kVQ2XYUjTdMQb1+FxWmSKr5T/uIRuX8ayWnD1lT5Bmrt5534nB6wVfZRljmWWzA==} + '@vercel/hono@7.0.0': + resolution: {integrity: sha512-Bn+illFuXukoI0l2z9Y75IS0c8mnLwy0zf5D0AIC2vegjPy5PnoYZ8c7mnEh88On/2ahMR3KEfocWnDssbpGHQ==} + peerDependencies: + '@vercel/build-utils': 14.9.0 - '@vercel/hydrogen@1.3.6': - resolution: {integrity: sha512-Ec8dKEjGIM4BfThcRLtQs5zaJ4+iJbgLZwkytwi7Blk8VrK6W2F1dtLDmVQYZdVnQcnmHmTx8mxUuMkfP06Mnw==} + '@vercel/hydrogen@8.0.0': + resolution: {integrity: sha512-Z2CKkTKn2SsqD5ftXXizPJ2HCmUoWvvEL9RnSd/eup1IPpOMK42MjIGyh+aPiiwzOwcPmoANPCmMufqrUOjHzw==} + peerDependencies: + '@vercel/build-utils': 14.9.0 - '@vercel/koa@0.1.36': - resolution: {integrity: sha512-ht1w0Qjrb9uMGbfz7aFabs1CCOG24XH5tLYN+sb4sYKHdioKusbpa4u9O76WrAwJXtnyHc48hJE4eyb6lCTndQ==} + '@vercel/koa@7.0.0': + resolution: {integrity: sha512-UgfQVMc2+hjfoMrGmAFhHmGIf1uDwNL+3Y2B+Ad9Gn4D2wz2gn2Pl5ywy23VOmfSD/aTerbLigTWKT+y9fb9Tg==} + peerDependencies: + '@vercel/build-utils': 14.9.0 - '@vercel/nestjs@0.2.57': - resolution: {integrity: sha512-qXE4Z0BZTj4KEpDJtZp0/2x1aOWf97tUHoatCG+ovTRLIdtSLhyZUFDcoqv/T/WZ4MbO24JqX2sqLmuiTUxIow==} + '@vercel/nestjs@7.0.0': + resolution: {integrity: sha512-suD7cuigAQlLA/RLAly8234gXXgIC/XG7AGBa1h2Y4vuIwGP43EBHZ4IuojdN6YCKwbD4/2eUMRa/qBs9OpsNQ==} + peerDependencies: + '@vercel/build-utils': 14.9.0 - '@vercel/next@4.16.3': - resolution: {integrity: sha512-yVLrMxMI+Taq47C94lWVUf981WerJ+COrJbgltHcMY8HDdXdgthYe06+na3dni31AL6BYShS8VLpE54QsAdM9Q==} + '@vercel/next@11.0.0': + resolution: {integrity: sha512-eTaJA+6iKLfhRwOl44mAuNj35jnuA5uExpDvy8vN+oWW0F5Ycjc2yoLHffbUO+9x3SWmo9CV5hpkGKfSSrJakg==} + peerDependencies: + '@vercel/build-utils': 14.9.0 - '@vercel/nft@1.5.0': - resolution: {integrity: sha512-IWTDeIoWhQ7ZtRO/JRKH+jhmeQvZYhtGPmzw/QGDY+wDCQqfm25P9yIdoAFagu4fWsK4IwZXDFIjrmp5rRm/sA==} + '@vercel/nft@1.10.0': + resolution: {integrity: sha512-iLOW4fcsgkipfOh2Bw3wB38YDfxTlxr7+j4uFeui2OswkNT28jIitS/aMce7tS0mef1YPQ8zLIDYr3a0aahNrA==} engines: {node: '>=20'} hasBin: true - '@vercel/node@5.6.20': - resolution: {integrity: sha512-n9ZMFzuRauAQNhwVvmsS/prG0We7RyDP2j/tPQoxcnOq5vP1DK6A+r7dG46sRTytgxVBgVJtU3486RAOvhOgcg==} + '@vercel/node@12.0.0': + resolution: {integrity: sha512-F3tbqSdN1Nap1zeZcdfScatAVFUrLLYXNBsHf9wutOMyOjuW7M32NEEp1eXhjHXdrIg9SyChooqvJPmt3iepSw==} + peerDependencies: + '@vercel/build-utils': 14.9.0 - '@vercel/prepare-flags-definitions@0.2.1': - resolution: {integrity: sha512-ouXTsqn7I9xZ1KKezgvn/w3tZeQHL/tc52j9GHiOYi6kT8xgdbT8s2x8C9BQr44iceX0hfhtZwk9q7NuI2Tqbw==} + '@vercel/oidc@3.2.0': + resolution: {integrity: sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug==} + engines: {node: '>= 20'} - '@vercel/python-analysis@0.11.0': - resolution: {integrity: sha512-gsoj+nscmNm0xDh+tRhECRhit2VlAVaD7jc9h93sN6rDEBDxPo7eLEgIJFzVDaAItxERZ9Od2IK/04fB9vFy+g==} + '@vercel/oidc@3.8.5': + resolution: {integrity: sha512-RwXYtnt6za+5UO4IaLywN/6B95AlLqynPRUWRJxeJ/qufwkcLUbZNUxYtzT0uMpuraWhlNcGqPNGkTnZr4BGBw==} + engines: {node: '>= 20'} - '@vercel/python@6.28.0': - resolution: {integrity: sha512-/Ley7HPz/AXhxO7unK5+4hEtsMUxXa02SJ8Tiaz//nEtRQMUcVREIyAUkOOfecjiCpg2hMHSVyCtABFjhzAbgA==} + '@vercel/prepare-flags-definitions@0.3.0': + resolution: {integrity: sha512-/0nuDFwYje0nqZnVKSd2VfJy2wOPQwbkas1qO1JQgtb0sLl+EeSCW4O9hrvq55pN50PNlAZ/APSeWHIAT9ZGHg==} - '@vercel/redwood@2.4.12': - resolution: {integrity: sha512-8kJ7eEerI4iMpKVRxQCsnxiIwRVsWtyirEbYb4erCqqsJymTu/xrjhsfAUuzeP8qkuYGP82MJVK+hpKlFtsjGw==} + '@vercel/python-analysis@0.14.0': + resolution: {integrity: sha512-qyVxbaU14gAi/AsaR8syZLukUsOp69Jkm1xn80rZPy4k9+zRUTIfqs0AOk7L8wwBxDDB6LLjcBUAmafhbJQnUQ==} - '@vercel/remix-builder@5.7.2': - resolution: {integrity: sha512-bfStsDBQramYbWugelfyp1szTuDOLQ+ZELvgA9cpYc4FucgCrDy/bpvMMvsjiDACB9PoDzLrG//ZBgsic9yAhg==} + '@vercel/python@13.0.0': + resolution: {integrity: sha512-KUqi9G5jx26m10kbpzgd8q2jGlijgX4aawH5aD2J8T7pu4q0dZGTw0DlczdrSFnEDYd8yM3zghGjXjtvcrKeaQ==} + peerDependencies: + '@vercel/build-utils': 14.9.0 - '@vercel/ruby@2.3.2': - resolution: {integrity: sha512-okIgMmPEePyDR9TZYaKM4oftcxVHM5Dbdl7V/tIdh3lq8MGLi7HR5vvQglmZUwZOeovE6MVtezxl960EOzeIiQ==} + '@vercel/redwood@9.0.0': + resolution: {integrity: sha512-y4YdEsqjGVHFcLTPKZWppTvcgXbq9edxxXl+KBEJvtJpqY7s4ZjFd+BzF8YF6KTC7x18rYOiugBSrbyLyctRtQ==} + peerDependencies: + '@vercel/build-utils': 14.9.0 - '@vercel/rust@1.0.5': - resolution: {integrity: sha512-Y03g59nv1uT6Da+PvB/50WqJSHlaFZ9MSkG00R82dUcTySslMbQdOeaXymZtabrmU8zQYhWDb1/CwBki8sWnaQ==} + '@vercel/remix-builder@12.0.0': + resolution: {integrity: sha512-SQqmOQSQn44Migiu/xglGZ2EjlE4YfEEi8GpOHfN2iVKZhGqyeA5spTzVuxWAHjxeYbaOarHW7qM/giaFc0e2A==} + peerDependencies: + '@vercel/build-utils': 14.9.0 - '@vercel/static-build@2.9.4': - resolution: {integrity: sha512-TqObjKTlr9nGkOzAUFweshKdbqOFKj8hS1xE499A7wYqlZWcORfcn+Yqe9ifXi628KA9+K+sLPaXJN/D4krr7A==} + '@vercel/ruby@9.0.0': + resolution: {integrity: sha512-kTmJZWkZpSEcK1t0FuM1Py1PTLDTgGbmhwXt1B1Tv/ZpJU2UfLK9rxzNUZemy2jdxSRtFWL+D/Ur0j1sF1W7Hg==} + peerDependencies: + '@vercel/build-utils': 14.9.0 - '@vercel/static-config@3.2.0': - resolution: {integrity: sha512-UpOEIgWxWx0M+mDe1IMdHS6JuWM/L5nNIJ4ixX8v9JgBAejymo88OkgnmfLCNMem0Wd+b5vcQPWLdZybCndlsA==} + '@vercel/rust@8.0.0': + resolution: {integrity: sha512-Ut16g8BZkwedJ8NN+LlPZPbiy4sC4efYYl2f440A6dg7obW5t4xjt9M1wTkooIgT0w2/j/FVfDdmQNJGfpPCgA==} + peerDependencies: + '@vercel/build-utils': 14.9.0 + + '@vercel/sandbox@3.1.0': + resolution: {integrity: sha512-z124E4rsmNpwGtTnLImiYzEXk972bWniQyhlvkEt35UtwKTdfBAFXcNG2OvqYqwzAsdQaP3B7teQ2pqA9B5Viw==} + + '@vercel/static-build@9.0.0': + resolution: {integrity: sha512-JrYaWxWmq83vQofB669VTp9egqoJN4wn3JgduvgCj3mNsdRj7R2wvyMPdGJWNm6t5Jsq3YMikik9VVmxZpctRQ==} + peerDependencies: + '@vercel/build-utils': 14.9.0 + + '@vercel/static-config@3.4.3': + resolution: {integrity: sha512-BY1sL8rNJvIm3TK/8TQ+Q6jIx7NpO5xckQzv7s6c1ypHvRnTl+vf6rmgY5WFXmoYDeGm3tMy4jiy/5M/5jGr/g==} + + '@vercel/vc-native-darwin-arm64@59.11.1': + resolution: {integrity: sha512-Lpxu0C2h3HThwfa7hrQXtzvy0uMAg2XAgMAyS0xwe10LQ5+OiX3nNFQCb4sGKhCBHvtiHv14iSCZRgQo1HtNFQ==} + cpu: [arm64] + os: [darwin] + + '@vercel/vc-native-darwin-x64@59.11.1': + resolution: {integrity: sha512-aAmQaFTC37ZNFWwf+WZ+zSzCnbKXR6UkQC77Zx2sbc/yGsjraBzf44Nza9fL1lLfVsIYs+wixmLKhvig1AQ06A==} + cpu: [x64] + os: [darwin] + + '@vercel/vc-native-linux-arm64@59.11.1': + resolution: {integrity: sha512-npU8GcrkwqyOotX2txj5TRIC9gof7uFr1Lp5fhzlw7qLoZNTzP/uNu//erNsSi4/LjIGBAD0YAnWVv6XkvlT1Q==} + cpu: [arm64] + os: [linux] + + '@vercel/vc-native-linux-x64@59.11.1': + resolution: {integrity: sha512-CV0nod07WyVceW9KucckRZSi4AVuQYz/T+lKjSmnNOFXz9TPi6i0X1R8ObcDfSzi0o6kgVhUGZS2+DXDiUTHqQ==} + cpu: [x64] + os: [linux] + + '@workflow/serde@4.1.0-beta.2': + resolution: {integrity: sha512-8kkeoQKLDaKXefjV5dbhBj2aErfKp1Mc4pb6tj8144cF+Em5SPbyMbyLCHp+BVrFfFVCBluCtMx+jjvaFVZGww==} abbrev@3.0.1: resolution: {integrity: sha512-AO2ac6pjRB3SJmGJo+v5/aK6Omggp6fsLrs6wN9bd35ulu4cCwaAU9+7ZhXjeqHVkaHThLuzH0nZr0YpCDhygg==} @@ -1963,10 +2250,6 @@ packages: ast-types-flow@0.0.8: resolution: {integrity: sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==} - ast-types@0.13.4: - resolution: {integrity: sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==} - engines: {node: '>=4'} - astring@1.9.0: resolution: {integrity: sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg==} hasBin: true @@ -1992,9 +2275,6 @@ packages: async-sema@3.1.1: resolution: {integrity: sha512-tLRNUXati5MFePdAk8dw7Qt7DpxPB60ofAgn8WRhW6a2rcimZnYBP9oxHiv0OHy+Wz7kPMG+t4LGdt31+4EmGg==} - asynckit@0.4.0: - resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} - available-typed-arrays@1.0.7: resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} engines: {node: '>= 0.4'} @@ -2007,6 +2287,14 @@ packages: resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} engines: {node: '>= 0.4'} + b4a@1.8.1: + resolution: {integrity: sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw==} + peerDependencies: + react-native-b4a: '*' + peerDependenciesMeta: + react-native-b4a: + optional: true + bail@2.0.2: resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==} @@ -2017,15 +2305,19 @@ packages: resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} engines: {node: 18 || 20 || >=22} + bare-events@2.9.2: + resolution: {integrity: sha512-AIPKioV7/Y/8KfZ3AAhjPJxLLbY49S64Ym5DakZlUg75qQiTgUq9hEJoEwa4eUezPUlXRy/i5NpsKvo9jgKmoA==} + peerDependencies: + bare-abort-controller: '*' + peerDependenciesMeta: + bare-abort-controller: + optional: true + baseline-browser-mapping@2.11.1: resolution: {integrity: sha512-HYXq73DDpCtNzOmrFsm9eSwCvWCql0RzqjpDzXN9EadiLJ4DNat0nsZ/Bzmy+Ud12mb4/zKDY0cQ805ZzN+i0A==} engines: {node: '>=6.0.0'} hasBin: true - basic-ftp@5.3.1: - resolution: {integrity: sha512-bopVNp6ugyA150DDuZfPFdt1KZ5a94ZDiwX4hMgZDzF+GttD80lEy8kj98kbyhLXnPvhtIo93mdnLIjpCAeeOw==} - engines: {node: '>=10.0.0'} - bindings@1.5.0: resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==} @@ -2132,10 +2424,6 @@ packages: color-name@1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} - combined-stream@1.0.8: - resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} - engines: {node: '>= 0.8'} - comma-separated-tokens@2.0.3: resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} @@ -2160,9 +2448,6 @@ packages: convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} - cookie-es@2.0.1: - resolution: {integrity: sha512-aVf4A4hI2w70LnF7GG+7xDQUkliwiXWXFvTjkip4+b64ygDQ2sJPRSKFDHbxn8o0xu9QzPkMuuiWIXyFSE2slA==} - cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -2173,10 +2458,6 @@ packages: damerau-levenshtein@1.0.8: resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==} - data-uri-to-buffer@6.0.2: - resolution: {integrity: sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==} - engines: {node: '>= 14'} - data-view-buffer@1.0.2: resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==} engines: {node: '>= 0.4'} @@ -2225,18 +2506,14 @@ packages: resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} engines: {node: '>= 0.4'} + define-lazy-prop@2.0.0: + resolution: {integrity: sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==} + engines: {node: '>=8'} + define-properties@1.2.1: resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} engines: {node: '>= 0.4'} - degenerator@5.0.1: - resolution: {integrity: sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ==} - engines: {node: '>= 14'} - - delayed-stream@1.0.0: - resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} - engines: {node: '>=0.4.0'} - depd@1.1.2: resolution: {integrity: sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==} engines: {node: '>= 0.6'} @@ -2311,6 +2588,9 @@ packages: es-module-lexer@1.4.1: resolution: {integrity: sha512-cXLGjP0c4T3flZJKQSuziYoq7MlT+rnvfZjfp7h+I7K9BNX54kP9nyWvdbwjQ4u1iWbOL4u96fgeZLToQlZC7w==} + es-module-lexer@1.5.0: + resolution: {integrity: sha512-pqrTKmwEIgafsYZAGw9kszYzmagcE/n4dbgwGWLEXg7J4QFJVQRBld8j3Q3GNez79jzxZshq0bcT962QHOghjw==} + es-object-atoms@1.1.2: resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} engines: {node: '>= 0.4'} @@ -2355,13 +2635,8 @@ packages: resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==} engines: {node: '>=12'} - escodegen@2.1.0: - resolution: {integrity: sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==} - engines: {node: '>=6.0'} - hasBin: true - - eslint-config-next@16.2.1: - resolution: {integrity: sha512-qhabwjQZ1Mk53XzXvmogf8KQ0tG0CQXF0CZ56+2/lVhmObgmaqj7x5A1DSrWdZd3kwI7GTPGUjFne+krRxYmFg==} + eslint-config-next@16.3.4: + resolution: {integrity: sha512-35/8RM10huEL9vlr8hUZMERMENHBrnyHN3ZZkF9efSgzGaqK34jIqry44A956//zriUhUAUW0XSkcolhrryqAA==} peerDependencies: eslint: '>=9.0.0' typescript: '>=3.3.1' @@ -2464,11 +2739,6 @@ packages: resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - esprima@4.0.1: - resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} - engines: {node: '>=4'} - hasBin: true - esquery@1.7.0: resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} engines: {node: '>=0.10'} @@ -2519,6 +2789,9 @@ packages: events-intercept@2.0.0: resolution: {integrity: sha512-blk1va0zol9QOrdZt0rFXo5KMkNPVSp92Eju/Qz8THwKWKRKeE0T8Br/1aW6+Edkyq9xHYgYxn2QtOnUKPUp+Q==} + events-universal@1.0.1: + resolution: {integrity: sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==} + execa@3.2.0: resolution: {integrity: sha512-kJJfVbI/lZE1PZYDI5VPxp8zXPO9rtxOkhpZ0jMKha56AI9y2gGVC6bkukStQf0ka5Rh15BA5m7cCCH4jmHqkw==} engines: {node: ^8.12.0 || >=9.7.0} @@ -2533,6 +2806,9 @@ packages: fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + fast-fifo@1.3.2: + resolution: {integrity: sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==} + fast-glob@3.3.1: resolution: {integrity: sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==} engines: {node: '>=8.6.0'} @@ -2588,10 +2864,6 @@ packages: resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} engines: {node: '>= 0.4'} - form-data@4.0.6: - resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==} - engines: {node: '>= 6'} - framer-motion@12.42.2: resolution: {integrity: sha512-5XY9luDiu0oHfHBjpDthFMh0ES+122w6p/papSJBweMkO8Sn+PW2QaEgRblQBpWFnuvZS5qvarpt/hO2pjGmnw==} peerDependencies: @@ -2756,6 +3028,10 @@ packages: resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==} engines: {node: '>=6'} + get-port@5.1.1: + resolution: {integrity: sha512-g/Q1aTSDOxFpchXC4i8ZWvxA1lnPqx/JHqcpIw0/LX9T8x/GBbi6YnlN5nhaKIFkT8oFsscUKgDJYxfwfS6QsQ==} + engines: {node: '>=8'} + get-proto@1.0.1: resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} engines: {node: '>= 0.4'} @@ -2775,10 +3051,6 @@ packages: get-tsconfig@4.14.0: resolution: {integrity: sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==} - get-uri@6.0.5: - resolution: {integrity: sha512-b1O07XYq8eRuVzBNgJLstU6FYc1tS6wnMtF1I1D9lE8LxZSOGZ7LhxN54yPP6mGw5f2CkXY2BQUL9Fx41qvcIg==} - engines: {node: '>= 14'} - github-slugger@2.0.0: resolution: {integrity: sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==} @@ -2880,10 +3152,6 @@ packages: resolution: {integrity: sha512-ZTTX0MWrsQ2ZAhA1cejAwDLycFsd7I7nVtnkT3Ol0aqodaKW+0CTZDQ1uBv5whptCnc8e8HeRRJxRs0kmm/Qfw==} engines: {node: '>= 0.6'} - http-proxy-agent@7.0.2: - resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} - engines: {node: '>= 14'} - https-proxy-agent@7.0.6: resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} engines: {node: '>= 14'} @@ -2926,10 +3194,6 @@ packages: resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} engines: {node: '>= 0.4'} - ip-address@10.2.0: - resolution: {integrity: sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==} - engines: {node: '>= 12'} - is-alphabetical@2.0.1: resolution: {integrity: sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==} @@ -2978,6 +3242,11 @@ packages: is-decimal@2.0.1: resolution: {integrity: sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==} + is-docker@2.2.1: + resolution: {integrity: sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==} + engines: {node: '>=8'} + hasBin: true + is-document.all@1.0.0: resolution: {integrity: sha512-+XSoyS05OdBbhFuELhgTCpFNHkpBOJqtsZfUFFpe5QTw+9Sjbh8zitxhQkYAo6wV7e1Vb8cAPvpCk9jGam/82g==} engines: {node: '>= 0.4'} @@ -3064,6 +3333,10 @@ packages: resolution: {integrity: sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==} engines: {node: '>= 0.4'} + is-wsl@2.2.0: + resolution: {integrity: sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==} + engines: {node: '>=8'} + isarray@2.0.5: resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==} @@ -3081,15 +3354,14 @@ packages: jose@5.9.6: resolution: {integrity: sha512-AMlnetc9+CV9asI19zHmrgS/WYsWUwCn2R7RzlbJWD7F9eWYUTGyBmU9o6PxngtLGOiDGPRu+Uc4fhKzbpteZQ==} + jose@6.2.3: + resolution: {integrity: sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} - js-yaml@4.1.1: - resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} - hasBin: true - - js-yaml@4.3.0: - resolution: {integrity: sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==} + js-yaml@4.3.2: + resolution: {integrity: sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==} hasBin: true jsesc@3.1.0: @@ -3121,9 +3393,15 @@ packages: engines: {node: '>=6'} hasBin: true + jsonc-parser@3.3.1: + resolution: {integrity: sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==} + jsonfile@6.2.1: resolution: {integrity: sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==} + jsonlines@0.1.1: + resolution: {integrity: sha512-ekDrAGso79Cvf+dtm+mL8OBI2bmAOt3gssYs833De/C9NmIpWDWyUO4zPgB5x2/OhY366dkhgfPMYfwZF7yOZA==} + jsx-ast-utils@3.3.5: resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==} engines: {node: '>=4.0'} @@ -3226,6 +3504,9 @@ packages: resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} hasBin: true + lru-cache@10.4.3: + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + lru-cache@11.5.2: resolution: {integrity: sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==} engines: {node: 20 || >=22} @@ -3237,10 +3518,6 @@ packages: resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==} engines: {node: '>=10'} - lru-cache@7.18.3: - resolution: {integrity: sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==} - engines: {node: '>=12'} - lucide-react@1.26.0: resolution: {integrity: sha512-raglYVR2+VkMfJL158krjVmE+rV5ST2lzA/KQm1FRSjMHT4MnWaegHxoVEpmc2So3nOEhp9oGejJwAPX8MoAjg==} peerDependencies: @@ -3445,12 +3722,8 @@ packages: resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} engines: {node: '>=6'} - minimatch@10.1.1: - resolution: {integrity: sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==} - engines: {node: 20 || >=22} - - minimatch@10.2.5: - resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} + minimatch@10.2.6: + resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==} engines: {node: 18 || 20 || >=22} minimatch@3.1.5: @@ -3505,8 +3778,8 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - nanoid@3.3.16: - resolution: {integrity: sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==} + nanoid@3.3.18: + resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true @@ -3518,18 +3791,14 @@ packages: natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} - netmask@2.1.1: - resolution: {integrity: sha512-eonl3sLUha+S1GzTPxychyhnUzKyeQkZ7jLjKrBagJgPla13F+uQ71HgpFefyHgqrjEbCPkDArxYsjY8/+gLKA==} - engines: {node: '>= 0.4.0'} - next-themes@0.4.6: resolution: {integrity: sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==} peerDependencies: react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc react-dom: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc - next@16.2.1: - resolution: {integrity: sha512-VaChzNL7o9rbfdt60HUj8tev4m6d7iC1igAy157526+cJlXOQu5LzsBXNT+xaJnTP/k+utSX5vMv7m0G+zKH+Q==} + next@16.3.4: + resolution: {integrity: sha512-/Ztf6CeRH+ejEXUrYtqI4gkS66eFIHuSwqi60RgcpWKodxFZx2/dqVCMKBwILfAHXQ+F1b1vAudgj3mnxqtoIA==} engines: {node: '>=20.9.0'} hasBin: true peerDependencies: @@ -3649,6 +3918,10 @@ packages: oniguruma-to-es@4.3.6: resolution: {integrity: sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA==} + open@8.4.0: + resolution: {integrity: sha512-XgFPPM+B28FtCCgSb9I+s9szOC1vZRSwgWsRUA5ylIxRTgKozqjOCrVOqGsYABPYK5qnfqClxZTFBa8PKt2v6Q==} + engines: {node: '>=12'} + optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} @@ -3661,6 +3934,10 @@ packages: resolution: {integrity: sha512-19YVAg7T+WTrxggPukVq7DjTv6+PJ867TmhCvBsYwmbFCsZd344rq2Ld1p0wo8f8Qrrhgp82c6FJRqdXWtSEhg==} engines: {node: '>= 0.4'} + oxc-parser@0.121.0: + resolution: {integrity: sha512-ek9o58+SCv6AV7nchiAcUJy1DNE2CC5WRdBcO0mF+W4oRjNQfPO7b3pLjTHSFECpHkKGOZSQxx3hk8viIL5YCg==} + engines: {node: ^20.19.0 || >=22.12.0} + oxc-transform@0.111.0: resolution: {integrity: sha512-oa5KKSDNLHZGaiqIGAbCWXeN9IJUAz9MElWcQX90epDxdKc9Hrt/BsLj3K4gDqfAYa5dwdH+ZCFJG9hR74fiGg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -3677,14 +3954,6 @@ packages: resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} engines: {node: '>=10'} - pac-proxy-agent@7.2.0: - resolution: {integrity: sha512-TEB8ESquiLMc0lV8vcd5Ql/JAKAoyzHFXaStwjkzpOpC5Yv+pIzLfHvjTSdf3vpa2bMiUQrg9i6276yn8666aA==} - engines: {node: '>= 14'} - - pac-resolver@7.0.1: - resolution: {integrity: sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg==} - engines: {node: '>= 14'} - parent-module@1.0.1: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} @@ -3751,12 +4020,12 @@ packages: resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} engines: {node: '>= 0.4'} - postcss@8.4.31: - resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==} + postcss@8.5.23: + resolution: {integrity: sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==} engines: {node: ^10 || ^12 || >=14} - postcss@8.5.22: - resolution: {integrity: sha512-KBDEIpLrvpv16pp3K0Fw+UCoZfopFjjgeB+0tA/aaThfEE74kKDLrgg603YvOWJyg3+WYtyq3xYsQWsIyZlPqQ==} + postcss@8.5.26: + resolution: {integrity: sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==} engines: {node: ^10 || ^12 || >=14} prelude-ls@1.2.1: @@ -3776,13 +4045,6 @@ packages: property-information@7.2.0: resolution: {integrity: sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==} - proxy-agent@6.4.0: - resolution: {integrity: sha512-u0piLU+nCOHMgGjRbimiXmA9kM/L9EHh3zL81xCdp7m+Y2pHIsnmbdDoEDoAz5geaonNR6q6+yOPQs6n4T6sBQ==} - engines: {node: '>= 14'} - - proxy-from-env@1.1.0: - resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} - pump@3.0.4: resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} @@ -3957,6 +4219,10 @@ packages: safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + sandbox@4.1.0: + resolution: {integrity: sha512-kzDiAyvrGHGdrQ/7mT6Md18K9OUVgZW/KUKO/wBJ/gHouDh6oJPWcGWfOV5i7CSep2map3Pl7vV9gszm3Cvu7Q==} + hasBin: true + scheduler@0.27.0: resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} @@ -3992,9 +4258,14 @@ packages: setprototypeof@1.1.1: resolution: {integrity: sha512-JvdAWfbXeIGaZ9cILp38HntZSFSo3mWg6xGcJJsd+d4aRMOqauag1C63dJfDw7OaMYwEbHMOxEZ1lqVRYP2OAw==} - sharp@0.34.5: - resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + sharp@0.35.4: + resolution: {integrity: sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==} + engines: {node: '>=20.9.0'} + peerDependencies: + '@types/node': '*' + peerDependenciesMeta: + '@types/node': + optional: true shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} @@ -4031,30 +4302,14 @@ packages: resolution: {integrity: sha512-MY2/qGx4enyjprQnFaZsHib3Yadh3IXyV2C321GY0pjGfVBu4un0uDJkwgdxqO+Rdx8JMT8IfJIRwbYVz3Ob3Q==} engines: {node: '>=14'} - smart-buffer@4.2.0: - resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==} - engines: {node: '>= 6.0.0', npm: '>= 3.0.0'} - smol-toml@1.5.2: resolution: {integrity: sha512-QlaZEqcAH3/RtNyet1IPIYPsEWAaYyXXv1Krsi+1L/QHppjX4Ifm8MQsBISz9vE8cHicIq3clogsheili5vhaQ==} engines: {node: '>= 18'} - socks-proxy-agent@8.0.5: - resolution: {integrity: sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==} - engines: {node: '>= 14'} - - socks@2.8.9: - resolution: {integrity: sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==} - engines: {node: '>= 10.0.0', npm: '>= 3.0.0'} - source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} - source-map@0.6.1: - resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} - engines: {node: '>=0.10.0'} - source-map@0.7.6: resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==} engines: {node: '>= 12'} @@ -4062,8 +4317,8 @@ packages: space-separated-tokens@2.0.2: resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} - srvx@0.8.9: - resolution: {integrity: sha512-wYc3VLZHRzwYrWJhkEqkhLb31TI0SOkfYZDkUhXdp3NoCnNS0FqajiQszZZjfow/VYEuc6Q5sZh9nM6kPy2NBQ==} + srvx@0.11.16: + resolution: {integrity: sha512-bp07zRuycfTY43IjAvvTFnmnJi8ikW0VFiHwOhhYcVW/L4xQ1XY4PAd4Nuum1rsA17C39zL7x+CDhrn5AL32Rw==} engines: {node: '>=20.16.0'} hasBin: true @@ -4088,6 +4343,9 @@ packages: resolution: {integrity: sha512-HAGUASw8NT0k8JvIVutB2Y/9iBk7gpgEyAudXwNJmZERdMITGdajOa4VJfD/kNiA3TppQpTP4J+CtcHwdzKBAw==} deprecated: Deprecated. Use node:stream/promises and node:stream/consumers instead. + streamx@2.28.1: + resolution: {integrity: sha512-zEzXb0s5Cds7tqMH6rhZ05lcJydCWiQPEwiNngVqzsxCc962vLY4Uw+mW7od8kDH258k2Uz/JrOkdIAAhSh9VA==} + string.prototype.includes@2.0.1: resolution: {integrity: sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg==} engines: {node: '>= 0.4'} @@ -4163,14 +4421,15 @@ packages: resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} engines: {node: '>=6'} - tar@7.5.21: - resolution: {integrity: sha512-XdhtCvlMywwxpCW8YEq3lOXBJpUPTR2OHHcwLPO3HwsJqOHa2Ok/oJ7ruGzp+JrKoRPVCzJwAdEjqLW/vNRPHA==} + tar-stream@3.1.7: + resolution: {integrity: sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==} + + tar@7.5.22: + resolution: {integrity: sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA==} engines: {node: '>=18'} - tar@7.5.7: - resolution: {integrity: sha512-fov56fJiRuThVFXD6o6/Q354S7pnWMJIVlDBYijsTNx6jKSE4pvrDTs6lUnmGvNyfJwFQQwWy3owKz1ucIhveQ==} - engines: {node: '>=18'} - deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + text-decoder@1.2.7: + resolution: {integrity: sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==} throttleit@2.1.0: resolution: {integrity: sha512-nt6AMGKW1p/70DF/hGBdJB57B8Tspmbp5gfJ8ilhLnt7kkr2ye7hzD6NVG8GGErk2HWF34igrL2CXmNIkzKqKw==} @@ -4283,14 +4542,18 @@ packages: undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} - undici@5.28.4: - resolution: {integrity: sha512-72RFADWFqKmUb2hmmvNODKL3p9hcB6Gt2DOQMis1SEBaV6a4MH8soBvzg+95CYhCKPFedut2JY9bMfrDl9D23g==} + undici@5.29.0: + resolution: {integrity: sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg==} engines: {node: '>=14.0'} - undici@6.27.0: - resolution: {integrity: sha512-YmfV3YnEDzXRC5lZ2jWtWWHKGUm1zIt8AhesR1tens+HTNv+YZlN/dp6G727LOvMJ8xjP9Be7Y2Sdr96LDm+pg==} + undici@6.28.0: + resolution: {integrity: sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA==} engines: {node: '>=18.17'} + undici@7.29.0: + resolution: {integrity: sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==} + engines: {node: '>=20.18.1'} + unified@11.0.5: resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==} @@ -4355,8 +4618,12 @@ packages: '@types/react': optional: true - vercel@50.37.0: - resolution: {integrity: sha512-GVeZ/5vw1dZXw2S3aEVmo05BbfspKc+gb3+h6hTz0Dm1IW3lCpAI7A/FXor8XDMHy64PhOjYCwpEu6Qnr3Cz+Q==} + uuid@14.0.1: + resolution: {integrity: sha512-6ZxzVpzDXDa3bJWaHilVayA+BH/1zmxCJoVgvmqJnid/gPoKHxUrS/aC/T6LGQtNHT+XHG9fXPJB4d+IrU30Ew==} + hasBin: true + + vercel@59.11.1: + resolution: {integrity: sha512-vGmxxo6NcqfMcHMJ2rtTsOLTdYjb/Jp49eAMh/zpTvvDS7BCqQdCLpeMTxxZ+5yezQWDKvYstzueRicEuNfubg==} engines: {node: '>= 18'} hasBin: true @@ -4409,6 +4676,18 @@ packages: wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + ws@8.21.3: + resolution: {integrity: sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + xdg-app-paths@5.1.0: resolution: {integrity: sha512-RAQ3WkPf4KTU1A8RtFx3gWywzVKe00tfOPFfl2NDGqbIFENQO4kqAJp7mhQjNj/33W5x5hiWWUdyfPq/5SU3QA==} engines: {node: '>=6'} @@ -4456,6 +4735,9 @@ packages: zod@3.22.4: resolution: {integrity: sha512-iC+8Io04lddc+mVqQ9AZ7OQ2MrUKGN+oIQyq1vemgt46jwCwLfhq7/pwnBnNXXXZb8VTVLKwp9EDkx+ryxIWmg==} + zod@4.1.11: + resolution: {integrity: sha512-WPsqwxITS2tzx1bzhIKsEs19ABD5vmCVa4xBo2tq/SrV4RNZtfws1EnCWQXM6yh8bD08a1idvkB5MZSBiZsjwg==} + zod@4.4.3: resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} @@ -4591,7 +4873,7 @@ snapshots: tslib: 2.8.1 optional: true - '@emnapi/runtime@1.11.2': + '@emnapi/runtime@1.11.3': dependencies: tslib: 2.8.1 optional: true @@ -4762,6 +5044,11 @@ snapshots: eslint: 9.39.5(jiti@2.7.0) eslint-visitor-keys: 3.4.3 + '@eslint-community/eslint-utils@4.9.1(eslint@9.39.5(jiti@2.7.0))': + dependencies: + eslint: 9.39.5(jiti@2.7.0) + eslint-visitor-keys: 3.4.3 + '@eslint-community/regexpp@4.12.2': {} '@eslint/config-array@0.21.2': @@ -4788,7 +5075,7 @@ snapshots: globals: 14.0.0 ignore: 5.3.2 import-fresh: 3.3.1 - js-yaml: 4.3.0 + js-yaml: 4.3.2 minimatch: 3.1.5 strip-json-comments: 3.1.1 transitivePeerDependencies: @@ -4849,110 +5136,112 @@ snapshots: '@humanwhocodes/retry@0.4.3': {} - '@iarna/toml@2.2.5': {} - '@img/colour@1.1.0': optional: true - '@img/sharp-darwin-arm64@0.34.5': + '@img/sharp-darwin-arm64@0.35.4': optionalDependencies: - '@img/sharp-libvips-darwin-arm64': 1.2.4 + '@img/sharp-libvips-darwin-arm64': 1.3.3 optional: true - '@img/sharp-darwin-x64@0.34.5': + '@img/sharp-darwin-x64@0.35.4': optionalDependencies: - '@img/sharp-libvips-darwin-x64': 1.2.4 + '@img/sharp-libvips-darwin-x64': 1.3.3 optional: true - '@img/sharp-libvips-darwin-arm64@1.2.4': - optional: true - - '@img/sharp-libvips-darwin-x64@1.2.4': - optional: true - - '@img/sharp-libvips-linux-arm64@1.2.4': - optional: true - - '@img/sharp-libvips-linux-arm@1.2.4': - optional: true - - '@img/sharp-libvips-linux-ppc64@1.2.4': - optional: true - - '@img/sharp-libvips-linux-riscv64@1.2.4': - optional: true - - '@img/sharp-libvips-linux-s390x@1.2.4': - optional: true - - '@img/sharp-libvips-linux-x64@1.2.4': - optional: true - - '@img/sharp-libvips-linuxmusl-arm64@1.2.4': - optional: true - - '@img/sharp-libvips-linuxmusl-x64@1.2.4': - optional: true - - '@img/sharp-linux-arm64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linux-arm64': 1.2.4 - optional: true - - '@img/sharp-linux-arm@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linux-arm': 1.2.4 - optional: true - - '@img/sharp-linux-ppc64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linux-ppc64': 1.2.4 - optional: true - - '@img/sharp-linux-riscv64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linux-riscv64': 1.2.4 - optional: true - - '@img/sharp-linux-s390x@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linux-s390x': 1.2.4 - optional: true - - '@img/sharp-linux-x64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linux-x64': 1.2.4 - optional: true - - '@img/sharp-linuxmusl-arm64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 - optional: true - - '@img/sharp-linuxmusl-x64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linuxmusl-x64': 1.2.4 - optional: true - - '@img/sharp-wasm32@0.34.5': + '@img/sharp-freebsd-wasm32@0.35.4': dependencies: - '@emnapi/runtime': 1.11.2 + '@img/sharp-wasm32': 0.35.4 optional: true - '@img/sharp-win32-arm64@0.34.5': + '@img/sharp-libvips-darwin-arm64@1.3.3': optional: true - '@img/sharp-win32-ia32@0.34.5': + '@img/sharp-libvips-darwin-x64@1.3.3': optional: true - '@img/sharp-win32-x64@0.34.5': + '@img/sharp-libvips-linux-arm64@1.3.3': optional: true - '@isaacs/balanced-match@4.0.1': {} + '@img/sharp-libvips-linux-arm@1.3.3': + optional: true - '@isaacs/brace-expansion@5.0.1': + '@img/sharp-libvips-linux-ppc64@1.3.3': + optional: true + + '@img/sharp-libvips-linux-riscv64@1.3.3': + optional: true + + '@img/sharp-libvips-linux-s390x@1.3.3': + optional: true + + '@img/sharp-libvips-linux-x64@1.3.3': + optional: true + + '@img/sharp-libvips-linuxmusl-arm64@1.3.3': + optional: true + + '@img/sharp-libvips-linuxmusl-x64@1.3.3': + optional: true + + '@img/sharp-linux-arm64@0.35.4': + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.3.3 + optional: true + + '@img/sharp-linux-arm@0.35.4': + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.3.3 + optional: true + + '@img/sharp-linux-ppc64@0.35.4': + optionalDependencies: + '@img/sharp-libvips-linux-ppc64': 1.3.3 + optional: true + + '@img/sharp-linux-riscv64@0.35.4': + optionalDependencies: + '@img/sharp-libvips-linux-riscv64': 1.3.3 + optional: true + + '@img/sharp-linux-s390x@0.35.4': + optionalDependencies: + '@img/sharp-libvips-linux-s390x': 1.3.3 + optional: true + + '@img/sharp-linux-x64@0.35.4': + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.3.3 + optional: true + + '@img/sharp-linuxmusl-arm64@0.35.4': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-arm64': 1.3.3 + optional: true + + '@img/sharp-linuxmusl-x64@0.35.4': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.3.3 + optional: true + + '@img/sharp-wasm32@0.35.4': dependencies: - '@isaacs/balanced-match': 4.0.1 + '@emnapi/runtime': 1.11.3 + optional: true + + '@img/sharp-webcontainers-wasm32@0.35.4': + dependencies: + '@img/sharp-wasm32': 0.35.4 + optional: true + + '@img/sharp-win32-arm64@0.35.4': + optional: true + + '@img/sharp-win32-ia32@0.35.4': + optional: true + + '@img/sharp-win32-x64@0.35.4': + optional: true '@isaacs/fs-minipass@4.0.1': dependencies: @@ -4985,7 +5274,7 @@ snapshots: node-fetch: 2.7.0 nopt: 8.1.0 semver: 7.8.5 - tar: 7.5.21 + tar: 7.5.22 transitivePeerDependencies: - encoding - supports-color @@ -5020,6 +5309,57 @@ snapshots: transitivePeerDependencies: - supports-color + '@napi-rs/keyring-darwin-arm64@1.2.0': + optional: true + + '@napi-rs/keyring-darwin-x64@1.2.0': + optional: true + + '@napi-rs/keyring-freebsd-x64@1.2.0': + optional: true + + '@napi-rs/keyring-linux-arm-gnueabihf@1.2.0': + optional: true + + '@napi-rs/keyring-linux-arm64-gnu@1.2.0': + optional: true + + '@napi-rs/keyring-linux-arm64-musl@1.2.0': + optional: true + + '@napi-rs/keyring-linux-riscv64-gnu@1.2.0': + optional: true + + '@napi-rs/keyring-linux-x64-gnu@1.2.0': + optional: true + + '@napi-rs/keyring-linux-x64-musl@1.2.0': + optional: true + + '@napi-rs/keyring-win32-arm64-msvc@1.2.0': + optional: true + + '@napi-rs/keyring-win32-ia32-msvc@1.2.0': + optional: true + + '@napi-rs/keyring-win32-x64-msvc@1.2.0': + optional: true + + '@napi-rs/keyring@1.2.0': + optionalDependencies: + '@napi-rs/keyring-darwin-arm64': 1.2.0 + '@napi-rs/keyring-darwin-x64': 1.2.0 + '@napi-rs/keyring-freebsd-x64': 1.2.0 + '@napi-rs/keyring-linux-arm-gnueabihf': 1.2.0 + '@napi-rs/keyring-linux-arm64-gnu': 1.2.0 + '@napi-rs/keyring-linux-arm64-musl': 1.2.0 + '@napi-rs/keyring-linux-riscv64-gnu': 1.2.0 + '@napi-rs/keyring-linux-x64-gnu': 1.2.0 + '@napi-rs/keyring-linux-x64-musl': 1.2.0 + '@napi-rs/keyring-win32-arm64-msvc': 1.2.0 + '@napi-rs/keyring-win32-ia32-msvc': 1.2.0 + '@napi-rs/keyring-win32-x64-msvc': 1.2.0 + '@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': dependencies: '@emnapi/core': 1.10.0 @@ -5027,41 +5367,44 @@ snapshots: '@tybys/wasm-util': 0.10.3 optional: true - '@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.2)': + '@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3)': dependencies: '@emnapi/core': 1.10.0 - '@emnapi/runtime': 1.11.2 + '@emnapi/runtime': 1.11.3 '@tybys/wasm-util': 0.10.3 optional: true - '@next/env@16.2.1': {} + '@next/env@16.3.4': {} - '@next/eslint-plugin-next@16.2.1': + '@next/eslint-plugin-next@16.3.4(eslint@9.39.5(jiti@2.7.0))': dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.5(jiti@2.7.0)) fast-glob: 3.3.1 + transitivePeerDependencies: + - eslint - '@next/swc-darwin-arm64@16.2.1': + '@next/swc-darwin-arm64@16.3.4': optional: true - '@next/swc-darwin-x64@16.2.1': + '@next/swc-darwin-x64@16.3.4': optional: true - '@next/swc-linux-arm64-gnu@16.2.1': + '@next/swc-linux-arm64-gnu@16.3.4': optional: true - '@next/swc-linux-arm64-musl@16.2.1': + '@next/swc-linux-arm64-musl@16.3.4': optional: true - '@next/swc-linux-x64-gnu@16.2.1': + '@next/swc-linux-x64-gnu@16.3.4': optional: true - '@next/swc-linux-x64-musl@16.2.1': + '@next/swc-linux-x64-musl@16.3.4': optional: true - '@next/swc-win32-arm64-msvc@16.2.1': + '@next/swc-win32-arm64-msvc@16.3.4': optional: true - '@next/swc-win32-x64-msvc@16.2.1': + '@next/swc-win32-x64-msvc@16.3.4': optional: true '@nodelib/fs.scandir@2.1.5': @@ -5080,8 +5423,75 @@ snapshots: '@orama/orama@3.1.18': {} + '@oxc-parser/binding-android-arm-eabi@0.121.0': + optional: true + + '@oxc-parser/binding-android-arm64@0.121.0': + optional: true + + '@oxc-parser/binding-darwin-arm64@0.121.0': + optional: true + + '@oxc-parser/binding-darwin-x64@0.121.0': + optional: true + + '@oxc-parser/binding-freebsd-x64@0.121.0': + optional: true + + '@oxc-parser/binding-linux-arm-gnueabihf@0.121.0': + optional: true + + '@oxc-parser/binding-linux-arm-musleabihf@0.121.0': + optional: true + + '@oxc-parser/binding-linux-arm64-gnu@0.121.0': + optional: true + + '@oxc-parser/binding-linux-arm64-musl@0.121.0': + optional: true + + '@oxc-parser/binding-linux-ppc64-gnu@0.121.0': + optional: true + + '@oxc-parser/binding-linux-riscv64-gnu@0.121.0': + optional: true + + '@oxc-parser/binding-linux-riscv64-musl@0.121.0': + optional: true + + '@oxc-parser/binding-linux-s390x-gnu@0.121.0': + optional: true + + '@oxc-parser/binding-linux-x64-gnu@0.121.0': + optional: true + + '@oxc-parser/binding-linux-x64-musl@0.121.0': + optional: true + + '@oxc-parser/binding-openharmony-arm64@0.121.0': + optional: true + + '@oxc-parser/binding-wasm32-wasi@0.121.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3)': + dependencies: + '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3) + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' + optional: true + + '@oxc-parser/binding-win32-arm64-msvc@0.121.0': + optional: true + + '@oxc-parser/binding-win32-ia32-msvc@0.121.0': + optional: true + + '@oxc-parser/binding-win32-x64-msvc@0.121.0': + optional: true + '@oxc-project/types@0.110.0': {} + '@oxc-project/types@0.121.0': {} + '@oxc-transform/binding-android-arm-eabi@0.111.0': optional: true @@ -5130,9 +5540,9 @@ snapshots: '@oxc-transform/binding-openharmony-arm64@0.111.0': optional: true - '@oxc-transform/binding-wasm32-wasi@0.111.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.2)': + '@oxc-transform/binding-wasm32-wasi@0.111.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3)': dependencies: - '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.2) + '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3) transitivePeerDependencies: - '@emnapi/core' - '@emnapi/runtime' @@ -5532,9 +5942,9 @@ snapshots: '@rolldown/binding-openharmony-arm64@1.0.0-rc.1': optional: true - '@rolldown/binding-wasm32-wasi@1.0.0-rc.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.2)': + '@rolldown/binding-wasm32-wasi@1.0.0-rc.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3)': dependencies: - '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.2) + '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3) transitivePeerDependencies: - '@emnapi/core' - '@emnapi/runtime' @@ -5600,7 +6010,7 @@ snapshots: '@standard-schema/spec@1.1.0': {} - '@swc/helpers@0.5.15': + '@swc/helpers@0.5.23': dependencies: tslib: 2.8.1 @@ -5670,12 +6080,10 @@ snapshots: '@alloc/quick-lru': 5.2.0 '@tailwindcss/node': 4.3.3 '@tailwindcss/oxide': 4.3.3 - postcss: 8.5.22 + postcss: 8.5.26 tailwindcss: 4.3.3 - '@tootallnate/once@2.0.0': {} - - '@tootallnate/quickjs-emscripten@0.23.0': {} + '@tootallnate/once@2.0.1': {} '@ts-morph/common@0.11.1': dependencies: @@ -5802,7 +6210,7 @@ snapshots: '@typescript-eslint/types': 8.65.0 '@typescript-eslint/visitor-keys': 8.65.0 debug: 4.4.3 - minimatch: 10.2.5 + minimatch: 10.2.6 semver: 7.8.5 tinyglobby: 0.2.17 ts-api-utils: 2.5.0(typescript@5.9.3) @@ -5898,19 +6306,21 @@ snapshots: '@unrs/resolver-binding-win32-x64-msvc@1.12.2': optional: true - '@vercel/backends@0.0.51(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.2)(typescript@5.9.3)': + '@vercel/backends@7.0.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3)(@vercel/build-utils@14.9.0)': dependencies: - '@vercel/build-utils': 13.10.0 - '@vercel/nft': 1.5.0 + '@vercel/build-utils': 14.9.0 + '@vercel/nft': 1.10.0 + '@vercel/static-config': 3.4.3(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3) execa: 3.2.0 fs-extra: 11.1.0 - oxc-transform: 0.111.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.2) + get-port: 5.1.1 + oxc-transform: 0.111.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3) path-to-regexp: 8.3.0 resolve.exports: 2.0.3 - rolldown: 1.0.0-rc.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.2) - srvx: 0.8.9 + rolldown: 1.0.0-rc.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3) + srvx: 0.11.16 + ts-morph: 12.0.0 tsx: 4.21.0 - typescript: 5.9.3 zod: 3.22.4 transitivePeerDependencies: - '@emnapi/core' @@ -5919,22 +6329,59 @@ snapshots: - rollup - supports-color - '@vercel/blob@2.3.0': + '@vercel/blob@2.8.0': dependencies: + '@vercel/oidc': 3.8.5 async-retry: 1.3.3 is-buffer: 2.0.5 is-node-process: 1.2.0 throttleit: 2.1.0 - undici: 6.27.0 + undici: 6.28.0 - '@vercel/build-utils@13.10.0': + '@vercel/build-utils@14.9.0': dependencies: - '@vercel/python-analysis': 0.11.0 + cjs-module-lexer: 1.2.3 + es-module-lexer: 1.5.0 - '@vercel/cervel@0.0.38(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.2)(typescript@5.9.3)': + '@vercel/cervel@0.1.59(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3)(@vercel/build-utils@14.9.0)': dependencies: - '@vercel/backends': 0.0.51(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.2)(typescript@5.9.3) - typescript: 5.9.3 + '@vercel/backends': 7.0.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3)(@vercel/build-utils@14.9.0) + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' + - '@vercel/build-utils' + - encoding + - rollup + - supports-color + + '@vercel/cli-auth@0.3.5': + dependencies: + '@napi-rs/keyring': 1.2.0 + '@vercel/cli-config': 0.2.4 + async-listen: 3.0.0 + open: 8.4.0 + zod: 4.1.11 + + '@vercel/cli-config@0.2.4': + dependencies: + xdg-app-paths: 5.1.0 + zod: 4.1.11 + + '@vercel/cli-exec@1.0.1': + dependencies: + execa: 5.1.1 + + '@vercel/container@7.0.0(@vercel/build-utils@14.9.0)': + dependencies: + '@vercel/build-utils': 14.9.0 + + '@vercel/detect-agent@1.2.5': {} + + '@vercel/elysia@7.0.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3)(@vercel/build-utils@14.9.0)': + dependencies: + '@vercel/build-utils': 14.9.0 + '@vercel/node': 12.0.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3)(@vercel/build-utils@14.9.0) + '@vercel/static-config': 3.4.3(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3) transitivePeerDependencies: - '@emnapi/core' - '@emnapi/runtime' @@ -5942,25 +6389,15 @@ snapshots: - rollup - supports-color - '@vercel/detect-agent@1.2.1': {} + '@vercel/error-utils@2.2.1': {} - '@vercel/elysia@0.1.53': + '@vercel/express@7.0.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3)(@vercel/build-utils@14.9.0)': dependencies: - '@vercel/node': 5.6.20 - '@vercel/static-config': 3.2.0 - transitivePeerDependencies: - - encoding - - rollup - - supports-color - - '@vercel/error-utils@2.0.3': {} - - '@vercel/express@0.1.63(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.2)(typescript@5.9.3)': - dependencies: - '@vercel/cervel': 0.0.38(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.2)(typescript@5.9.3) - '@vercel/nft': 1.5.0 - '@vercel/node': 5.6.20 - '@vercel/static-config': 3.2.0 + '@vercel/build-utils': 14.9.0 + '@vercel/cervel': 0.1.59(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3)(@vercel/build-utils@14.9.0) + '@vercel/nft': 1.10.0 + '@vercel/node': 12.0.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3)(@vercel/build-utils@14.9.0) + '@vercel/static-config': 3.4.3(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3) fs-extra: 11.1.0 path-to-regexp: 8.3.0 ts-morph: 12.0.0 @@ -5971,20 +6408,22 @@ snapshots: - encoding - rollup - supports-color - - typescript - '@vercel/fastify@0.1.56': + '@vercel/fastify@7.0.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3)(@vercel/build-utils@14.9.0)': dependencies: - '@vercel/node': 5.6.20 - '@vercel/static-config': 3.2.0 + '@vercel/build-utils': 14.9.0 + '@vercel/node': 12.0.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3)(@vercel/build-utils@14.9.0) + '@vercel/static-config': 3.4.3(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3) transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' - encoding - rollup - supports-color '@vercel/fun@1.3.0': dependencies: - '@tootallnate/once': 2.0.0 + '@tootallnate/once': 2.0.1 async-listen: 1.2.0 debug: 4.3.4 generic-pool: 3.4.2 @@ -5996,7 +6435,7 @@ snapshots: semver: 7.5.4 stat-mode: 0.3.0 stream-to-promise: 2.2.0 - tar: 7.5.7 + tar: 7.5.22 tinyexec: 0.3.2 tree-kill: 1.2.2 uid-promise: 1.0.0 @@ -6006,75 +6445,94 @@ snapshots: - encoding - supports-color - '@vercel/gatsby-plugin-vercel-analytics@1.0.11': + '@vercel/gatsby-plugin-vercel-analytics@1.0.12': dependencies: web-vitals: 0.2.4 - '@vercel/gatsby-plugin-vercel-builder@2.1.4': + '@vercel/gatsby-plugin-vercel-builder@2.2.52': dependencies: '@sinclair/typebox': 0.25.24 - '@vercel/build-utils': 13.10.0 + '@vercel/build-utils': 14.9.0 esbuild: 0.27.0 etag: 1.8.1 fs-extra: 11.1.0 - '@vercel/go@3.4.6': {} - - '@vercel/h3@0.1.62': + '@vercel/go@10.0.0(@vercel/build-utils@14.9.0)': dependencies: - '@vercel/node': 5.6.20 - '@vercel/static-config': 3.2.0 + '@vercel/build-utils': 14.9.0 + + '@vercel/h3@7.0.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3)(@vercel/build-utils@14.9.0)': + dependencies: + '@vercel/build-utils': 14.9.0 + '@vercel/node': 12.0.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3)(@vercel/build-utils@14.9.0) + '@vercel/static-config': 3.4.3(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3) transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' - encoding - rollup - supports-color - '@vercel/hono@0.2.56': + '@vercel/hono@7.0.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3)(@vercel/build-utils@14.9.0)': dependencies: - '@vercel/nft': 1.5.0 - '@vercel/node': 5.6.20 - '@vercel/static-config': 3.2.0 + '@vercel/build-utils': 14.9.0 + '@vercel/nft': 1.10.0 + '@vercel/node': 12.0.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3)(@vercel/build-utils@14.9.0) + '@vercel/static-config': 3.4.3(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3) fs-extra: 11.1.0 path-to-regexp: 8.3.0 ts-morph: 12.0.0 zod: 3.22.4 transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' - encoding - rollup - supports-color - '@vercel/hydrogen@1.3.6': + '@vercel/hydrogen@8.0.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3)(@vercel/build-utils@14.9.0)': dependencies: - '@vercel/static-config': 3.2.0 + '@vercel/build-utils': 14.9.0 + '@vercel/static-config': 3.4.3(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3) ts-morph: 12.0.0 + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' - '@vercel/koa@0.1.36': + '@vercel/koa@7.0.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3)(@vercel/build-utils@14.9.0)': dependencies: - '@vercel/node': 5.6.20 - '@vercel/static-config': 3.2.0 + '@vercel/build-utils': 14.9.0 + '@vercel/node': 12.0.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3)(@vercel/build-utils@14.9.0) + '@vercel/static-config': 3.4.3(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3) + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' + - encoding + - rollup + - supports-color + + '@vercel/nestjs@7.0.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3)(@vercel/build-utils@14.9.0)': + dependencies: + '@vercel/build-utils': 14.9.0 + '@vercel/node': 12.0.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3)(@vercel/build-utils@14.9.0) + '@vercel/static-config': 3.4.3(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3) + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' + - encoding + - rollup + - supports-color + + '@vercel/next@11.0.0(@vercel/build-utils@14.9.0)': + dependencies: + '@vercel/build-utils': 14.9.0 + '@vercel/nft': 1.10.0 transitivePeerDependencies: - encoding - rollup - supports-color - '@vercel/nestjs@0.2.57': - dependencies: - '@vercel/node': 5.6.20 - '@vercel/static-config': 3.2.0 - transitivePeerDependencies: - - encoding - - rollup - - supports-color - - '@vercel/next@4.16.3': - dependencies: - '@vercel/nft': 1.5.0 - transitivePeerDependencies: - - encoding - - rollup - - supports-color - - '@vercel/nft@1.5.0': + '@vercel/nft@1.10.0': dependencies: '@mapbox/node-pre-gyp': 2.0.3 '@rollup/pluginutils': 5.4.0 @@ -6093,16 +6551,16 @@ snapshots: - rollup - supports-color - '@vercel/node@5.6.20': + '@vercel/node@12.0.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3)(@vercel/build-utils@14.9.0)': dependencies: '@edge-runtime/node-utils': 2.3.0 '@edge-runtime/primitives': 4.1.0 '@edge-runtime/vm': 3.2.0 '@types/node': 20.11.0 - '@vercel/build-utils': 13.10.0 - '@vercel/error-utils': 2.0.3 - '@vercel/nft': 1.5.0 - '@vercel/static-config': 3.2.0 + '@vercel/build-utils': 14.9.0 + '@vercel/error-utils': 2.2.1 + '@vercel/nft': 1.10.0 + '@vercel/static-config': 3.4.3(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3) async-listen: 3.0.0 cjs-module-lexer: 1.2.3 edge-runtime: 2.5.9 @@ -6116,71 +6574,132 @@ snapshots: ts-morph: 12.0.0 tsx: 4.21.0 typescript: 5.9.3 - undici: 5.28.4 + undici: 5.29.0 transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' - encoding - rollup - supports-color - '@vercel/prepare-flags-definitions@0.2.1': {} + '@vercel/oidc@3.2.0': {} - '@vercel/python-analysis@0.11.0': + '@vercel/oidc@3.8.5': + dependencies: + '@vercel/cli-config': 0.2.4 + '@vercel/cli-exec': 1.0.1 + jose: 5.9.6 + + '@vercel/prepare-flags-definitions@0.3.0': {} + + '@vercel/python-analysis@0.14.0': dependencies: '@bytecodealliance/preview2-shim': 0.17.6 '@renovatebot/pep440': 4.2.1 fs-extra: 11.1.1 - js-yaml: 4.1.1 - minimatch: 10.1.1 + js-yaml: 4.3.2 + minimatch: 10.2.6 smol-toml: 1.5.2 zod: 3.22.4 - '@vercel/python@6.28.0': + '@vercel/python@13.0.0(@vercel/build-utils@14.9.0)': dependencies: - '@vercel/python-analysis': 0.11.0 + '@vercel/build-utils': 14.9.0 + '@vercel/python-analysis': 0.14.0 - '@vercel/redwood@2.4.12': + '@vercel/redwood@9.0.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3)(@vercel/build-utils@14.9.0)': dependencies: - '@vercel/nft': 1.5.0 - '@vercel/static-config': 3.2.0 + '@vercel/build-utils': 14.9.0 + '@vercel/nft': 1.10.0 + '@vercel/static-config': 3.4.3(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3) semver: 6.3.1 ts-morph: 12.0.0 transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' - encoding - rollup - supports-color - '@vercel/remix-builder@5.7.2': + '@vercel/remix-builder@12.0.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3)(@vercel/build-utils@14.9.0)': dependencies: - '@vercel/error-utils': 2.0.3 - '@vercel/nft': 1.5.0 - '@vercel/static-config': 3.2.0 + '@vercel/build-utils': 14.9.0 + '@vercel/error-utils': 2.2.1 + '@vercel/nft': 1.10.0 + '@vercel/static-config': 3.4.3(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3) path-to-regexp: 6.1.0 path-to-regexp-updated: path-to-regexp@6.3.0 ts-morph: 12.0.0 transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' - encoding - rollup - supports-color - '@vercel/ruby@2.3.2': {} - - '@vercel/rust@1.0.5': + '@vercel/ruby@9.0.0(@vercel/build-utils@14.9.0)': dependencies: - '@iarna/toml': 2.2.5 + '@vercel/build-utils': 14.9.0 + + '@vercel/rust@8.0.0(@vercel/build-utils@14.9.0)': + dependencies: + '@vercel/build-utils': 14.9.0 execa: 5.1.1 + get-port: 5.1.1 + smol-toml: 1.5.2 - '@vercel/static-build@2.9.4': + '@vercel/sandbox@3.1.0': dependencies: - '@vercel/gatsby-plugin-vercel-analytics': 1.0.11 - '@vercel/gatsby-plugin-vercel-builder': 2.1.4 - '@vercel/static-config': 3.2.0 - ts-morph: 12.0.0 + '@vercel/oidc': 3.2.0 + '@workflow/serde': 4.1.0-beta.2 + async-retry: 1.3.3 + jose: 6.2.3 + jsonlines: 0.1.1 + lru-cache: 10.4.3 + ms: 2.1.3 + picocolors: 1.1.1 + tar-stream: 3.1.7 + undici: 7.29.0 + xdg-app-paths: 5.1.0 + zod: 4.4.3 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a - '@vercel/static-config@3.2.0': + '@vercel/static-build@9.0.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3)(@vercel/build-utils@14.9.0)': + dependencies: + '@vercel/build-utils': 14.9.0 + '@vercel/gatsby-plugin-vercel-analytics': 1.0.12 + '@vercel/gatsby-plugin-vercel-builder': 2.2.52 + '@vercel/static-config': 3.4.3(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3) + ts-morph: 12.0.0 + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' + + '@vercel/static-config@3.4.3(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3)': dependencies: ajv: 8.6.3 json-schema-to-ts: 1.6.4 + oxc-parser: 0.121.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3) ts-morph: 12.0.0 + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' + + '@vercel/vc-native-darwin-arm64@59.11.1': + optional: true + + '@vercel/vc-native-darwin-x64@59.11.1': + optional: true + + '@vercel/vc-native-linux-arm64@59.11.1': + optional: true + + '@vercel/vc-native-linux-x64@59.11.1': + optional: true + + '@workflow/serde@4.1.0-beta.2': {} abbrev@3.0.1: {} @@ -6295,10 +6814,6 @@ snapshots: ast-types-flow@0.0.8: {} - ast-types@0.13.4: - dependencies: - tslib: 2.8.1 - astring@1.9.0: {} async-function@1.0.0: {} @@ -6315,8 +6830,6 @@ snapshots: async-sema@3.1.1: {} - asynckit@0.4.0: {} - available-typed-arrays@1.0.7: dependencies: possible-typed-array-names: 1.1.0 @@ -6325,15 +6838,17 @@ snapshots: axobject-query@4.1.0: {} + b4a@1.8.1: {} + bail@2.0.2: {} balanced-match@1.0.2: {} balanced-match@4.0.4: {} - baseline-browser-mapping@2.11.1: {} + bare-events@2.9.2: {} - basic-ftp@5.3.1: {} + baseline-browser-mapping@2.11.1: {} bindings@1.5.0: dependencies: @@ -6432,10 +6947,6 @@ snapshots: color-name@1.1.4: {} - combined-stream@1.0.8: - dependencies: - delayed-stream: 1.0.0 - comma-separated-tokens@2.0.3: {} compute-scroll-into-view@3.1.1: {} @@ -6450,8 +6961,6 @@ snapshots: convert-source-map@2.0.0: {} - cookie-es@2.0.1: {} - cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -6462,8 +6971,6 @@ snapshots: damerau-levenshtein@1.0.8: {} - data-uri-to-buffer@6.0.2: {} - data-view-buffer@1.0.2: dependencies: call-bound: 1.0.4 @@ -6506,20 +7013,14 @@ snapshots: es-errors: 1.3.0 gopd: 1.2.0 + define-lazy-prop@2.0.0: {} + define-properties@1.2.1: dependencies: define-data-property: 1.1.4 has-property-descriptors: 1.0.2 object-keys: 1.1.1 - degenerator@5.0.1: - dependencies: - ast-types: 0.13.4 - escodegen: 2.1.0 - esprima: 4.0.1 - - delayed-stream@1.0.0: {} - depd@1.1.2: {} dequal@2.0.3: {} @@ -6662,6 +7163,8 @@ snapshots: es-module-lexer@1.4.1: {} + es-module-lexer@1.5.0: {} + es-object-atoms@1.1.2: dependencies: es-errors: 1.3.0 @@ -6764,17 +7267,9 @@ snapshots: escape-string-regexp@5.0.0: {} - escodegen@2.1.0: + eslint-config-next@16.3.4(@typescript-eslint/parser@8.65.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3): dependencies: - esprima: 4.0.1 - estraverse: 5.3.0 - esutils: 2.0.3 - optionalDependencies: - source-map: 0.6.1 - - eslint-config-next@16.2.1(@typescript-eslint/parser@8.65.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3): - dependencies: - '@next/eslint-plugin-next': 16.2.1 + '@next/eslint-plugin-next': 16.3.4(eslint@9.39.5(jiti@2.7.0)) eslint: 9.39.5(jiti@2.7.0) eslint-import-resolver-node: 0.3.10 eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.5(jiti@2.7.0)) @@ -6965,8 +7460,6 @@ snapshots: acorn-jsx: 5.3.2(acorn@8.17.0) eslint-visitor-keys: 4.2.1 - esprima@4.0.1: {} - esquery@1.7.0: dependencies: estraverse: 5.3.0 @@ -7022,6 +7515,12 @@ snapshots: events-intercept@2.0.0: {} + events-universal@1.0.1: + dependencies: + bare-events: 2.9.2 + transitivePeerDependencies: + - bare-abort-controller + execa@3.2.0: dependencies: cross-spawn: 7.0.6 @@ -7051,6 +7550,8 @@ snapshots: fast-deep-equal@3.1.3: {} + fast-fifo@1.3.2: {} + fast-glob@3.3.1: dependencies: '@nodelib/fs.stat': 2.0.5 @@ -7109,14 +7610,6 @@ snapshots: dependencies: is-callable: 1.2.7 - form-data@4.0.6: - dependencies: - asynckit: 0.4.0 - combined-stream: 1.0.8 - es-set-tostringtag: 2.1.0 - hasown: 2.0.4 - mime-types: 2.1.35 - framer-motion@12.42.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4): dependencies: motion-dom: 12.42.2 @@ -7141,7 +7634,7 @@ snapshots: fsevents@2.3.3: optional: true - fumadocs-core@16.12.1(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.26.0(react@19.2.4))(next@16.2.1(@babel/core@7.29.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.4.3): + fumadocs-core@16.12.1(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.26.0(react@19.2.4))(next@16.3.4(@babel/core@7.29.7)(@types/node@20.19.43)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.4.3): dependencies: '@orama/orama': 3.1.18 estree-util-value-to-estree: 3.5.0 @@ -7168,22 +7661,22 @@ snapshots: '@types/mdast': 4.0.4 '@types/react': 19.2.17 lucide-react: 1.26.0(react@19.2.4) - next: 16.2.1(@babel/core@7.29.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + next: 16.3.4(@babel/core@7.29.7)(@types/node@20.19.43)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) react: 19.2.4 react-dom: 19.2.4(react@19.2.4) zod: 4.4.3 transitivePeerDependencies: - supports-color - fumadocs-mdx@14.3.2(@types/mdast@4.0.4)(@types/mdx@2.0.14)(@types/react@19.2.17)(fumadocs-core@16.12.1(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.26.0(react@19.2.4))(next@16.2.1(@babel/core@7.29.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.4.3))(next@16.2.1(@babel/core@7.29.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4): + fumadocs-mdx@14.3.2(@types/mdast@4.0.4)(@types/mdx@2.0.14)(@types/react@19.2.17)(fumadocs-core@16.12.1(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.26.0(react@19.2.4))(next@16.3.4(@babel/core@7.29.7)(@types/node@20.19.43)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.4.3))(next@16.3.4(@babel/core@7.29.7)(@types/node@20.19.43)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4): dependencies: '@mdx-js/mdx': 3.1.1 '@standard-schema/spec': 1.1.0 chokidar: 5.0.0 esbuild: 0.28.1 estree-util-value-to-estree: 3.5.0 - fumadocs-core: 16.12.1(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.26.0(react@19.2.4))(next@16.2.1(@babel/core@7.29.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.4.3) - js-yaml: 4.3.0 + fumadocs-core: 16.12.1(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.26.0(react@19.2.4))(next@16.3.4(@babel/core@7.29.7)(@types/node@20.19.43)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.4.3) + js-yaml: 4.3.2 mdast-util-mdx: 3.0.0 mdast-util-to-markdown: 2.1.2 picocolors: 1.1.1 @@ -7199,12 +7692,12 @@ snapshots: '@types/mdast': 4.0.4 '@types/mdx': 2.0.14 '@types/react': 19.2.17 - next: 16.2.1(@babel/core@7.29.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + next: 16.3.4(@babel/core@7.29.7)(@types/node@20.19.43)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) react: 19.2.4 transitivePeerDependencies: - supports-color - fumadocs-ui@16.12.1(@types/mdx@2.0.14)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(fumadocs-core@16.12.1(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.26.0(react@19.2.4))(next@16.2.1(@babel/core@7.29.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.4.3))(next@16.2.1(@babel/core@7.29.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(tailwindcss@4.3.3): + fumadocs-ui@16.12.1(@types/mdx@2.0.14)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(fumadocs-core@16.12.1(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.26.0(react@19.2.4))(next@16.3.4(@babel/core@7.29.7)(@types/node@20.19.43)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.4.3))(next@16.3.4(@babel/core@7.29.7)(@types/node@20.19.43)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(tailwindcss@4.3.3): dependencies: '@fuma-translate/react': 1.0.2(@types/react@19.2.17)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@fumadocs/tailwind': 0.1.1(tailwindcss@4.3.3) @@ -7220,7 +7713,7 @@ snapshots: '@radix-ui/react-tabs': 1.1.19(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) class-variance-authority: 0.7.1 cnfast: 0.0.8 - fumadocs-core: 16.12.1(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.26.0(react@19.2.4))(next@16.2.1(@babel/core@7.29.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.4.3) + fumadocs-core: 16.12.1(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.26.0(react@19.2.4))(next@16.3.4(@babel/core@7.29.7)(@types/node@20.19.43)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.4.3) lucide-react: 1.26.0(react@19.2.4) motion: 12.42.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4) next-themes: 0.4.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -7234,7 +7727,7 @@ snapshots: optionalDependencies: '@types/mdx': 2.0.14 '@types/react': 19.2.17 - next: 16.2.1(@babel/core@7.29.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + next: 16.3.4(@babel/core@7.29.7)(@types/node@20.19.43)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) transitivePeerDependencies: - '@emotion/is-prop-valid' - '@types/react-dom' @@ -7277,6 +7770,8 @@ snapshots: get-nonce@1.0.1: {} + get-port@5.1.1: {} + get-proto@1.0.1: dependencies: dunder-proto: 1.0.1 @@ -7298,14 +7793,6 @@ snapshots: dependencies: resolve-pkg-maps: 1.0.0 - get-uri@6.0.5: - dependencies: - basic-ftp: 5.3.1 - data-uri-to-buffer: 6.0.2 - debug: 4.4.3 - transitivePeerDependencies: - - supports-color - github-slugger@2.0.0: {} glob-parent@5.1.2: @@ -7318,7 +7805,7 @@ snapshots: glob@13.0.6: dependencies: - minimatch: 10.2.5 + minimatch: 10.2.6 minipass: 7.1.3 path-scurry: 2.0.2 @@ -7481,13 +7968,6 @@ snapshots: statuses: 1.5.0 toidentifier: 1.0.0 - http-proxy-agent@7.0.2: - dependencies: - agent-base: 7.1.4 - debug: 4.4.3 - transitivePeerDependencies: - - supports-color - https-proxy-agent@7.0.6: dependencies: agent-base: 7.1.4 @@ -7524,8 +8004,6 @@ snapshots: hasown: 2.0.4 side-channel: 1.1.1 - ip-address@10.2.0: {} - is-alphabetical@2.0.1: {} is-alphanumerical@2.0.1: @@ -7581,6 +8059,8 @@ snapshots: is-decimal@2.0.1: {} + is-docker@2.2.1: {} + is-document.all@1.0.0: dependencies: call-bound: 1.0.4 @@ -7661,6 +8141,10 @@ snapshots: call-bound: 1.0.4 get-intrinsic: 1.3.0 + is-wsl@2.2.0: + dependencies: + is-docker: 2.2.1 + isarray@2.0.5: {} isexe@2.0.0: {} @@ -7678,13 +8162,11 @@ snapshots: jose@5.9.6: {} + jose@6.2.3: {} + js-tokens@4.0.0: {} - js-yaml@4.1.1: - dependencies: - argparse: 2.0.1 - - js-yaml@4.3.0: + js-yaml@4.3.2: dependencies: argparse: 2.0.1 @@ -7709,12 +8191,16 @@ snapshots: json5@2.2.3: {} + jsonc-parser@3.3.1: {} + jsonfile@6.2.1: dependencies: universalify: 2.0.1 optionalDependencies: graceful-fs: 4.2.11 + jsonlines@0.1.1: {} + jsx-ast-utils@3.3.5: dependencies: array-includes: 3.1.9 @@ -7798,6 +8284,8 @@ snapshots: dependencies: js-tokens: 4.0.0 + lru-cache@10.4.3: {} + lru-cache@11.5.2: {} lru-cache@5.1.1: @@ -7808,8 +8296,6 @@ snapshots: dependencies: yallist: 4.0.0 - lru-cache@7.18.3: {} - lucide-react@1.26.0(react@19.2.4): dependencies: react: 19.2.4 @@ -8276,11 +8762,7 @@ snapshots: mimic-fn@2.1.0: {} - minimatch@10.1.1: - dependencies: - '@isaacs/brace-expansion': 5.0.1 - - minimatch@10.2.5: + minimatch@10.2.6: dependencies: brace-expansion: 5.0.8 @@ -8320,41 +8802,40 @@ snapshots: ms@2.1.3: {} - nanoid@3.3.16: {} + nanoid@3.3.18: {} napi-postinstall@0.3.4: {} natural-compare@1.4.0: {} - netmask@2.1.1: {} - next-themes@0.4.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4): dependencies: react: 19.2.4 react-dom: 19.2.4(react@19.2.4) - next@16.2.1(@babel/core@7.29.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + next@16.3.4(@babel/core@7.29.7)(@types/node@20.19.43)(react-dom@19.2.4(react@19.2.4))(react@19.2.4): dependencies: - '@next/env': 16.2.1 - '@swc/helpers': 0.5.15 + '@next/env': 16.3.4 + '@swc/helpers': 0.5.23 baseline-browser-mapping: 2.11.1 caniuse-lite: 1.0.30001806 - postcss: 8.4.31 + postcss: 8.5.23 react: 19.2.4 react-dom: 19.2.4(react@19.2.4) styled-jsx: 5.1.6(@babel/core@7.29.7)(react@19.2.4) optionalDependencies: - '@next/swc-darwin-arm64': 16.2.1 - '@next/swc-darwin-x64': 16.2.1 - '@next/swc-linux-arm64-gnu': 16.2.1 - '@next/swc-linux-arm64-musl': 16.2.1 - '@next/swc-linux-x64-gnu': 16.2.1 - '@next/swc-linux-x64-musl': 16.2.1 - '@next/swc-win32-arm64-msvc': 16.2.1 - '@next/swc-win32-x64-msvc': 16.2.1 - sharp: 0.34.5 + '@next/swc-darwin-arm64': 16.3.4 + '@next/swc-darwin-x64': 16.3.4 + '@next/swc-linux-arm64-gnu': 16.3.4 + '@next/swc-linux-arm64-musl': 16.3.4 + '@next/swc-linux-x64-gnu': 16.3.4 + '@next/swc-linux-x64-musl': 16.3.4 + '@next/swc-win32-arm64-msvc': 16.3.4 + '@next/swc-win32-x64-msvc': 16.3.4 + sharp: 0.35.4(@types/node@20.19.43) transitivePeerDependencies: - '@babel/core' + - '@types/node' - babel-plugin-macros node-exports-info@1.6.2: @@ -8452,6 +8933,12 @@ snapshots: regex: 6.1.0 regex-recursion: 6.0.2 + open@8.4.0: + dependencies: + define-lazy-prop: 2.0.0 + is-docker: 2.2.1 + is-wsl: 2.2.0 + optionator@0.9.4: dependencies: deep-is: 0.1.4 @@ -8470,7 +8957,35 @@ snapshots: object-keys: 1.1.1 safe-push-apply: 1.0.0 - oxc-transform@0.111.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.2): + oxc-parser@0.121.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3): + dependencies: + '@oxc-project/types': 0.121.0 + optionalDependencies: + '@oxc-parser/binding-android-arm-eabi': 0.121.0 + '@oxc-parser/binding-android-arm64': 0.121.0 + '@oxc-parser/binding-darwin-arm64': 0.121.0 + '@oxc-parser/binding-darwin-x64': 0.121.0 + '@oxc-parser/binding-freebsd-x64': 0.121.0 + '@oxc-parser/binding-linux-arm-gnueabihf': 0.121.0 + '@oxc-parser/binding-linux-arm-musleabihf': 0.121.0 + '@oxc-parser/binding-linux-arm64-gnu': 0.121.0 + '@oxc-parser/binding-linux-arm64-musl': 0.121.0 + '@oxc-parser/binding-linux-ppc64-gnu': 0.121.0 + '@oxc-parser/binding-linux-riscv64-gnu': 0.121.0 + '@oxc-parser/binding-linux-riscv64-musl': 0.121.0 + '@oxc-parser/binding-linux-s390x-gnu': 0.121.0 + '@oxc-parser/binding-linux-x64-gnu': 0.121.0 + '@oxc-parser/binding-linux-x64-musl': 0.121.0 + '@oxc-parser/binding-openharmony-arm64': 0.121.0 + '@oxc-parser/binding-wasm32-wasi': 0.121.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3) + '@oxc-parser/binding-win32-arm64-msvc': 0.121.0 + '@oxc-parser/binding-win32-ia32-msvc': 0.121.0 + '@oxc-parser/binding-win32-x64-msvc': 0.121.0 + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' + + oxc-transform@0.111.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3): optionalDependencies: '@oxc-transform/binding-android-arm-eabi': 0.111.0 '@oxc-transform/binding-android-arm64': 0.111.0 @@ -8488,7 +9003,7 @@ snapshots: '@oxc-transform/binding-linux-x64-gnu': 0.111.0 '@oxc-transform/binding-linux-x64-musl': 0.111.0 '@oxc-transform/binding-openharmony-arm64': 0.111.0 - '@oxc-transform/binding-wasm32-wasi': 0.111.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.2) + '@oxc-transform/binding-wasm32-wasi': 0.111.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3) '@oxc-transform/binding-win32-arm64-msvc': 0.111.0 '@oxc-transform/binding-win32-ia32-msvc': 0.111.0 '@oxc-transform/binding-win32-x64-msvc': 0.111.0 @@ -8506,24 +9021,6 @@ snapshots: dependencies: p-limit: 3.1.0 - pac-proxy-agent@7.2.0: - dependencies: - '@tootallnate/quickjs-emscripten': 0.23.0 - agent-base: 7.1.4 - debug: 4.4.3 - get-uri: 6.0.5 - http-proxy-agent: 7.0.2 - https-proxy-agent: 7.0.6 - pac-resolver: 7.0.1 - socks-proxy-agent: 8.0.5 - transitivePeerDependencies: - - supports-color - - pac-resolver@7.0.1: - dependencies: - degenerator: 5.0.1 - netmask: 2.1.1 - parent-module@1.0.1: dependencies: callsites: 3.1.0 @@ -8577,15 +9074,15 @@ snapshots: possible-typed-array-names@1.1.0: {} - postcss@8.4.31: + postcss@8.5.23: dependencies: - nanoid: 3.3.16 + nanoid: 3.3.18 picocolors: 1.1.1 source-map-js: 1.2.1 - postcss@8.5.22: + postcss@8.5.26: dependencies: - nanoid: 3.3.16 + nanoid: 3.3.18 picocolors: 1.1.1 source-map-js: 1.2.1 @@ -8605,21 +9102,6 @@ snapshots: property-information@7.2.0: {} - proxy-agent@6.4.0: - dependencies: - agent-base: 7.1.4 - debug: 4.4.3 - http-proxy-agent: 7.0.2 - https-proxy-agent: 7.0.6 - lru-cache: 7.18.3 - pac-proxy-agent: 7.2.0 - proxy-from-env: 1.1.0 - socks-proxy-agent: 8.0.5 - transitivePeerDependencies: - - supports-color - - proxy-from-env@1.1.0: {} - pump@3.0.4: dependencies: end-of-stream: 1.4.5 @@ -8822,7 +9304,7 @@ snapshots: reusify@1.1.0: {} - rolldown@1.0.0-rc.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.2): + rolldown@1.0.0-rc.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3): dependencies: '@oxc-project/types': 0.110.0 '@rolldown/pluginutils': 1.0.0-rc.1 @@ -8837,7 +9319,7 @@ snapshots: '@rolldown/binding-linux-x64-gnu': 1.0.0-rc.1 '@rolldown/binding-linux-x64-musl': 1.0.0-rc.1 '@rolldown/binding-openharmony-arm64': 1.0.0-rc.1 - '@rolldown/binding-wasm32-wasi': 1.0.0-rc.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.2) + '@rolldown/binding-wasm32-wasi': 1.0.0-rc.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3) '@rolldown/binding-win32-arm64-msvc': 1.0.0-rc.1 '@rolldown/binding-win32-x64-msvc': 1.0.0-rc.1 transitivePeerDependencies: @@ -8869,6 +9351,20 @@ snapshots: safer-buffer@2.1.2: {} + sandbox@4.1.0: + dependencies: + '@vercel/sandbox': 3.1.0 + async-retry: 1.3.3 + debug: 4.4.3 + ws: 8.21.3 + zod: 4.4.3 + transitivePeerDependencies: + - bare-abort-controller + - bufferutil + - react-native-b4a + - supports-color + - utf-8-validate + scheduler@0.27.0: {} scroll-into-view-if-needed@3.1.0: @@ -8907,36 +9403,38 @@ snapshots: setprototypeof@1.1.1: {} - sharp@0.34.5: + sharp@0.35.4(@types/node@20.19.43): dependencies: '@img/colour': 1.1.0 detect-libc: 2.1.2 semver: 7.8.5 optionalDependencies: - '@img/sharp-darwin-arm64': 0.34.5 - '@img/sharp-darwin-x64': 0.34.5 - '@img/sharp-libvips-darwin-arm64': 1.2.4 - '@img/sharp-libvips-darwin-x64': 1.2.4 - '@img/sharp-libvips-linux-arm': 1.2.4 - '@img/sharp-libvips-linux-arm64': 1.2.4 - '@img/sharp-libvips-linux-ppc64': 1.2.4 - '@img/sharp-libvips-linux-riscv64': 1.2.4 - '@img/sharp-libvips-linux-s390x': 1.2.4 - '@img/sharp-libvips-linux-x64': 1.2.4 - '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 - '@img/sharp-libvips-linuxmusl-x64': 1.2.4 - '@img/sharp-linux-arm': 0.34.5 - '@img/sharp-linux-arm64': 0.34.5 - '@img/sharp-linux-ppc64': 0.34.5 - '@img/sharp-linux-riscv64': 0.34.5 - '@img/sharp-linux-s390x': 0.34.5 - '@img/sharp-linux-x64': 0.34.5 - '@img/sharp-linuxmusl-arm64': 0.34.5 - '@img/sharp-linuxmusl-x64': 0.34.5 - '@img/sharp-wasm32': 0.34.5 - '@img/sharp-win32-arm64': 0.34.5 - '@img/sharp-win32-ia32': 0.34.5 - '@img/sharp-win32-x64': 0.34.5 + '@img/sharp-darwin-arm64': 0.35.4 + '@img/sharp-darwin-x64': 0.35.4 + '@img/sharp-freebsd-wasm32': 0.35.4 + '@img/sharp-libvips-darwin-arm64': 1.3.3 + '@img/sharp-libvips-darwin-x64': 1.3.3 + '@img/sharp-libvips-linux-arm': 1.3.3 + '@img/sharp-libvips-linux-arm64': 1.3.3 + '@img/sharp-libvips-linux-ppc64': 1.3.3 + '@img/sharp-libvips-linux-riscv64': 1.3.3 + '@img/sharp-libvips-linux-s390x': 1.3.3 + '@img/sharp-libvips-linux-x64': 1.3.3 + '@img/sharp-libvips-linuxmusl-arm64': 1.3.3 + '@img/sharp-libvips-linuxmusl-x64': 1.3.3 + '@img/sharp-linux-arm': 0.35.4 + '@img/sharp-linux-arm64': 0.35.4 + '@img/sharp-linux-ppc64': 0.35.4 + '@img/sharp-linux-riscv64': 0.35.4 + '@img/sharp-linux-s390x': 0.35.4 + '@img/sharp-linux-x64': 0.35.4 + '@img/sharp-linuxmusl-arm64': 0.35.4 + '@img/sharp-linuxmusl-x64': 0.35.4 + '@img/sharp-webcontainers-wasm32': 0.35.4 + '@img/sharp-win32-arm64': 0.35.4 + '@img/sharp-win32-ia32': 0.35.4 + '@img/sharp-win32-x64': 0.35.4 + '@types/node': 20.19.43 optional: true shebang-command@2.0.0: @@ -8988,35 +9486,15 @@ snapshots: signal-exit@4.0.2: {} - smart-buffer@4.2.0: {} - smol-toml@1.5.2: {} - socks-proxy-agent@8.0.5: - dependencies: - agent-base: 7.1.4 - debug: 4.4.3 - socks: 2.8.9 - transitivePeerDependencies: - - supports-color - - socks@2.8.9: - dependencies: - ip-address: 10.2.0 - smart-buffer: 4.2.0 - source-map-js@1.2.1: {} - source-map@0.6.1: - optional: true - source-map@0.7.6: {} space-separated-tokens@2.0.2: {} - srvx@0.8.9: - dependencies: - cookie-es: 2.0.1 + srvx@0.11.16: {} stable-hash@0.0.5: {} @@ -9039,6 +9517,15 @@ snapshots: end-of-stream: 1.1.0 stream-to-array: 2.3.0 + streamx@2.28.1: + dependencies: + events-universal: 1.0.1 + fast-fifo: 1.3.2 + text-decoder: 1.2.7 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + string.prototype.includes@2.0.1: dependencies: call-bind: 1.0.9 @@ -9128,7 +9615,16 @@ snapshots: tapable@2.3.3: {} - tar@7.5.21: + tar-stream@3.1.7: + dependencies: + b4a: 1.8.1 + fast-fifo: 1.3.2 + streamx: 2.28.1 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + + tar@7.5.22: dependencies: '@isaacs/fs-minipass': 4.0.1 chownr: 3.0.0 @@ -9136,13 +9632,11 @@ snapshots: minizlib: 3.1.0 yallist: 5.0.0 - tar@7.5.7: + text-decoder@1.2.7: dependencies: - '@isaacs/fs-minipass': 4.0.1 - chownr: 3.0.0 - minipass: 7.1.3 - minizlib: 3.1.0 - yallist: 5.0.0 + b4a: 1.8.1 + transitivePeerDependencies: + - react-native-b4a throttleit@2.1.0: {} @@ -9265,11 +9759,13 @@ snapshots: undici-types@6.21.0: {} - undici@5.28.4: + undici@5.29.0: dependencies: '@fastify/busboy': 2.1.1 - undici@6.27.0: {} + undici@6.28.0: {} + + undici@7.29.0: {} unified@11.0.5: dependencies: @@ -9369,44 +9865,62 @@ snapshots: optionalDependencies: '@types/react': 19.2.17 - vercel@50.37.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.2)(typescript@5.9.3): + uuid@14.0.1: {} + + vercel@59.11.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3): dependencies: - '@vercel/backends': 0.0.51(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.2)(typescript@5.9.3) - '@vercel/blob': 2.3.0 - '@vercel/build-utils': 13.10.0 - '@vercel/detect-agent': 1.2.1 - '@vercel/elysia': 0.1.53 - '@vercel/express': 0.1.63(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.2)(typescript@5.9.3) - '@vercel/fastify': 0.1.56 + '@vercel/backends': 7.0.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3)(@vercel/build-utils@14.9.0) + '@vercel/blob': 2.8.0 + '@vercel/build-utils': 14.9.0 + '@vercel/cli-auth': 0.3.5 + '@vercel/cli-config': 0.2.4 + '@vercel/container': 7.0.0(@vercel/build-utils@14.9.0) + '@vercel/detect-agent': 1.2.5 + '@vercel/elysia': 7.0.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3)(@vercel/build-utils@14.9.0) + '@vercel/express': 7.0.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3)(@vercel/build-utils@14.9.0) + '@vercel/fastify': 7.0.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3)(@vercel/build-utils@14.9.0) '@vercel/fun': 1.3.0 - '@vercel/go': 3.4.6 - '@vercel/h3': 0.1.62 - '@vercel/hono': 0.2.56 - '@vercel/hydrogen': 1.3.6 - '@vercel/koa': 0.1.36 - '@vercel/nestjs': 0.2.57 - '@vercel/next': 4.16.3 - '@vercel/node': 5.6.20 - '@vercel/prepare-flags-definitions': 0.2.1 - '@vercel/python': 6.28.0 - '@vercel/redwood': 2.4.12 - '@vercel/remix-builder': 5.7.2 - '@vercel/ruby': 2.3.2 - '@vercel/rust': 1.0.5 - '@vercel/static-build': 2.9.4 + '@vercel/go': 10.0.0(@vercel/build-utils@14.9.0) + '@vercel/h3': 7.0.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3)(@vercel/build-utils@14.9.0) + '@vercel/hono': 7.0.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3)(@vercel/build-utils@14.9.0) + '@vercel/hydrogen': 8.0.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3)(@vercel/build-utils@14.9.0) + '@vercel/koa': 7.0.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3)(@vercel/build-utils@14.9.0) + '@vercel/nestjs': 7.0.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3)(@vercel/build-utils@14.9.0) + '@vercel/next': 11.0.0(@vercel/build-utils@14.9.0) + '@vercel/node': 12.0.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3)(@vercel/build-utils@14.9.0) + '@vercel/prepare-flags-definitions': 0.3.0 + '@vercel/python': 13.0.0(@vercel/build-utils@14.9.0) + '@vercel/python-analysis': 0.14.0 + '@vercel/redwood': 9.0.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3)(@vercel/build-utils@14.9.0) + '@vercel/remix-builder': 12.0.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3)(@vercel/build-utils@14.9.0) + '@vercel/ruby': 9.0.0(@vercel/build-utils@14.9.0) + '@vercel/rust': 8.0.0(@vercel/build-utils@14.9.0) + '@vercel/static-build': 9.0.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3)(@vercel/build-utils@14.9.0) chokidar: 4.0.0 esbuild: 0.27.0 - form-data: 4.0.6 jose: 5.9.6 + jsonc-parser: 3.3.1 luxon: 3.7.2 - proxy-agent: 6.4.0 + sandbox: 4.1.0 + smol-toml: 1.5.2 + undici: 5.29.0 + uuid: 14.0.1 + zod: 4.1.11 + optionalDependencies: + '@vercel/vc-native-darwin-arm64': 59.11.1 + '@vercel/vc-native-darwin-x64': 59.11.1 + '@vercel/vc-native-linux-arm64': 59.11.1 + '@vercel/vc-native-linux-x64': 59.11.1 transitivePeerDependencies: - '@emnapi/core' - '@emnapi/runtime' + - bare-abort-controller + - bufferutil - encoding + - react-native-b4a - rollup - supports-color - - typescript + - utf-8-validate vfile-location@5.0.3: dependencies: @@ -9483,6 +9997,8 @@ snapshots: wrappy@1.0.2: {} + ws@8.21.3: {} + xdg-app-paths@5.1.0: dependencies: xdg-portable: 7.3.0 @@ -9521,6 +10037,8 @@ snapshots: zod@3.22.4: {} + zod@4.1.11: {} + zod@4.4.3: {} zwitch@2.0.4: {} diff --git a/mobile/pnpm-lock.yaml b/mobile/pnpm-lock.yaml index 6473659419d..60ccee97d2f 100644 --- a/mobile/pnpm-lock.yaml +++ b/mobile/pnpm-lock.yaml @@ -93,7 +93,7 @@ importers: version: 55.0.27(expo@55.0.30)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8)(typescript@6.0.3) expo-router: specifier: ^55.0.18 - version: 55.0.18(2f99795f9796def5cdb1516a493dc117) + version: 55.0.18(4a60a26fd685ffdcc7f556016ae4bc5e) expo-secure-store: specifier: ^55.0.18 version: 55.0.18(expo@55.0.30) @@ -178,7 +178,7 @@ importers: version: 0.25.4 expo-module-scripts: specifier: ^55.0.2 - version: 55.0.2(@babel/core@7.29.7)(@babel/runtime@7.29.7)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.25.4)(eslint@9.39.4)(expo@55.0.30)(jest@29.7.0(@types/node@26.1.2))(prettier@2.8.8)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react-refresh@0.14.2)(react-test-renderer@19.2.8(react@19.2.8))(react@19.2.8) + version: 55.0.2(@babel/core@7.29.7)(@babel/runtime@7.29.7)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.25.4)(eslint@9.39.4)(expo@55.0.30)(jest@29.7.0(@types/node@26.4.0))(prettier@2.8.8)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react-refresh@0.14.2)(react-test-renderer@19.2.8(react@19.2.8))(react@19.2.8) happy-dom: specifier: ^20.11.8 version: 20.11.8 @@ -199,10 +199,10 @@ importers: version: 6.0.3 vite: specifier: ^8.0.16 - version: 8.1.0(@types/node@26.1.2)(esbuild@0.25.4)(terser@5.49.1)(tsx@4.22.4)(yaml@2.9.0) + version: 8.1.0(@types/node@26.4.0)(esbuild@0.25.4)(terser@5.51.2)(tsx@4.22.4)(yaml@2.9.0) vitest: specifier: ^4.1.11 - version: 4.1.11(@types/node@26.1.2)(happy-dom@20.11.8)(jsdom@20.0.3)(vite@8.1.0(@types/node@26.1.2)(esbuild@0.25.4)(terser@5.49.1)(tsx@4.22.4)(yaml@2.9.0)) + version: 4.1.11(@types/node@26.4.0)(happy-dom@20.11.8)(jsdom@20.0.3)(vite@8.1.0(@types/node@26.4.0)(esbuild@0.25.4)(terser@5.51.2)(tsx@4.22.4)(yaml@2.9.0)) packages: @@ -2897,6 +2897,9 @@ packages: '@types/node@26.1.2': resolution: {integrity: sha512-Vu4a5UFA9rIIFJ7rB/Vaafh9lrCQszopTCx6KjFboXTGQbPNasehVR5TEiithSDGyd1DEiUByggTZsg8jukeIg==} + '@types/node@26.4.0': + resolution: {integrity: sha512-faiGnoIrLH/V8cibOMEAZ8pMw6oXqSukl29ra4mN8GdaB2ZewzeaLj+INpV5N+Z1eKWzY+IzaIZH2EIR6YZRNQ==} + '@types/react-native@0.73.0': resolution: {integrity: sha512-6ZRPQrYM72qYKGWidEttRe6M5DZBEV5F+MHMHqd4TTYx0tfkcdrUFGdef6CCxY0jXU7wldvd/zA/b0A/kTeJmA==} deprecated: This is a stub types definition. react-native provides its own type definitions, so you do not need this installed. @@ -3356,8 +3359,8 @@ packages: base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} - baseline-browser-mapping@2.10.27: - resolution: {integrity: sha512-zEs/ufmZoUd7WftKpKyXaT6RFxpQ5Qm9xytKRHvJfxFV9DFJkZph9RvJ1LcOUi0Z1ZVijMte65JbILeV+8QQEA==} + baseline-browser-mapping@2.11.20: + resolution: {integrity: sha512-H0ulySigv6icDJ1F7SjtdCD6PrhTpdYCmP0CactWy1+ekh0AFd0o1Wn5T8b+hnTmdBx19u9yhL6wvCylXMY7zw==} engines: {node: '>=6.0.0'} hasBin: true @@ -3398,8 +3401,8 @@ packages: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} - browserslist@4.28.2: - resolution: {integrity: sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==} + browserslist@4.28.8: + resolution: {integrity: sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true @@ -3448,8 +3451,8 @@ packages: resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==} engines: {node: '>=10'} - caniuse-lite@1.0.30001792: - resolution: {integrity: sha512-hVLMUZFgR4JJ6ACt1uEESvQN1/dBVqPAKY0hgrV70eN3391K6juAfTjKZLKvOMsx8PxA7gsY1/tLMMTcfFLLpw==} + caniuse-lite@1.0.30001810: + resolution: {integrity: sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==} chai@6.2.2: resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} @@ -3941,8 +3944,8 @@ packages: ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} - electron-to-chromium@1.5.352: - resolution: {integrity: sha512-9wHk8x6dyuimoe18EdiDPWKExNdxYqo4fn4FwOVVper6RxT3cmpBwBkWWfSOCYJjQdIco/nPhJhNLmn4Ufg1Yg==} + electron-to-chromium@1.5.416: + resolution: {integrity: sha512-K6bvB2BjnNrugtIih6ewlbBI9DXa976jIdiIlRLHhBoEI9a4JaQjjHyF+A1IQI543aQYR4LnmOrT/K5fZj0aPA==} emittery@0.13.1: resolution: {integrity: sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==} @@ -5228,6 +5231,10 @@ packages: resolution: {integrity: sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==} hasBin: true + js-yaml@4.3.2: + resolution: {integrity: sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==} + hasBin: true + jsc-safe-url@0.2.4: resolution: {integrity: sha512-0wM3YBWtYePOjfyXQH5MWQ8H7sdk5EXSwZvmSLKk2RboVQ2Bu239jycHDz5J/8Blf3K0Qnoy2b6xD+z10MFB+Q==} @@ -5492,8 +5499,8 @@ packages: resolution: {integrity: sha512-tnn0J5wzgTgTx2OJy3Cwr1y79bJz4eNgFQd+2HENOs5Vz6QOMnt05z7J+BedIo9wIbpEa0iN9U1nerxyvMRE9g==} engines: {node: '>=20.19.4'} - metro-babel-transformer@0.84.4: - resolution: {integrity: sha512-rvCfz8snl9h20VcvpOHxZuHP1SlAkv4HXbzw7nyyVwu6Eqo5PRerbakQ9XmUCOsRy70spJ37O+G1TK8oMzo48g==} + metro-babel-transformer@0.84.5: + resolution: {integrity: sha512-2WbHILKMiJUzfdjmGOQOqU1bWi9//gqiclc/tkk/AIsrrVw3efhZ1uhkOwMTxUEPOzqoo091H0olLmVZH5FHGQ==} engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} metro-cache-key@0.83.7: @@ -5504,8 +5511,8 @@ packages: resolution: {integrity: sha512-I38PtcjT4crS5HY9UQ8i6z8S7tJ2WewtPGr/OwS6FcLKfy5T/1hlTaFw+wozUZkEtNpR6Gc0oJuvuKCbSoSN5A==} engines: {node: '>=20.19.4'} - metro-cache-key@0.84.4: - resolution: {integrity: sha512-wVO79aGrkYImpnaVS4+d5RrRBRPX31QtvKB3wKGBuiNSznduZTQHzsrJZRroFJSwnygrzdsGUtDQPuqqFjFdvw==} + metro-cache-key@0.84.5: + resolution: {integrity: sha512-3dPB2TnvGjjf0/9O7AXVQURKXuQNauTZE7WpTGTlR017Gh/B5y0m/2wcqxfveUguHSpu89KhVxCAlr2k/H7uhQ==} engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} metro-cache@0.83.7: @@ -5516,8 +5523,8 @@ packages: resolution: {integrity: sha512-aogMG5WbKzW5000otNjYrS9hIoORzkCI1faPJK+vxQLaf2BorJKBBFe/jl2Tfsi1mZUglS2EBuBt8B3JB7MYDQ==} engines: {node: '>=20.19.4'} - metro-cache@0.84.4: - resolution: {integrity: sha512-gpcFQdSLUwUCk71saKoE64jLFbx2nwTfVCcPSULMNT8QYq0p1eZZE29Jvd0HtT/UlhC3ZOutLxJME5xqD2JUZg==} + metro-cache@0.84.5: + resolution: {integrity: sha512-WHS0n2OxQqtwEjSeQFPePNrMvEFhmQcUQM9cRJMHByWoi/GMWFBEWOf7hVkAM/0KRutAXNbDlSu/cZB6CyxgQQ==} engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} metro-config@0.83.7: @@ -5528,8 +5535,8 @@ packages: resolution: {integrity: sha512-crNbNy+/B4tCne2+HjUshwvC57gNBQj+V9fFSy3lHH2RlKcLHib3Mil/SfTX8Pfh2fCY5pPuSu2OKa7YiadB+Q==} engines: {node: '>=20.19.4'} - metro-config@0.84.4: - resolution: {integrity: sha512-PMotGDjXcXLWo2TMRH+VR99phFNgYTwqh4OoieIKK3yTJa1Jmkl+fZJxDO0jfBvNF+WESHciHvpNuBtXaF3B0Q==} + metro-config@0.84.5: + resolution: {integrity: sha512-zie+uN6oohscowi2S7ByU+wUw6CrT4ZxW9uAbONOObSxx86RGmnIAmjXHLkfmcdYoY7jzOPEbqcI6oeVmqyBQA==} engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} metro-core@0.83.7: @@ -5540,8 +5547,8 @@ packages: resolution: {integrity: sha512-NTyOUOQaQKvQgJG9VI2ymN6KTM7gHEqkVFhkPc9bK4BsHSTz/EaXuFBXtC+wtwINLeH9tbusL/jfsSmdEmuU7A==} engines: {node: '>=20.19.4'} - metro-core@0.84.4: - resolution: {integrity: sha512-HONpWC5LGXZn3ffkd4Hu6AIrfE7j4Z0g0wMo/goV24WOB3lhuFZ40KgvaDiSw8iyQHloMYay5N/wPX+z8oN/PQ==} + metro-core@0.84.5: + resolution: {integrity: sha512-xwm605hCi5Y6eJTTb8ZWo6pkUcoBEIyiQOfkZh5GwtDwUrP9SNhTQZhzJHrBCwwxlf3Ptl/pxWJgQ1rsNYMnrA==} engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} metro-file-map@0.83.7: @@ -5552,8 +5559,8 @@ packages: resolution: {integrity: sha512-+W++EUuzEXIfWQEFTWQMVThzhWbnJL4gRNJ9WSHzIAM5pT7gsprUxo9+2hrfirURm/TLvrKwhq37oCECJcDSyQ==} engines: {node: '>=20.19.4'} - metro-file-map@0.84.4: - resolution: {integrity: sha512-KSVDi/u60hKPx++NLu3MTIvyjzNoJnFAF8PQFxaj1jiSka/wjw+Ua6sNuJ0TDHQv+7AAoFQxeMgaRAe8Yic5wQ==} + metro-file-map@0.84.5: + resolution: {integrity: sha512-mlm/JL8toSbSc2akpKIGmzvrVRSCgZ5vkbycI34oMLoOnLGuLyC8WTyVJ6P0hZG/usDaGwZSl/s9BCRriqjGJA==} engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} metro-minify-terser@0.83.7: @@ -5564,8 +5571,8 @@ packages: resolution: {integrity: sha512-7tU0J5/c7LZaZJwTlOb1xq0NepTFvGzRxigDhZOq4jSE6g0BRHmBqe7XvLH3WcRiJNGy+eshcZr34RpMjb6mmg==} engines: {node: '>=20.19.4'} - metro-minify-terser@0.84.4: - resolution: {integrity: sha512-5qpbaVOMC7CPitIpuewzVeGw7E+C3ykbv2mqTjQLl85Z3annSVGlSCTcsZjqXZzjupfK4Ztj3dDc4kc44NZwtQ==} + metro-minify-terser@0.84.5: + resolution: {integrity: sha512-BJoFwCEDsYnagPqarayInv2+diCDNDdLlaof/p6s9w4gh+gc9HXYM+pDvsKGKKUumpZswNF3Z/ftTMqKl/5IBg==} engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} metro-resolver@0.83.7: @@ -5576,8 +5583,8 @@ packages: resolution: {integrity: sha512-piU0NVTI9i37YztDVF5rtn9uxP3NebVT0xZM9NKJ9z0jbigrctUQksi3NoYIeJMvL6Wn2dgAehpcmmnfn+gUwA==} engines: {node: '>=20.19.4'} - metro-resolver@0.84.4: - resolution: {integrity: sha512-1qLgbxQ5ZGhhutuPot1Yp348ofDsATL2WkrHF65TobqTT9K3P9qJXw38bomk7ncp5B7OYMfWwtyBZo1lCV792A==} + metro-resolver@0.84.5: + resolution: {integrity: sha512-VSSnepg1k6LyCwtb6eirWdAWlpKwBG8Rdtsr1mU38rMelFyWgh3/QuMSiZIZAIjwg/fsa8GhW5/FO54CAUPCEA==} engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} metro-runtime@0.83.7: @@ -5588,8 +5595,8 @@ packages: resolution: {integrity: sha512-f7FfeM0pamq8vrvs8aO9KvIUabbUKe0WkHFpLt6Q9yIIIsORqNFwlgJeHGraOFPU7Cxqj5yLXkJu5bT1uwDXvw==} engines: {node: '>=20.19.4'} - metro-runtime@0.84.4: - resolution: {integrity: sha512-Jibypds4g7AhzdRKY+kDoj51s5EXMwgyp5ddtlreDAsWefMdOx+agWqgm0H2XSZ/ueanHHVM89fnf5OJnlxa8Q==} + metro-runtime@0.84.5: + resolution: {integrity: sha512-U1m2+d1Pr+JO2/iVXBB2OfXXityz7tqwIorxfrT15IEgaHvpJBq/OHiqnOWPKJbUl3JcxjcdviZZOKk85oK4Qg==} engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} metro-source-map@0.83.7: @@ -5600,8 +5607,8 @@ packages: resolution: {integrity: sha512-60Uor7bM+KsVewLkLCcZfkPFCbqjPDdoSmeBuT3+ye+ac80BuLWbbR8DpyxWpUqQeZPdaAT5ZqFgDIs8BNEcVA==} engines: {node: '>=20.19.4'} - metro-source-map@0.84.4: - resolution: {integrity: sha512-jbWkPxIesVuo1IWkvezmMJld6iu8nD62GsrZiV6jP37AOdbo4OBq1FJ+qkOg8sV05wAHB//jAbziuW0SlJfW4g==} + metro-source-map@0.84.5: + resolution: {integrity: sha512-2BtV5L9uPc49F13Gn5wiP6bX/EncqzqTIk2VL/0F/96Vo0YEOjluT/qktQjFODfqGFsucwnh5mPEAl/2jVEfeg==} engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} metro-symbolicate@0.83.7: @@ -5614,8 +5621,8 @@ packages: engines: {node: '>=20.19.4'} hasBin: true - metro-symbolicate@0.84.4: - resolution: {integrity: sha512-OnfpacxUqGPZQ27t8qK9mFa7uqHIlVWeqRqkCbvMvreEBiamEeOn8krKtcwgP5M4cYDPwuSmCTopHMVthqG4zA==} + metro-symbolicate@0.84.5: + resolution: {integrity: sha512-rQ40zYDAkaWBN9yvjUuAD0ZpzBMZSoKyGYXnb5JrfbKjun7fTvfoLHL3KXFYenBTYZkQtlp4cKSCv/1utxFyOw==} engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} hasBin: true @@ -5627,8 +5634,8 @@ packages: resolution: {integrity: sha512-9JRPkvi+m0QH2Y/w5RjCF9mHqOUNFpMFLDDLhKUjhcKESh8Wm3HKDdHXHVldTN1lTToCIabv81nF0zyJzKEJ5g==} engines: {node: '>=20.19.4'} - metro-transform-plugins@0.84.4: - resolution: {integrity: sha512-kehr6HbAecqD0/a3xLXobELdPaAmRAl8bel0qagPF4vhZtux93nS8S4eq2kgKt6J2GnQpVjSoW1PXdst04mwow==} + metro-transform-plugins@0.84.5: + resolution: {integrity: sha512-+InaSVGaOyt0DyRo4Y/zIdPI6CZwnbNho5LAL23tgmuGwv7fyfkF7kKfPjZcfxXBcoYdTLLFnCfCH/dHSiCqNg==} engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} metro-transform-worker@0.83.7: @@ -5639,8 +5646,8 @@ packages: resolution: {integrity: sha512-Pa2hOfhUmWpI/dmkhsLq8uGyFHK2opoEK7j/YCiRtqNSe0YzzxYguXTNgg8AMiuZfc9LPcB1AhGENS10O5wcIw==} engines: {node: '>=20.19.4'} - metro-transform-worker@0.84.4: - resolution: {integrity: sha512-W1IYMvvXTu4MxYr7d9h7CeG2vpIr3bmLLIavkPY4O1ilzDrvS8z/NEe6y+pC44Ff7raMXQgYSfdqDUwN/i39gg==} + metro-transform-worker@0.84.5: + resolution: {integrity: sha512-ui1Z8x4s5RL36gMmKLaMMO7O9NNDHNdthEZSCDQHAau3JcAsTaFOK6I+2q4I/kW5u8hSEjJk9L45TXSVJw6g1A==} engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} metro@0.83.7: @@ -5653,8 +5660,8 @@ packages: engines: {node: '>=20.19.4'} hasBin: true - metro@0.84.4: - resolution: {integrity: sha512-8ETTubqfD6ornDy2zYDvRcKnVDOXdFJsjetYDBsY4oAsb6NJkiwFR+FaMESyGppFmQUyBQA4H4sFGxzcQSGtFA==} + metro@0.84.5: + resolution: {integrity: sha512-r1liLkyFZMVSEMNjU1CJU5pRzs3NdkxHqXS60O25c0rCIqAR+cGk7rPydw/g0WAIKVXojIBIF45yYBPagJGcgw==} engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} hasBin: true @@ -5763,8 +5770,9 @@ packages: node-int64@0.4.0: resolution: {integrity: sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==} - node-releases@2.0.38: - resolution: {integrity: sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==} + node-releases@2.0.54: + resolution: {integrity: sha512-YHs7BmmcsdAI5Ozuf8JZo6PT0mv2GIWC9vMfvUC3dp65M8hn7Ux8CPL+2oBI7juNuj9d0ndhTcznq2ODBps9cQ==} + engines: {node: '>=18'} normalize-path@3.0.0: resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} @@ -5795,8 +5803,8 @@ packages: resolution: {integrity: sha512-pk7el+eTOzfSKMAY4QBiiwKzegXn633JQj13y+pW5E5IdS+yV2CfDJ9Hf20K/LKy8nCrP9dAmd7XOk52OJxifA==} engines: {node: '>=20.19.4'} - ob1@0.84.4: - resolution: {integrity: sha512-eJXMpz4aQHXF/YBB9ddqZDIS+ooO91hObo9FoW/xBkr54/zCwYYCDqT/O54vNo8kOkWs5Ou/y28NgdrV0edQNA==} + ob1@0.84.5: + resolution: {integrity: sha512-aH9RkoZc7w/90HBamFxTw8ZLFr05wXS+iOnvmrgo53Ep8Pyrm5FieQSaPIVROkfFVQISeD/zo92fes26TOwe+A==} engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} object-assign@4.1.1: @@ -6677,6 +6685,11 @@ packages: engines: {node: '>=10'} hasBin: true + terser@5.51.2: + resolution: {integrity: sha512-bWnjSNscmuI+GJze6ZupnHP8G/cTcsJF+bXCeQknk2SHQsgbNJnLrqiH9jZ2W4STPVXH2mDKKRX3iwPhc9Cn/Q==} + engines: {node: '>=10'} + hasBin: true + test-exclude@6.0.0: resolution: {integrity: sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==} engines: {node: '>=8'} @@ -6862,8 +6875,8 @@ packages: resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} engines: {node: '>= 0.8'} - update-browserslist-db@1.2.3: - resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} + update-browserslist-db@1.3.2: + resolution: {integrity: sha512-UQ+MSxlhRm1bzjhU+DcuXfjFO1FzNtqhK5+9Yvlp90ItDLk5vT932A0rFu619nf7RVS+Y/VeaUW1jaRDqZ8VJw==} hasBin: true peerDependencies: browserslist: '>= 4.21.0' @@ -7301,7 +7314,7 @@ snapshots: dependencies: '@babel/compat-data': 7.29.3 '@babel/helper-validator-option': 7.27.1 - browserslist: 4.28.2 + browserslist: 4.28.8 lru-cache: 5.1.1 semver: 6.3.1 @@ -7309,7 +7322,7 @@ snapshots: dependencies: '@babel/compat-data': 7.29.7 '@babel/helper-validator-option': 7.29.7 - browserslist: 4.28.2 + browserslist: 4.28.8 lru-cache: 5.1.1 semver: 6.3.1 @@ -8694,7 +8707,7 @@ snapshots: globals: 14.0.0 ignore: 5.3.2 import-fresh: 3.3.1 - js-yaml: 4.3.1 + js-yaml: 4.3.2 minimatch: 3.1.5 strip-json-comments: 3.1.1 transitivePeerDependencies: @@ -8773,7 +8786,7 @@ snapshots: ws: 8.21.3 zod: 3.25.76 optionalDependencies: - expo-router: 55.0.18(2f99795f9796def5cdb1516a493dc117) + expo-router: 55.0.18(4a60a26fd685ffdcc7f556016ae4bc5e) react-native: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8) transitivePeerDependencies: - '@expo/dom-webview' @@ -8985,7 +8998,7 @@ snapshots: '@expo/json-file': 10.0.16 '@expo/metro': 55.1.2 '@expo/spawn-async': 1.8.0 - browserslist: 4.28.2 + browserslist: 4.28.8 chalk: 4.1.2 debug: 4.4.3 getenv: 2.0.0 @@ -9116,7 +9129,7 @@ snapshots: react: 19.2.8 optionalDependencies: '@expo/metro-runtime': 55.0.10(@expo/dom-webview@55.0.5)(expo@55.0.30)(react-dom@19.2.8(react@19.2.8))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) - expo-router: 55.0.18(2f99795f9796def5cdb1516a493dc117) + expo-router: 55.0.18(4a60a26fd685ffdcc7f556016ae4bc5e) react-dom: 19.2.8(react@19.2.8) transitivePeerDependencies: - supports-color @@ -9201,14 +9214,14 @@ snapshots: '@jest/test-result': 29.7.0 '@jest/transform': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 26.1.2 + '@types/node': 26.4.0 ansi-escapes: 4.3.2 chalk: 4.1.2 ci-info: 3.9.0 exit: 0.1.2 graceful-fs: 4.2.11 jest-changed-files: 29.7.0 - jest-config: 29.7.0(@types/node@26.1.2) + jest-config: 29.7.0(@types/node@26.4.0) jest-haste-map: 29.7.0 jest-message-util: 29.7.0 jest-regex-util: 29.6.3 @@ -9281,7 +9294,7 @@ snapshots: '@jest/transform': 29.7.0 '@jest/types': 29.6.3 '@jridgewell/trace-mapping': 0.3.31 - '@types/node': 26.1.2 + '@types/node': 26.4.0 chalk: 4.1.2 collect-v8-coverage: 1.0.3 exit: 0.1.2 @@ -9975,8 +9988,8 @@ snapshots: dependencies: '@react-native/js-polyfills': 0.85.2 '@react-native/metro-babel-transformer': 0.85.2(@babel/core@7.29.7) - metro-config: 0.84.4 - metro-runtime: 0.84.4 + metro-config: 0.84.5 + metro-runtime: 0.84.5 transitivePeerDependencies: - '@babel/core' - bufferutil @@ -10126,7 +10139,7 @@ snapshots: '@standard-schema/spec@1.1.0': {} - '@testing-library/react-native@13.3.3(jest@29.7.0(@types/node@26.1.2))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react-test-renderer@19.2.8(react@19.2.8))(react@19.2.8)': + '@testing-library/react-native@13.3.3(jest@29.7.0(@types/node@26.4.0))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react-test-renderer@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: jest-matcher-utils: 30.3.0 picocolors: 1.1.1 @@ -10136,7 +10149,7 @@ snapshots: react-test-renderer: 19.2.8(react@19.2.8) redent: 3.0.0 optionalDependencies: - jest: 29.7.0(@types/node@26.1.2) + jest: 29.7.0(@types/node@26.4.0) '@tootallnate/once@2.0.1': {} @@ -10345,6 +10358,10 @@ snapshots: dependencies: undici-types: 8.3.0 + '@types/node@26.4.0': + dependencies: + undici-types: 8.3.0 + '@types/react-native@0.73.0(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8)': dependencies: react-native: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8) @@ -10525,13 +10542,13 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@4.1.11(vite@8.1.0(@types/node@26.1.2)(esbuild@0.25.4)(terser@5.49.1)(tsx@4.22.4)(yaml@2.9.0))': + '@vitest/mocker@4.1.11(vite@8.1.0(@types/node@26.4.0)(esbuild@0.25.4)(terser@5.51.2)(tsx@4.22.4)(yaml@2.9.0))': dependencies: '@vitest/spy': 4.1.11 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 8.1.0(@types/node@26.1.2)(esbuild@0.25.4)(terser@5.49.1)(tsx@4.22.4)(yaml@2.9.0) + vite: 8.1.0(@types/node@26.4.0)(esbuild@0.25.4)(terser@5.51.2)(tsx@4.22.4)(yaml@2.9.0) '@vitest/pretty-format@4.1.11': dependencies: @@ -10934,7 +10951,7 @@ snapshots: base64-js@1.5.1: {} - baseline-browser-mapping@2.10.27: {} + baseline-browser-mapping@2.11.20: {} better-opn@3.0.2: dependencies: @@ -10972,13 +10989,13 @@ snapshots: dependencies: fill-range: 7.1.1 - browserslist@4.28.2: + browserslist@4.28.8: dependencies: - baseline-browser-mapping: 2.10.27 - caniuse-lite: 1.0.30001792 - electron-to-chromium: 1.5.352 - node-releases: 2.0.38 - update-browserslist-db: 1.2.3(browserslist@4.28.2) + baseline-browser-mapping: 2.11.20 + caniuse-lite: 1.0.30001810 + electron-to-chromium: 1.5.416 + node-releases: 2.0.54 + update-browserslist-db: 1.3.2(browserslist@4.28.8) bs-logger@0.2.6: dependencies: @@ -11024,7 +11041,7 @@ snapshots: camelcase@6.3.0: {} - caniuse-lite@1.0.30001792: {} + caniuse-lite@1.0.30001810: {} chai@6.2.2: {} @@ -11174,7 +11191,7 @@ snapshots: core-js-compat@3.49.0: dependencies: - browserslist: 4.28.2 + browserslist: 4.28.8 cose-base@1.0.3: dependencies: @@ -11184,13 +11201,13 @@ snapshots: dependencies: layout-base: 2.0.1 - create-jest@29.7.0(@types/node@26.1.2): + create-jest@29.7.0(@types/node@26.4.0): dependencies: '@jest/types': 29.6.3 chalk: 4.1.2 exit: 0.1.2 graceful-fs: 4.2.11 - jest-config: 29.7.0(@types/node@26.1.2) + jest-config: 29.7.0(@types/node@26.4.0) jest-util: 29.7.0 prompts: 2.4.2 transitivePeerDependencies: @@ -11554,7 +11571,7 @@ snapshots: ee-first@1.1.1: {} - electron-to-chromium@1.5.352: {} + electron-to-chromium@1.5.416: {} emittery@0.13.1: {} @@ -12169,7 +12186,7 @@ snapshots: expo: 55.0.30(10e8e71dd92768dd7f344108f3edbbe3) expo-json-utils: 55.0.2 - expo-module-scripts@55.0.2(@babel/core@7.29.7)(@babel/runtime@7.29.7)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.25.4)(eslint@9.39.4)(expo@55.0.30)(jest@29.7.0(@types/node@26.1.2))(prettier@2.8.8)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react-refresh@0.14.2)(react-test-renderer@19.2.8(react@19.2.8))(react@19.2.8): + expo-module-scripts@55.0.2(@babel/core@7.29.7)(@babel/runtime@7.29.7)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.25.4)(eslint@9.39.4)(expo@55.0.30)(jest@29.7.0(@types/node@26.4.0))(prettier@2.8.8)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react-refresh@0.14.2)(react-test-renderer@19.2.8(react@19.2.8))(react@19.2.8): dependencies: '@babel/cli': 7.28.6(@babel/core@7.29.7) '@babel/plugin-transform-export-namespace-from': 7.27.1(@babel/core@7.29.7) @@ -12177,7 +12194,7 @@ snapshots: '@babel/preset-typescript': 7.28.5(@babel/core@7.29.7) '@expo/npm-proofread': 1.0.1 '@expo/spawn-async': 1.7.2 - '@testing-library/react-native': 13.3.3(jest@29.7.0(@types/node@26.1.2))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react-test-renderer@19.2.8(react@19.2.8))(react@19.2.8) + '@testing-library/react-native': 13.3.3(jest@29.7.0(@types/node@26.4.0))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react-test-renderer@19.2.8(react@19.2.8))(react@19.2.8) '@tsconfig/node18': 18.2.6 '@types/jest': 29.5.14 babel-plugin-dynamic-import-node: 2.3.3 @@ -12185,11 +12202,11 @@ snapshots: commander: 12.1.0 eslint-config-universe: 15.0.4(eslint@9.39.4)(prettier@2.8.8)(typescript@5.9.3) glob: 13.0.6 - jest-expo: 55.0.17(@babel/core@7.29.7)(expo@55.0.30)(jest@29.7.0(@types/node@26.1.2))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8)(typescript@5.9.3) + jest-expo: 55.0.17(@babel/core@7.29.7)(expo@55.0.30)(jest@29.7.0(@types/node@26.4.0))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8)(typescript@5.9.3) jest-snapshot-prettier: prettier@2.8.8 - jest-watch-typeahead: 2.2.1(jest@29.7.0(@types/node@26.1.2)) + jest-watch-typeahead: 2.2.1(jest@29.7.0(@types/node@26.4.0)) resolve-workspace-root: 2.0.1 - ts-jest: 29.0.5(@babel/core@7.29.7)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.25.4)(jest@29.7.0(@types/node@26.1.2))(typescript@5.9.3) + ts-jest: 29.0.5(@babel/core@7.29.7)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.25.4)(jest@29.7.0(@types/node@26.4.0))(typescript@5.9.3) typescript: 5.9.3 transitivePeerDependencies: - '@babel/core' @@ -12252,7 +12269,7 @@ snapshots: - supports-color - typescript - expo-router@55.0.18(2f99795f9796def5cdb1516a493dc117): + expo-router@55.0.18(4a60a26fd685ffdcc7f556016ae4bc5e): dependencies: '@expo/log-box': 55.0.13(@expo/dom-webview@55.0.5)(expo@55.0.30)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) '@expo/metro-runtime': 55.0.10(@expo/dom-webview@55.0.5)(expo@55.0.30)(react-dom@19.2.8(react@19.2.8))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) @@ -12289,7 +12306,7 @@ snapshots: use-latest-callback: 0.2.6(react@19.2.8) vaul: 1.1.2(@types/react@19.2.14)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) optionalDependencies: - '@testing-library/react-native': 13.3.3(jest@29.7.0(@types/node@26.1.2))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react-test-renderer@19.2.8(react@19.2.8))(react@19.2.8) + '@testing-library/react-native': 13.3.3(jest@29.7.0(@types/node@26.4.0))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react-test-renderer@19.2.8(react@19.2.8))(react@19.2.8) react-dom: 19.2.8(react@19.2.8) react-native-gesture-handler: 2.31.2(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) react-native-reanimated: 4.3.4(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8) @@ -12950,7 +12967,7 @@ snapshots: '@jest/expect': 29.7.0 '@jest/test-result': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 26.1.2 + '@types/node': 26.4.0 chalk: 4.1.2 co: 4.6.0 dedent: 1.7.2 @@ -12970,16 +12987,16 @@ snapshots: - babel-plugin-macros - supports-color - jest-cli@29.7.0(@types/node@26.1.2): + jest-cli@29.7.0(@types/node@26.4.0): dependencies: '@jest/core': 29.7.0 '@jest/test-result': 29.7.0 '@jest/types': 29.6.3 chalk: 4.1.2 - create-jest: 29.7.0(@types/node@26.1.2) + create-jest: 29.7.0(@types/node@26.4.0) exit: 0.1.2 import-local: 3.2.0 - jest-config: 29.7.0(@types/node@26.1.2) + jest-config: 29.7.0(@types/node@26.4.0) jest-util: 29.7.0 jest-validate: 29.7.0 yargs: 17.7.3 @@ -12989,7 +13006,7 @@ snapshots: - supports-color - ts-node - jest-config@29.7.0(@types/node@26.1.2): + jest-config@29.7.0(@types/node@26.4.0): dependencies: '@babel/core': 7.29.7 '@jest/test-sequencer': 29.7.0 @@ -13014,7 +13031,7 @@ snapshots: slash: 3.0.0 strip-json-comments: 3.1.1 optionalDependencies: - '@types/node': 26.1.2 + '@types/node': 26.4.0 transitivePeerDependencies: - babel-plugin-macros - supports-color @@ -13069,7 +13086,7 @@ snapshots: jest-mock: 29.7.0 jest-util: 29.7.0 - jest-expo@55.0.17(@babel/core@7.29.7)(expo@55.0.30)(jest@29.7.0(@types/node@26.1.2))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8)(typescript@5.9.3): + jest-expo@55.0.17(@babel/core@7.29.7)(expo@55.0.30)(jest@29.7.0(@types/node@26.4.0))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8)(typescript@5.9.3): dependencies: '@expo/config': 55.0.16(typescript@5.9.3) '@expo/json-file': 10.0.14 @@ -13080,7 +13097,7 @@ snapshots: jest-environment-jsdom: 29.7.0 jest-snapshot: 29.7.0 jest-watch-select-projects: 2.0.0 - jest-watch-typeahead: 2.2.1(jest@29.7.0(@types/node@26.1.2)) + jest-watch-typeahead: 2.2.1(jest@29.7.0(@types/node@26.4.0)) json5: 2.2.3 lodash: 4.18.1 react-native: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8) @@ -13184,7 +13201,7 @@ snapshots: '@jest/test-result': 29.7.0 '@jest/transform': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 26.1.2 + '@types/node': 26.4.0 chalk: 4.1.2 emittery: 0.13.1 graceful-fs: 4.2.11 @@ -13212,7 +13229,7 @@ snapshots: '@jest/test-result': 29.7.0 '@jest/transform': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 26.1.2 + '@types/node': 26.4.0 chalk: 4.1.2 cjs-module-lexer: 1.4.3 collect-v8-coverage: 1.0.3 @@ -13279,11 +13296,11 @@ snapshots: chalk: 3.0.0 prompts: 2.4.2 - jest-watch-typeahead@2.2.1(jest@29.7.0(@types/node@26.1.2)): + jest-watch-typeahead@2.2.1(jest@29.7.0(@types/node@26.4.0)): dependencies: ansi-escapes: 6.2.1 chalk: 4.1.2 - jest: 29.7.0(@types/node@26.1.2) + jest: 29.7.0(@types/node@26.4.0) jest-regex-util: 29.6.3 jest-watcher: 29.7.0 slash: 5.1.0 @@ -13308,12 +13325,12 @@ snapshots: merge-stream: 2.0.0 supports-color: 8.1.1 - jest@29.7.0(@types/node@26.1.2): + jest@29.7.0(@types/node@26.4.0): dependencies: '@jest/core': 29.7.0 '@jest/types': 29.6.3 import-local: 3.2.0 - jest-cli: 29.7.0(@types/node@26.1.2) + jest-cli: 29.7.0(@types/node@26.4.0) transitivePeerDependencies: - '@types/node' - babel-plugin-macros @@ -13333,6 +13350,10 @@ snapshots: dependencies: argparse: 2.0.1 + js-yaml@4.3.2: + dependencies: + argparse: 2.0.1 + jsc-safe-url@0.2.4: {} jsdom@20.0.3: @@ -13604,12 +13625,12 @@ snapshots: transitivePeerDependencies: - supports-color - metro-babel-transformer@0.84.4: + metro-babel-transformer@0.84.5: dependencies: '@babel/core': 7.29.7 flow-enums-runtime: 0.0.6 hermes-parser: 0.35.0 - metro-cache-key: 0.84.4 + metro-cache-key: 0.84.5 nullthrows: 1.1.1 transitivePeerDependencies: - supports-color @@ -13622,7 +13643,7 @@ snapshots: dependencies: flow-enums-runtime: 0.0.6 - metro-cache-key@0.84.4: + metro-cache-key@0.84.5: dependencies: flow-enums-runtime: 0.0.6 @@ -13644,12 +13665,12 @@ snapshots: transitivePeerDependencies: - supports-color - metro-cache@0.84.4: + metro-cache@0.84.5: dependencies: exponential-backoff: 3.1.3 flow-enums-runtime: 0.0.6 https-proxy-agent: 7.0.6 - metro-core: 0.84.4 + metro-core: 0.84.5 transitivePeerDependencies: - supports-color @@ -13683,15 +13704,15 @@ snapshots: - supports-color - utf-8-validate - metro-config@0.84.4: + metro-config@0.84.5: dependencies: connect: 3.7.0 flow-enums-runtime: 0.0.6 jest-validate: 29.7.0 - metro: 0.84.4 - metro-cache: 0.84.4 - metro-core: 0.84.4 - metro-runtime: 0.84.4 + metro: 0.84.5 + metro-cache: 0.84.5 + metro-core: 0.84.5 + metro-runtime: 0.84.5 yaml: 2.9.0 transitivePeerDependencies: - bufferutil @@ -13710,11 +13731,11 @@ snapshots: lodash.throttle: 4.1.1 metro-resolver: 0.83.8 - metro-core@0.84.4: + metro-core@0.84.5: dependencies: flow-enums-runtime: 0.0.6 lodash.throttle: 4.1.1 - metro-resolver: 0.84.4 + metro-resolver: 0.84.5 metro-file-map@0.83.7: dependencies: @@ -13744,7 +13765,7 @@ snapshots: transitivePeerDependencies: - supports-color - metro-file-map@0.84.4: + metro-file-map@0.84.5: dependencies: debug: 4.4.3 fb-watchman: 2.0.2 @@ -13768,10 +13789,10 @@ snapshots: flow-enums-runtime: 0.0.6 terser: 5.49.1 - metro-minify-terser@0.84.4: + metro-minify-terser@0.84.5: dependencies: flow-enums-runtime: 0.0.6 - terser: 5.49.1 + terser: 5.51.2 metro-resolver@0.83.7: dependencies: @@ -13781,7 +13802,7 @@ snapshots: dependencies: flow-enums-runtime: 0.0.6 - metro-resolver@0.84.4: + metro-resolver@0.84.5: dependencies: flow-enums-runtime: 0.0.6 @@ -13795,7 +13816,7 @@ snapshots: '@babel/runtime': 7.29.7 flow-enums-runtime: 0.0.6 - metro-runtime@0.84.4: + metro-runtime@0.84.5: dependencies: '@babel/runtime': 7.29.7 flow-enums-runtime: 0.0.6 @@ -13828,15 +13849,15 @@ snapshots: transitivePeerDependencies: - supports-color - metro-source-map@0.84.4: + metro-source-map@0.84.5: dependencies: '@babel/traverse': 7.29.8 '@babel/types': 7.29.8 flow-enums-runtime: 0.0.6 invariant: 2.2.4 - metro-symbolicate: 0.84.4 + metro-symbolicate: 0.84.5 nullthrows: 1.1.1 - ob1: 0.84.4 + ob1: 0.84.5 source-map: 0.5.7 vlq: 1.0.1 transitivePeerDependencies: @@ -13864,11 +13885,11 @@ snapshots: transitivePeerDependencies: - supports-color - metro-symbolicate@0.84.4: + metro-symbolicate@0.84.5: dependencies: flow-enums-runtime: 0.0.6 invariant: 2.2.4 - metro-source-map: 0.84.4 + metro-source-map: 0.84.5 nullthrows: 1.1.1 source-map: 0.5.7 vlq: 1.0.1 @@ -13897,7 +13918,7 @@ snapshots: transitivePeerDependencies: - supports-color - metro-transform-plugins@0.84.4: + metro-transform-plugins@0.84.5: dependencies: '@babel/core': 7.29.7 '@babel/generator': 7.29.8 @@ -13948,20 +13969,20 @@ snapshots: - supports-color - utf-8-validate - metro-transform-worker@0.84.4: + metro-transform-worker@0.84.5: dependencies: '@babel/core': 7.29.7 '@babel/generator': 7.29.8 '@babel/parser': 7.29.8 '@babel/types': 7.29.8 flow-enums-runtime: 0.0.6 - metro: 0.84.4 - metro-babel-transformer: 0.84.4 - metro-cache: 0.84.4 - metro-cache-key: 0.84.4 - metro-minify-terser: 0.84.4 - metro-source-map: 0.84.4 - metro-transform-plugins: 0.84.4 + metro: 0.84.5 + metro-babel-transformer: 0.84.5 + metro-cache: 0.84.5 + metro-cache-key: 0.84.5 + metro-minify-terser: 0.84.5 + metro-source-map: 0.84.5 + metro-transform-plugins: 0.84.5 nullthrows: 1.1.1 transitivePeerDependencies: - bufferutil @@ -14059,7 +14080,7 @@ snapshots: - supports-color - utf-8-validate - metro@0.84.4: + metro@0.84.5: dependencies: '@babel/code-frame': 7.29.7 '@babel/core': 7.29.7 @@ -14076,23 +14097,22 @@ snapshots: flow-enums-runtime: 0.0.6 graceful-fs: 4.2.11 hermes-parser: 0.35.0 - image-size: 1.2.1 invariant: 2.2.4 jest-worker: 29.7.0 jsc-safe-url: 0.2.4 lodash.throttle: 4.1.1 - metro-babel-transformer: 0.84.4 - metro-cache: 0.84.4 - metro-cache-key: 0.84.4 - metro-config: 0.84.4 - metro-core: 0.84.4 - metro-file-map: 0.84.4 - metro-resolver: 0.84.4 - metro-runtime: 0.84.4 - metro-source-map: 0.84.4 - metro-symbolicate: 0.84.4 - metro-transform-plugins: 0.84.4 - metro-transform-worker: 0.84.4 + metro-babel-transformer: 0.84.5 + metro-cache: 0.84.5 + metro-cache-key: 0.84.5 + metro-config: 0.84.5 + metro-core: 0.84.5 + metro-file-map: 0.84.5 + metro-resolver: 0.84.5 + metro-runtime: 0.84.5 + metro-source-map: 0.84.5 + metro-symbolicate: 0.84.5 + metro-transform-plugins: 0.84.5 + metro-transform-worker: 0.84.5 mime-types: 3.0.2 nullthrows: 1.1.1 serialize-error: 2.1.0 @@ -14175,7 +14195,7 @@ snapshots: node-int64@0.4.0: {} - node-releases@2.0.38: {} + node-releases@2.0.54: {} normalize-path@3.0.0: {} @@ -14206,7 +14226,7 @@ snapshots: dependencies: flow-enums-runtime: 0.0.6 - ob1@0.84.4: + ob1@0.84.5: dependencies: flow-enums-runtime: 0.0.6 @@ -15212,6 +15232,13 @@ snapshots: commander: 2.20.3 source-map-support: 0.5.21 + terser@5.51.2: + dependencies: + '@jridgewell/source-map': 0.3.11 + acorn: 8.15.0 + commander: 2.20.3 + source-map-support: 0.5.21 + test-exclude@6.0.0: dependencies: '@istanbuljs/schema': 0.1.6 @@ -15267,11 +15294,11 @@ snapshots: ts-dedent@2.3.0: {} - ts-jest@29.0.5(@babel/core@7.29.7)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.25.4)(jest@29.7.0(@types/node@26.1.2))(typescript@5.9.3): + ts-jest@29.0.5(@babel/core@7.29.7)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.25.4)(jest@29.7.0(@types/node@26.4.0))(typescript@5.9.3): dependencies: bs-logger: 0.2.6 fast-json-stable-stringify: 2.1.0 - jest: 29.7.0(@types/node@26.1.2) + jest: 29.7.0(@types/node@26.4.0) jest-util: 29.7.0 json5: 2.2.3 lodash.memoize: 4.1.2 @@ -15381,9 +15408,9 @@ snapshots: unpipe@1.0.0: {} - update-browserslist-db@1.2.3(browserslist@4.28.2): + update-browserslist-db@1.3.2(browserslist@4.28.8): dependencies: - browserslist: 4.28.2 + browserslist: 4.28.8 escalade: 3.2.0 picocolors: 1.1.1 @@ -15442,7 +15469,7 @@ snapshots: - '@types/react' - '@types/react-dom' - vite@8.1.0(@types/node@26.1.2)(esbuild@0.25.4)(terser@5.49.1)(tsx@4.22.4)(yaml@2.9.0): + vite@8.1.0(@types/node@26.4.0)(esbuild@0.25.4)(terser@5.51.2)(tsx@4.22.4)(yaml@2.9.0): dependencies: lightningcss: 1.32.0 picomatch: 4.0.4 @@ -15450,17 +15477,17 @@ snapshots: rolldown: 1.1.3 tinyglobby: 0.2.17 optionalDependencies: - '@types/node': 26.1.2 + '@types/node': 26.4.0 esbuild: 0.25.4 fsevents: 2.3.3 - terser: 5.49.1 + terser: 5.51.2 tsx: 4.22.4 yaml: 2.9.0 - vitest@4.1.11(@types/node@26.1.2)(happy-dom@20.11.8)(jsdom@20.0.3)(vite@8.1.0(@types/node@26.1.2)(esbuild@0.25.4)(terser@5.49.1)(tsx@4.22.4)(yaml@2.9.0)): + vitest@4.1.11(@types/node@26.4.0)(happy-dom@20.11.8)(jsdom@20.0.3)(vite@8.1.0(@types/node@26.4.0)(esbuild@0.25.4)(terser@5.51.2)(tsx@4.22.4)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.11 - '@vitest/mocker': 4.1.11(vite@8.1.0(@types/node@26.1.2)(esbuild@0.25.4)(terser@5.49.1)(tsx@4.22.4)(yaml@2.9.0)) + '@vitest/mocker': 4.1.11(vite@8.1.0(@types/node@26.4.0)(esbuild@0.25.4)(terser@5.51.2)(tsx@4.22.4)(yaml@2.9.0)) '@vitest/pretty-format': 4.1.11 '@vitest/runner': 4.1.11 '@vitest/snapshot': 4.1.11 @@ -15477,10 +15504,10 @@ snapshots: tinyexec: 1.1.2 tinyglobby: 0.2.17 tinyrainbow: 3.1.0 - vite: 8.1.0(@types/node@26.1.2)(esbuild@0.25.4)(terser@5.49.1)(tsx@4.22.4)(yaml@2.9.0) + vite: 8.1.0(@types/node@26.4.0)(esbuild@0.25.4)(terser@5.51.2)(tsx@4.22.4)(yaml@2.9.0) why-is-node-running: 2.3.0 optionalDependencies: - '@types/node': 26.1.2 + '@types/node': 26.4.0 happy-dom: 20.11.8 jsdom: 20.0.3 transitivePeerDependencies: diff --git a/mobile/src/terminal/terminal-webview-html-source.test-support.ts b/mobile/src/terminal/terminal-webview-html-source.test-support.ts index 2491591572f..19a9cfc07ba 100644 --- a/mobile/src/terminal/terminal-webview-html-source.test-support.ts +++ b/mobile/src/terminal/terminal-webview-html-source.test-support.ts @@ -1,16 +1,31 @@ import { readFileSync } from 'node:fs' -const SOURCE_FILES = [ - './terminal-webview-html.ts', - ...Array.from( - { length: 10 }, - (_, index) => `./terminal-webview-html/fragment-${String(index + 1).padStart(2, '0')}.ts` - ) -] as const +const COMPOSER_FILE = './terminal-webview-html.ts' +const SLICE_IMPORT_RE = /^import \{[^}]*\} from '(\.\/terminal-webview-html\/[\w-]+)'$/gm +const COMPOSED_ENTRY_RE = /^ {2}TERMINAL_HTML_\w+,?$/gm -/** Reads the TypeScript source that assembles the in-WebView document. */ +function readSource(relativePath: string): string { + return readFileSync(new URL(relativePath, import.meta.url), 'utf8') +} + +/** + * Reads the TypeScript source that assembles the in-WebView document. + * + * Why: the slice list is derived from the composer's own imports rather than duplicated, so a + * new slice cannot join the emitted document while staying invisible to the tests that search + * this source. The count cross-check catches an import shape the regex cannot see. + */ export function readTerminalWebViewHtmlSource(): string { - return SOURCE_FILES.map((relativePath) => - readFileSync(new URL(relativePath, import.meta.url), 'utf8') - ).join('\n') + const composer = readSource(COMPOSER_FILE) + const slices = [...composer.matchAll(SLICE_IMPORT_RE)].map((match) => `${match[1]}.ts`) + const composedCount = [...composer.matchAll(COMPOSED_ENTRY_RE)].length + if (composedCount === 0) { + throw new Error('no composed WebView document slices found') + } + if (slices.length !== composedCount) { + throw new Error( + `WebView document slice imports (${slices.length}) do not match composed entries (${composedCount})` + ) + } + return [composer, ...slices.map(readSource)].join('\n') } diff --git a/mobile/src/terminal/terminal-webview-html.ts b/mobile/src/terminal/terminal-webview-html.ts index b55421bbe4d..17fadd4d26c 100644 --- a/mobile/src/terminal/terminal-webview-html.ts +++ b/mobile/src/terminal/terminal-webview-html.ts @@ -1,28 +1,38 @@ -import { TERMINAL_HTML_FRAGMENT_01 } from './terminal-webview-html/fragment-01' -import { TERMINAL_HTML_FRAGMENT_02 } from './terminal-webview-html/fragment-02' -import { TERMINAL_HTML_FRAGMENT_03 } from './terminal-webview-html/fragment-03' -import { TERMINAL_HTML_FRAGMENT_04 } from './terminal-webview-html/fragment-04' -import { TERMINAL_HTML_FRAGMENT_05 } from './terminal-webview-html/fragment-05' -import { TERMINAL_HTML_FRAGMENT_06 } from './terminal-webview-html/fragment-06' -import { TERMINAL_HTML_FRAGMENT_07 } from './terminal-webview-html/fragment-07' -import { TERMINAL_HTML_FRAGMENT_08 } from './terminal-webview-html/fragment-08' -import { TERMINAL_HTML_FRAGMENT_09 } from './terminal-webview-html/fragment-09' -import { TERMINAL_HTML_FRAGMENT_10 } from './terminal-webview-html/fragment-10' +import { TERMINAL_HTML_DOCUMENT_SHELL } from './terminal-webview-html/document-shell' +import { TERMINAL_HTML_RUNTIME_STATE_AND_TEXT_SCALING } from './terminal-webview-html/runtime-state-and-text-scaling' +import { TERMINAL_HTML_FIT_SCALE } from './terminal-webview-html/terminal-fit-scale' +import { TERMINAL_HTML_MOUSE_MODE_DECSET_SCAN } from './terminal-webview-html/mouse-mode-decset-scan' +import { TERMINAL_HTML_WRITE_QUEUE } from './terminal-webview-html/write-queue' +import { TERMINAL_HTML_INIT_AND_WRITE } from './terminal-webview-html/terminal-init-and-write' +import { TERMINAL_HTML_HOST_MESSAGE_ROUTER } from './terminal-webview-html/host-message-router' +import { TERMINAL_HTML_SELECTION_STATE_AND_EVICTION } from './terminal-webview-html/selection-state-and-eviction' +import { TERMINAL_HTML_OBSERVERS_AND_MODE_MIRRORING } from './terminal-webview-html/term-observers-and-mode-mirroring' +import { TERMINAL_HTML_MOUSE_REPORT_AND_SCROLL_ROUTING } from './terminal-webview-html/mouse-report-and-scroll-routing' +import { TERMINAL_HTML_SMOOTH_SCROLL_AND_CELL_GEOMETRY } from './terminal-webview-html/smooth-scroll-and-cell-geometry' +import { TERMINAL_HTML_SELECTION_OVERLAY } from './terminal-webview-html/selection-overlay' +import { TERMINAL_HTML_SURFACE_TOUCH_GESTURES } from './terminal-webview-html/surface-touch-gestures' +import { TERMINAL_HTML_MESSAGE_BRIDGE_AND_DOCUMENT_CLOSE } from './terminal-webview-html/message-bridge-and-document-close' export { MOBILE_TERMINAL_CARET_OPTIONS } from './terminal-webview-html/theme' -// Why: keep the document source stable while each script/style concern remains independently reviewable. +// Why: keep the document source stable while each script/style concern remains independently +// reviewable. Boundaries can only fall where the emitted document allows, so a few modules +// carry a second concern noted at the top of the file. export const XTERM_HTML = [ - TERMINAL_HTML_FRAGMENT_01, - TERMINAL_HTML_FRAGMENT_02, - TERMINAL_HTML_FRAGMENT_03, - TERMINAL_HTML_FRAGMENT_04, - TERMINAL_HTML_FRAGMENT_05, - TERMINAL_HTML_FRAGMENT_06, - TERMINAL_HTML_FRAGMENT_07, - TERMINAL_HTML_FRAGMENT_08, - TERMINAL_HTML_FRAGMENT_09, - TERMINAL_HTML_FRAGMENT_10 + TERMINAL_HTML_DOCUMENT_SHELL, + TERMINAL_HTML_RUNTIME_STATE_AND_TEXT_SCALING, + TERMINAL_HTML_FIT_SCALE, + TERMINAL_HTML_MOUSE_MODE_DECSET_SCAN, + TERMINAL_HTML_WRITE_QUEUE, + TERMINAL_HTML_INIT_AND_WRITE, + TERMINAL_HTML_HOST_MESSAGE_ROUTER, + TERMINAL_HTML_SELECTION_STATE_AND_EVICTION, + TERMINAL_HTML_OBSERVERS_AND_MODE_MIRRORING, + TERMINAL_HTML_MOUSE_REPORT_AND_SCROLL_ROUTING, + TERMINAL_HTML_SMOOTH_SCROLL_AND_CELL_GEOMETRY, + TERMINAL_HTML_SELECTION_OVERLAY, + TERMINAL_HTML_SURFACE_TOUCH_GESTURES, + TERMINAL_HTML_MESSAGE_BRIDGE_AND_DOCUMENT_CLOSE ].join('') export const XTERM_WEBVIEW_SOURCE = { html: XTERM_HTML } diff --git a/mobile/src/terminal/terminal-webview-html/fragment-01.ts b/mobile/src/terminal/terminal-webview-html/document-shell.ts similarity index 98% rename from mobile/src/terminal/terminal-webview-html/fragment-01.ts rename to mobile/src/terminal/terminal-webview-html/document-shell.ts index 77dba06232a..d6e733cdd77 100644 --- a/mobile/src/terminal/terminal-webview-html/fragment-01.ts +++ b/mobile/src/terminal/terminal-webview-html/document-shell.ts @@ -1,7 +1,7 @@ import { colors } from '../../theme/mobile-theme' import { XTERM_ENGINE_CSS, XTERM_ENGINE_JS } from '../terminal-webview-engine.generated' -export const TERMINAL_HTML_FRAGMENT_01 = ` +export const TERMINAL_HTML_DOCUMENT_SHELL = ` diff --git a/mobile/src/terminal/terminal-webview-html/fragment-03.ts b/mobile/src/terminal/terminal-webview-html/fragment-03.ts deleted file mode 100644 index 075ee819ec6..00000000000 --- a/mobile/src/terminal/terminal-webview-html/fragment-03.ts +++ /dev/null @@ -1,287 +0,0 @@ -import { TERMINAL_WEBVIEW_THEME_JS } from '../terminal-webview-theme-injected' - -export const TERMINAL_HTML_FRAGMENT_03 = `${TERMINAL_WEBVIEW_THEME_JS} - - function getCellHeight() { - if (!term || !term._core) return 15; - var core = term._core; - if (core._renderService && core._renderService.dimensions) { - return core._renderService.dimensions.css.cell.height || 15; - } - return 15; - } - - // Why: clamp pan so the terminal content always covers the viewport - // when zoomed in. When content is smaller than viewport in a - // dimension, pin to top-left (no floating in the middle). - function clampPan() { - if (!term || !term.element) return; - var ts = getTotalScale(); - var cw = term.element.scrollWidth * ts; - var ch = term.element.scrollHeight * ts; - var vpW = window.innerWidth; - var vpH = window.innerHeight; - if (cw > vpW) { - panX = Math.min(0, Math.max(vpW - cw, panX)); - } else { - panX = 0; - } - if (ch > vpH) { - panY = Math.min(0, Math.max(vpH - ch, panY)); - } else { - panY = 0; - } - } - - // Why: intentional no-op. Mobile replays a live PTY snapshot then applies - // live cursor-relative chunks from that same PTY; resizing only the WebView - // xterm changes cursor coordinates and makes TUI repaint chunks duplicate or - // overlap. Kept as a no-op so its call sites stay legible. - function adjustRowsForViewport() {} - - // Why: cold-start fit. After init() opens xterm, the renderer needs - // several frames before cell dimensions are computed. Reading too early - // gives cellWidth=0 (renderer service not ready) or scrollWidth=0 (DOM - // not laid out), and computeFitScale returns 1 → no zoom. - // - // Gate: cellWidth × cols is the canonical "logical width" of the grid - // and reflects xterm's layout decision, independent of buffer content. - // We commit when cellWidth becomes positive (renderer ready). Fallback: - // if cellWidth never becomes available, gate on stable positive - // scrollWidth (xterm rendered something). Cap at 60 frames (~1s @60Hz) - // so a backgrounded WebView never spins forever. - var FIT_RETRY_MAX_FRAMES = 60; - var fitRetryToken = 0; - function applyFitScale(reason) { - if (!term || !term.element) return; - var token = ++fitRetryToken; - var attempts = 0; - var lastScrollWidth = -1; - function attempt() { - if (token !== fitRetryToken) return; - if (!term || !term.element) return; - attempts++; - var cellW = getCellWidth(); - if (cellW > 0 && term.cols > 0) { - commitFitScale(reason, attempts, 'cellW'); - return; - } - var w = term.element.scrollWidth; - if (w > 0 && w === lastScrollWidth) { - commitFitScale(reason, attempts, 'stableSW'); - return; - } - lastScrollWidth = w; - if (attempts >= FIT_RETRY_MAX_FRAMES) { - flog('commit-timeout', { - reason: reason, - attempts: attempts, - cellW: cellW, - scrollWidth: w, - cols: term.cols - }); - commitFitScale(reason, attempts, 'timeout'); - return; - } - requestAnimationFrame(attempt); - } - requestAnimationFrame(attempt); - } - - function commitFitScale(reason, attempts, gate) { - if (!term || !term.element) return; - var preSnapScale = computeFitScale(); - currentScale = preSnapScale; - // Why: when scale is very close to 1 (e.g. 0.97 from xterm scrollbar - // sub-pixels) snap to 1 to avoid imperceptible shrinkage that prevents - // a second applyFitScale from observing a "no-op needed" state. - if (currentScale >= 0.95) currentScale = 1; - userScale = 1; - panX = 0; - panY = 0; - smoothScrollOffsetY = 0; - updateTransform(); - adjustRowsForViewport(); - - var cellW = getCellWidth(); - var sw = term.element.scrollWidth; - var vpW = window.innerWidth; - var expectedW = cellW * term.cols; - var suspect = - currentScale === 1 && term.cols > 0 && expectedW > vpW + 1; // expected wider than viewport but no zoom - if (suspect) { - flog('commit-SUSPECT', { - reason: reason, - attempts: attempts, - gate: gate, - preSnapScale: preSnapScale, - finalScale: currentScale, - cellW: cellW, - cols: term.cols, - expectedW: expectedW, - scrollWidth: sw, - vpWidth: vpW - }); - } - repositionOverlay(); - } - - function isAltScreenActive(data) { - if (typeof data !== 'string') return false; - var on = data.lastIndexOf(ESC + '[?1049h'); - var off = data.lastIndexOf(ESC + '[?1049l'); - return on !== -1 && on > off; - } - - function normalizeInitialData(data) { - if (!isAltScreenActive(data)) return data; - var on = data.lastIndexOf(ESC + '[?1049h'); - // Why: SerializeAddon can include normal-buffer scrollback before the - // active alternate-screen snapshot. Replaying both into a fresh mobile - // xterm duplicates TUI frames and can flatten SGR attributes. - return on > 0 ? data.slice(on) : data; - } - - function updateMouseModeFromData(data) { - if (typeof data !== 'string' || data.length === 0) return; - var input = mouseModeScanTail + data; - mouseModeScanTail = extractMouseModeScanTail(input); - var re = new RegExp(ESC + 'c|' + ESC + '\\\\[\\\\?([0-9;]+)([hl])|' + C1_CSI + '\\\\?([0-9;]+)([hl])', 'g'); - var match; - while ((match = re.exec(input)) !== null) { - if (match[0] === ESC + 'c') { - trackedMouseTrackingMode = 'none'; - sgrMouseMode = false; - sgrMousePixelsMode = false; - continue; - } - var enabled = (match[2] || match[4]) === 'h'; - var params = (match[1] || match[3]).split(';'); - for (var i = 0; i < params.length; i++) { - if (params[i] === '') continue; - var param = Number(params[i]); - if (!Number.isInteger(param)) continue; - if (param === 9) trackedMouseTrackingMode = enabled ? 'x10' : 'none'; - if (param === 1000) trackedMouseTrackingMode = enabled ? 'vt200' : 'none'; - if (param === 1002) trackedMouseTrackingMode = enabled ? 'drag' : 'none'; - if (param === 1003) trackedMouseTrackingMode = enabled ? 'any' : 'none'; - if (param === 1006) { - sgrMouseMode = enabled; - sgrMousePixelsMode = false; - } - if (param === 1016) { - sgrMouseMode = false; - sgrMousePixelsMode = enabled; - } - } - } - } - - function resetWriteQueue() { - writeQueue = []; - writeQueueHead = 0; - } - - function isStatusDotPresentationSelector(value) { - return value === TEXT_PRESENTATION_SELECTOR || value === EMOJI_PRESENTATION_SELECTOR; - } - - function endsWithStatusDotPresentationSequence(data) { - var i = data.length - 1; - while (i >= 0 && isStatusDotPresentationSelector(data.charAt(i))) i--; - return i >= 0 && data.charAt(i) === CLAUDE_STATUS_DOT; - } - - // Why: iOS WebKit promotes Claude's record/status dot to a colorful emoji glyph. - function normalizeStatusDotPresentation(data) { - if (typeof data !== 'string' || data.length === 0) return data; - if (statusDotPendingSelector) { - statusDotPendingSelector = false; - var strippedPendingSelectors = false; - while (data.length > 0 && isStatusDotPresentationSelector(data.charAt(0))) data = data.slice(1); - strippedPendingSelectors = data.length === 0; - if (strippedPendingSelectors) { - statusDotPendingSelector = true; - return ''; - } - } - var normalized = data.replace(CLAUDE_STATUS_DOT_PATTERN, CLAUDE_STATUS_DOT + TEXT_PRESENTATION_SELECTOR); - statusDotPendingSelector = endsWithStatusDotPresentationSequence(data); - return normalized; - } - - function enqueueWrite(data) { - writeQueue.push(normalizeStatusDotPresentation(data)); - } - - function enqueueWriteBoundary(callback) { - writeQueue.push(callback); - } - - function nextQueuedWrite() { - if (writeQueueHead >= writeQueue.length) { - resetWriteQueue(); - return undefined; - } - var next = writeQueue[writeQueueHead]; - writeQueueHead++; - // Why: high-throughput terminals can enqueue faster than xterm parses; - // compact consumed slots so drain work stays O(1) without retaining old chunks. - if (writeQueueHead > 128 && writeQueueHead * 2 > writeQueue.length) { - writeQueue = writeQueue.slice(writeQueueHead); - writeQueueHead = 0; - } - return next; - } - - function disposeTermObservers() { - var disposables = termObserverDisposables; - termObserverDisposables = []; - for (var i = 0; i < disposables.length; i++) { - try { disposables[i] && disposables[i].dispose && disposables[i].dispose(); } catch (e) {} - } - } - - function extractMouseModeScanTail(input) { - var start = Math.max(input.lastIndexOf(ESC), input.lastIndexOf(C1_CSI)); - if (start === -1) return ''; - var tail = input.slice(start); - // Why: PTY/SSH chunks can split a long combined DECSET before the final h/l. - // Keep parser state far beyond normal mode lists while still bounding memory. - if (tail.length > PRIVATE_MODE_SCAN_TAIL_LIMIT) return ''; - if (tail === ESC || tail === ESC + '[' || tail === C1_CSI) return tail; - if (tail.indexOf(ESC + '[?') === 0) { - return /^[0-9;]*$/.test(tail.slice(3)) ? tail : ''; - } - if (tail.indexOf(C1_CSI + '?') === 0) { - return /^[0-9;]*$/.test(tail.slice(2)) ? tail : ''; - } - return ''; - } - - function pumpWrites(gen) { - if (!ready || !term || writesDraining || gen !== terminalGeneration) return; - var next = nextQueuedWrite(); - if (typeof next !== 'string') { - if (typeof next === 'function') return next(), pumpWrites(gen); - var callbacks = afterDrainCallbacks; - afterDrainCallbacks = []; - for (var i = 0; i < callbacks.length; i++) callbacks[i](); - return; - } - writesDraining = true; - // Why: xterm.write() parses asynchronously. Row adjustment/resizing must - // wait until replayed SGR attributes have landed in the buffer. - term.write(next, function() { - if (gen !== terminalGeneration) return; - writesDraining = false; - pumpWrites(gen); - }); - } - - function afterWritesDrained(callback) { - afterDrainCallbacks.push(callback); - pumpWrites(terminalGeneration); - } - -` diff --git a/mobile/src/terminal/terminal-webview-html/fragment-10.ts b/mobile/src/terminal/terminal-webview-html/fragment-10.ts deleted file mode 100644 index d461f4a2039..00000000000 --- a/mobile/src/terminal/terminal-webview-html/fragment-10.ts +++ /dev/null @@ -1,132 +0,0 @@ -export const TERMINAL_HTML_FRAGMENT_10 = ` updateTransform(); - } - - var deltaY = ts.lastY - y; - ts.lastTime = now; - if (shouldRouteScrollToTerminalInput()) { - updateTouchVelocity(deltaY, dt); - resetSmoothScrollOffset(); - var effectiveCellH = getCellHeight() * getTotalScale(); - ts.accumDelta += deltaY; - var lines = Math.trunc(ts.accumDelta / effectiveCellH); - if (lines !== 0) { - ts.accumDelta -= lines * effectiveCellH; - routeScrollLines(lines, x, y); - } - } else { - if (enqueueNormalBufferScrollDelta(deltaY)) { - updateTouchVelocity(deltaY, dt); - } else { - ts.velY = 0; - } - } - ts.lastX = x; - ts.lastY = y; - } - }, { capture: true, passive: false }); - - targetSurface.addEventListener('touchend', function(e) { - if (dispatcherShouldBlockSurface()) return; - if (!term) return; - - if (ts.isPinching && e.touches.length < 2) { - ts.isPinching = false; - // Why: a finished pinch snaps to the nearest preset and becomes the new - // font size (reflowing the grid), so pinch-to-zoom IS the in-terminal way - // to set the text size. The CSS pinch zoom (userScale) is reset; the real - // size change reflows columns and RN persists + resizes the PTY to match. - var target = snapToTextScalePreset(currentTextScale * userScale); - var changed = target !== currentTextScale; - userScale = 1; - panX = 0; panY = 0; - applyTextScale(target); - updateTransform(); - notify({ type: 'font-scale-changed', fontScale: target }); - if (changed) notify({ type: 'haptic', kind: 'selection' }); - if (e.touches.length === 1) { - ts.lastX = e.touches[0].clientX; - ts.lastY = e.touches[0].clientY; - ts.lastTime = Date.now(); - ts.velY = 0; - ts.accumDelta = 0; - } - return; - } - - if (e.touches.length === 0) { - var vel = ts.velY; - var FRICTION = 0.972; - var MIN_VEL = 0.012; - function momentumStep() { - vel *= FRICTION; - if (Math.abs(vel) < MIN_VEL) { ts.momentumId = null; return; } - var delta = vel * 16; - if (shouldRouteScrollToTerminalInput()) { - resetSmoothScrollOffset(); - var effectiveCellH = getCellHeight() * getTotalScale(); - ts.accumDelta += delta; - var lines = Math.trunc(ts.accumDelta / effectiveCellH); - if (lines !== 0) { - ts.accumDelta -= lines * effectiveCellH; - routeScrollLines(lines, ts.lastX, ts.lastY); - } - } else { - if (!applyNormalBufferScrollDelta(delta)) { - ts.momentumId = null; - return; - } - } - ts.momentumId = requestAnimationFrame(momentumStep); - } - if (Math.abs(vel) > MIN_VEL) { - ts.momentumId = requestAnimationFrame(momentumStep); - } - } - }, { capture: true, passive: true }); - } - - attachSurfaceEventHandlers(surface); - - function handleIncomingMessage(e) { - var msg; - try { - msg = typeof e.data === 'string' ? JSON.parse(e.data) : e.data; - } catch (ex) { - return; - } - try { - handleMsg(msg); - } catch(ex) { - reportEngineError( - msg && msg.type === 'init' ? 'terminal init failed' : 'terminal message failed', - ex, - msg && msg.type === 'init' && !everReady - ); - } - } - - window.addEventListener('message', handleIncomingMessage); - - document.addEventListener('message', handleIncomingMessage); - - window.addEventListener('resize', function() { - // Why: viewport changed (keyboard open/close, orientation, RN container - // size update). Re-fit so the scale matches the new vpWidth — without - // this, opening the keyboard leaves the terminal at the old scale even - // though there's now less vertical room and the fit ratio may differ. - applyFitScale('window-resize'); - adjustRowsForViewport(); - repositionOverlay(); - clampPan(); - updateTransform(); - }); - - if (window.Terminal) { - notify({ type: 'web-ready' }); - } else { - reportEngineError('terminal engine missing', 'xterm failed to load', true); - } -})(); - - -` diff --git a/mobile/src/terminal/terminal-webview-html/fragment-05.ts b/mobile/src/terminal/terminal-webview-html/host-message-router.ts similarity index 77% rename from mobile/src/terminal/terminal-webview-html/fragment-05.ts rename to mobile/src/terminal/terminal-webview-html/host-message-router.ts index c8852c50d78..29cba25df36 100644 --- a/mobile/src/terminal/terminal-webview-html/fragment-05.ts +++ b/mobile/src/terminal/terminal-webview-html/host-message-router.ts @@ -1,6 +1,6 @@ import { TERMINAL_REFLOW_JS } from '../terminal-webview-reflow-injected' -export const TERMINAL_HTML_FRAGMENT_05 = ` ${TERMINAL_REFLOW_JS} +export const TERMINAL_HTML_HOST_MESSAGE_ROUTER = ` ${TERMINAL_REFLOW_JS} function notify(msg) { if (window.ReactNativeWebView) { @@ -186,45 +186,4 @@ export const TERMINAL_HTML_FRAGMENT_05 = ` ${TERMINAL_REFLOW_JS} } } - // ============================================================ - // SELECTION MODE (long-press → handles → Copy) - // ============================================================ - var WORD_RE = /[\\p{L}\\p{N}_./:@~+=?&#%-]/u; - var LONG_PRESS_MS = 500; - var LONG_PRESS_SLOP = 10; - // Why: a tap that opens a link/path must survive small finger jitter. The - // long-press slop (10px) only cancels the press-to-select timer; reusing it - // to gate the tap dropped any URL/file tap that wandered >10px — at fit scale - // a few screen px of jitter is a normal tap. Use a wider, time-bounded tap - // window so deliberate scrolls/pans still don't fire a tap. - var TAP_SLOP = 24; - var TAP_MAX_MS = 700; - var EDGE_SCROLL_PX = 40; - var EDGE_SCROLL_INTERVAL = 60; - - var selectionOverlay = document.getElementById('selection-overlay'); - var handleStart = document.getElementById('sel-handle-start'); - var handleEnd = document.getElementById('sel-handle-end'); - var selMenu = document.getElementById('sel-menu'); - var btnCopy = document.getElementById('sel-menu-copy'); - var btnSelAll = document.getElementById('sel-menu-all'); - - // mode: 'navigate' | 'select' - var selMode = 'navigate'; - var sel = null; // { anchor:{col,row}, focus:{col,row}, activeHandle:null|'start'|'end' } - var longPressTimer = null; - var longPressOrigin = null; // {x,y, identifier} - // Why: tap detection is tracked separately from the long-press timer so a - // small jitter that cancels the press-to-select timer does not also cancel - // the tap (which opens links/paths). {x,y,t,identifier} or null once the - // gesture is disqualified as a tap (moved too far or held too long). - var tapCandidate = null; - var edgeScrollTimer = null; - var edgeScrollDir = 0; - var edgeScrollClientX = 0; - var edgeScrollClientY = 0; - - // Eviction watchdog: linesEverWritten counts onLineFeed since last init. - // Once buffer is full, every onLineFeed evicts the top row in xterm and - // we mirror that by decrementing stored absolute rows. ` diff --git a/mobile/src/terminal/terminal-webview-html/message-bridge-and-document-close.ts b/mobile/src/terminal/terminal-webview-html/message-bridge-and-document-close.ts new file mode 100644 index 00000000000..49cd3b4d84f --- /dev/null +++ b/mobile/src/terminal/terminal-webview-html/message-bridge-and-document-close.ts @@ -0,0 +1,43 @@ +export const TERMINAL_HTML_MESSAGE_BRIDGE_AND_DOCUMENT_CLOSE = ` function handleIncomingMessage(e) { + var msg; + try { + msg = typeof e.data === 'string' ? JSON.parse(e.data) : e.data; + } catch (ex) { + return; + } + try { + handleMsg(msg); + } catch(ex) { + reportEngineError( + msg && msg.type === 'init' ? 'terminal init failed' : 'terminal message failed', + ex, + msg && msg.type === 'init' && !everReady + ); + } + } + + window.addEventListener('message', handleIncomingMessage); + + document.addEventListener('message', handleIncomingMessage); + + window.addEventListener('resize', function() { + // Why: viewport changed (keyboard open/close, orientation, RN container + // size update). Re-fit so the scale matches the new vpWidth — without + // this, opening the keyboard leaves the terminal at the old scale even + // though there's now less vertical room and the fit ratio may differ. + applyFitScale('window-resize'); + adjustRowsForViewport(); + repositionOverlay(); + clampPan(); + updateTransform(); + }); + + if (window.Terminal) { + notify({ type: 'web-ready' }); + } else { + reportEngineError('terminal engine missing', 'xterm failed to load', true); + } +})(); + + +` diff --git a/mobile/src/terminal/terminal-webview-html/mouse-mode-decset-scan.ts b/mobile/src/terminal/terminal-webview-html/mouse-mode-decset-scan.ts new file mode 100644 index 00000000000..6f0685df87e --- /dev/null +++ b/mobile/src/terminal/terminal-webview-html/mouse-mode-decset-scan.ts @@ -0,0 +1,52 @@ +export const TERMINAL_HTML_MOUSE_MODE_DECSET_SCAN = ` function isAltScreenActive(data) { + if (typeof data !== 'string') return false; + var on = data.lastIndexOf(ESC + '[?1049h'); + var off = data.lastIndexOf(ESC + '[?1049l'); + return on !== -1 && on > off; + } + + function normalizeInitialData(data) { + if (!isAltScreenActive(data)) return data; + var on = data.lastIndexOf(ESC + '[?1049h'); + // Why: SerializeAddon can include normal-buffer scrollback before the + // active alternate-screen snapshot. Replaying both into a fresh mobile + // xterm duplicates TUI frames and can flatten SGR attributes. + return on > 0 ? data.slice(on) : data; + } + + function updateMouseModeFromData(data) { + if (typeof data !== 'string' || data.length === 0) return; + var input = mouseModeScanTail + data; + mouseModeScanTail = extractMouseModeScanTail(input); + var re = new RegExp(ESC + 'c|' + ESC + '\\\\[\\\\?([0-9;]+)([hl])|' + C1_CSI + '\\\\?([0-9;]+)([hl])', 'g'); + var match; + while ((match = re.exec(input)) !== null) { + if (match[0] === ESC + 'c') { + trackedMouseTrackingMode = 'none'; + sgrMouseMode = false; + sgrMousePixelsMode = false; + continue; + } + var enabled = (match[2] || match[4]) === 'h'; + var params = (match[1] || match[3]).split(';'); + for (var i = 0; i < params.length; i++) { + if (params[i] === '') continue; + var param = Number(params[i]); + if (!Number.isInteger(param)) continue; + if (param === 9) trackedMouseTrackingMode = enabled ? 'x10' : 'none'; + if (param === 1000) trackedMouseTrackingMode = enabled ? 'vt200' : 'none'; + if (param === 1002) trackedMouseTrackingMode = enabled ? 'drag' : 'none'; + if (param === 1003) trackedMouseTrackingMode = enabled ? 'any' : 'none'; + if (param === 1006) { + sgrMouseMode = enabled; + sgrMousePixelsMode = false; + } + if (param === 1016) { + sgrMouseMode = false; + sgrMousePixelsMode = enabled; + } + } + } + } + +` diff --git a/mobile/src/terminal/terminal-webview-html/fragment-06.ts b/mobile/src/terminal/terminal-webview-html/mouse-report-and-scroll-routing.ts similarity index 56% rename from mobile/src/terminal/terminal-webview-html/fragment-06.ts rename to mobile/src/terminal/terminal-webview-html/mouse-report-and-scroll-routing.ts index 1a03331df34..3b7e7f24cf3 100644 --- a/mobile/src/terminal/terminal-webview-html/fragment-06.ts +++ b/mobile/src/terminal/terminal-webview-html/mouse-report-and-scroll-routing.ts @@ -1,100 +1,6 @@ -import { TERMINAL_KEYBOARD_AVOIDANCE_METRICS_JS } from '../terminal-keyboard-avoidance-metrics-injected' import { TERMINAL_MOUSE_REPORT_CELL_JS } from '../terminal-webview-mouse-report-cell-injected' -export const TERMINAL_HTML_FRAGMENT_06 = ` var linesEverWritten = 0; - - function resetEvictionCounter() { linesEverWritten = 0; } - - function isBufferFull() { - if (!term) return false; - return linesEverWritten >= 5000 + (term.rows || 0); - } - - function checkEviction() { - if (selMode !== 'select' || !sel) return; - var oldest = Math.min(sel.anchor.row, sel.focus.row); - if (oldest < 0) { - notify({ type: 'selection-evicted' }); - cancelSelect(); - } - } - - function logFeedAndEvict() { - linesEverWritten++; - if (initialOscLinkEvictionReady && isBufferFull()) initialOscLinkRowOffset += 1; - if (selMode === 'select' && sel && isBufferFull()) { - sel.anchor.row -= 1; - sel.focus.row -= 1; - checkEviction(); - repositionOverlay(); - } - } - - function emitModesIfChanged() { - if (!term) return; - var bp = !!(term.modes && term.modes.bracketedPasteMode); - var alt = false; - var mouseTrackingMode = getMouseTrackingMode(); - try { alt = term.buffer && term.buffer.active && term.buffer.active.type === 'alternate'; } catch (e) {} - if ( - bp !== lastEmittedModes.bracketedPasteMode || - alt !== lastEmittedModes.altScreen || - mouseTrackingMode !== lastEmittedModes.mouseTrackingMode || - sgrMouseMode !== lastEmittedModes.sgrMouseMode || - sgrMousePixelsMode !== lastEmittedModes.sgrMousePixelsMode - ) { - lastEmittedModes = { - bracketedPasteMode: bp, - altScreen: alt, - mouseTrackingMode: mouseTrackingMode, - sgrMouseMode: sgrMouseMode, - sgrMousePixelsMode: sgrMousePixelsMode - }; - notify({ - type: 'modes', - bracketedPasteMode: bp, - altScreen: alt, - mouseTrackingMode: mouseTrackingMode, - sgrMouseMode: sgrMouseMode, - sgrMousePixelsMode: sgrMousePixelsMode - }); - } - } - var lastEmittedModes = { - bracketedPasteMode: false, - altScreen: false, - mouseTrackingMode: 'none', - sgrMouseMode: false, - sgrMousePixelsMode: false - }; - - ${TERMINAL_KEYBOARD_AVOIDANCE_METRICS_JS} - - function attachTermObservers() { - if (!term) return; - disposeTermObservers(); - try { termObserverDisposables.push(term.onLineFeed(logFeedAndEvict)); } catch (e) {} - try { - termObserverDisposables.push(term.onScroll(function() { updateScrollIndicator(false); })); - } catch (e) {} - // Why: emit modes on every parsed write so RN's mirror stays current - // without round-trip; covers \\x1b[?2004h/l and alt-screen toggles. - try { - if (term.onWriteParsed) { - termObserverDisposables.push(term.onWriteParsed(function() { - emitModesIfChanged(); - emitKeyboardAvoidanceMetrics(); - })); - } - } catch (e) {} - // Initial emit once buffer settles. - afterWritesDrained(function() { - emitModesIfChanged(); - emitKeyboardAvoidanceMetrics(); - }); - } - - function viewportToCell(clientX, clientY) { +export const TERMINAL_HTML_MOUSE_REPORT_AND_SCROLL_ROUTING = ` function viewportToCell(clientX, clientY) { if (!term) return null; var cellW = getCellWidth(); var cellH = getCellHeight(); @@ -201,4 +107,82 @@ export const TERMINAL_HTML_FRAGMENT_06 = ` var linesEverWritten = 0; } if (sgrMouseMode) { // Why: xterm increments zero-based mouse cells before encoding reports. + var sgrCol = cell.col + 1; + var sgrRow = cell.row + 1; + if (!isSafeSgrMouseCoordinate(sgrCol) || !isSafeSgrMouseCoordinate(sgrRow)) return ''; + var sgrPress = ESC + '[<0;' + sgrCol + ';' + sgrRow + 'M'; + if (mouseTrackingMode === 'x10') return sgrPress; + return sgrPress + ESC + '[<0;' + sgrCol + ';' + sgrRow + 'm'; + } + // Why: non-SGR click coordinates use printable ASCII bytes on the mobile + // bridge; unsafe wide-terminal cells must not turn into corrupted input. + var col = cell.col + 1 + 32; + var row = cell.row + 1 + 32; + if (col > 126 || row > 126) return ''; + var press = ESC + '[M' + String.fromCharCode(32) + String.fromCharCode(col) + String.fromCharCode(row); + if (mouseTrackingMode === 'x10') return press; + return press + ESC + '[M' + String.fromCharCode(35) + String.fromCharCode(col) + String.fromCharCode(row); + } + + function isClickMouseTrackingMode(mode) { + return mode !== 'none'; + } + + function isWheelMouseTrackingMode(mode) { + return mode !== 'none' && mode !== 'x10'; + } + + function shouldRouteScrollToTerminalInput() { + return isWheelMouseTrackingMode(getMouseTrackingMode()) || isAlternateBufferActive(); + } + + function buildMouseWheelScrollInput(lines, clientX, clientY) { + var count = Math.min(Math.abs(lines), 32); + if (count === 0) return ''; + var sequence = buildMouseWheelSequence(lines, clientX, clientY); + if (!sequence) return ''; + return repeatSequence(sequence, count); + } + + function buildTuiScrollInput(lines, clientX, clientY) { + var count = Math.min(Math.abs(lines), 32); + if (count === 0) return ''; + var mouseTrackingMode = getMouseTrackingMode(); + var sequence = ''; + if (isWheelMouseTrackingMode(mouseTrackingMode)) { + sequence = buildMouseWheelSequence(lines, clientX, clientY); + } + if (!sequence) sequence = buildArrowScrollSequence(lines); + return repeatSequence(sequence, count); + } + + function routeScrollLines(lines, clientX, clientY) { + if (!term || lines === 0) return; + var mouseTrackingMode = getMouseTrackingMode(); + var alternateBufferActive = isAlternateBufferActive(); + if (isWheelMouseTrackingMode(mouseTrackingMode)) { + // Why: xterm sends wheel events to mouse-aware TUIs before considering + // scrollback, even if the app stays on the normal buffer. + var mouseInput = buildMouseWheelScrollInput(lines, clientX, clientY); + if (mouseInput) { + notify({ type: 'terminal-input', bytes: mouseInput }); + return; + } + // Why: default mouse encoding can be unrepresentable in our ASCII-safe + // RPC path on wide terminals. Send bounded arrows instead of local + // scrollback/no-op while a mouse-aware app owns scroll gestures. + var fallbackInput = buildTuiScrollInput(lines, clientX, clientY); + if (fallbackInput) notify({ type: 'terminal-input', bytes: fallbackInput }); + return; + } + if (alternateBufferActive) { + // Why: alternate-screen TUIs own their scroll state and xterm has no + // scrollback there, so mobile scroll gestures must become terminal input. + var input = buildTuiScrollInput(lines, clientX, clientY); + if (input) notify({ type: 'terminal-input', bytes: input }); + return; + } + term.scrollLines(lines); + } + ` diff --git a/mobile/src/terminal/terminal-webview-html/fragment-02.ts b/mobile/src/terminal/terminal-webview-html/runtime-state-and-text-scaling.ts similarity index 97% rename from mobile/src/terminal/terminal-webview-html/fragment-02.ts rename to mobile/src/terminal/terminal-webview-html/runtime-state-and-text-scaling.ts index 7d25b0fb6b2..44ce1d39042 100644 --- a/mobile/src/terminal/terminal-webview-html/fragment-02.ts +++ b/mobile/src/terminal/terminal-webview-html/runtime-state-and-text-scaling.ts @@ -3,7 +3,8 @@ import { TERMINAL_SURFACE_SWAP_JS } from '../terminal-webview-surface-swap-injec import { TERMINAL_TEXT_SCALES } from '../../storage/preferences' import { DEFAULT_TERMINAL_THEME } from './theme' -export const TERMINAL_HTML_FRAGMENT_02 = ` var PRIVATE_MODE_SCAN_TAIL_LIMIT = 4096; +// Also carries the scroll-indicator painter, which reads the scale state declared here. +export const TERMINAL_HTML_RUNTIME_STATE_AND_TEXT_SCALING = ` var PRIVATE_MODE_SCAN_TAIL_LIMIT = 4096; var term = null; ${TERMINAL_QUERY_REPLY_JS} ${TERMINAL_SURFACE_SWAP_JS} var scrollIndicator = document.getElementById('scroll-indicator'); diff --git a/mobile/src/terminal/terminal-webview-html/fragment-08.ts b/mobile/src/terminal/terminal-webview-html/selection-overlay.ts similarity index 97% rename from mobile/src/terminal/terminal-webview-html/fragment-08.ts rename to mobile/src/terminal/terminal-webview-html/selection-overlay.ts index 29f0d929918..335407af51a 100644 --- a/mobile/src/terminal/terminal-webview-html/fragment-08.ts +++ b/mobile/src/terminal/terminal-webview-html/selection-overlay.ts @@ -1,7 +1,8 @@ import { TERMINAL_PATH_TAP_JS } from '../terminal-path-tap-injected' import { URL_TAP_WEBVIEW_JS } from '../terminal-webview-url-tap' -export const TERMINAL_HTML_FRAGMENT_08 = ` ${TERMINAL_PATH_TAP_JS} +// Opens with the path/url tap matchers: they land at this point in the emitted document. +export const TERMINAL_HTML_SELECTION_OVERLAY = ` ${TERMINAL_PATH_TAP_JS} ${URL_TAP_WEBVIEW_JS} function seedWordSelection(col, absRow) { diff --git a/mobile/src/terminal/terminal-webview-html/selection-state-and-eviction.ts b/mobile/src/terminal/terminal-webview-html/selection-state-and-eviction.ts new file mode 100644 index 00000000000..48c05b6ad85 --- /dev/null +++ b/mobile/src/terminal/terminal-webview-html/selection-state-and-eviction.ts @@ -0,0 +1,71 @@ +export const TERMINAL_HTML_SELECTION_STATE_AND_EVICTION = ` // ============================================================ + // SELECTION MODE (long-press → handles → Copy) + // ============================================================ + var WORD_RE = /[\\p{L}\\p{N}_./:@~+=?&#%-]/u; + var LONG_PRESS_MS = 500; + var LONG_PRESS_SLOP = 10; + // Why: a tap that opens a link/path must survive small finger jitter. The + // long-press slop (10px) only cancels the press-to-select timer; reusing it + // to gate the tap dropped any URL/file tap that wandered >10px — at fit scale + // a few screen px of jitter is a normal tap. Use a wider, time-bounded tap + // window so deliberate scrolls/pans still don't fire a tap. + var TAP_SLOP = 24; + var TAP_MAX_MS = 700; + var EDGE_SCROLL_PX = 40; + var EDGE_SCROLL_INTERVAL = 60; + + var selectionOverlay = document.getElementById('selection-overlay'); + var handleStart = document.getElementById('sel-handle-start'); + var handleEnd = document.getElementById('sel-handle-end'); + var selMenu = document.getElementById('sel-menu'); + var btnCopy = document.getElementById('sel-menu-copy'); + var btnSelAll = document.getElementById('sel-menu-all'); + + // mode: 'navigate' | 'select' + var selMode = 'navigate'; + var sel = null; // { anchor:{col,row}, focus:{col,row}, activeHandle:null|'start'|'end' } + var longPressTimer = null; + var longPressOrigin = null; // {x,y, identifier} + // Why: tap detection is tracked separately from the long-press timer so a + // small jitter that cancels the press-to-select timer does not also cancel + // the tap (which opens links/paths). {x,y,t,identifier} or null once the + // gesture is disqualified as a tap (moved too far or held too long). + var tapCandidate = null; + var edgeScrollTimer = null; + var edgeScrollDir = 0; + var edgeScrollClientX = 0; + var edgeScrollClientY = 0; + + // Eviction watchdog: linesEverWritten counts onLineFeed since last init. + // Once buffer is full, every onLineFeed evicts the top row in xterm and + // we mirror that by decrementing stored absolute rows. + var linesEverWritten = 0; + + function resetEvictionCounter() { linesEverWritten = 0; } + + function isBufferFull() { + if (!term) return false; + return linesEverWritten >= 5000 + (term.rows || 0); + } + + function checkEviction() { + if (selMode !== 'select' || !sel) return; + var oldest = Math.min(sel.anchor.row, sel.focus.row); + if (oldest < 0) { + notify({ type: 'selection-evicted' }); + cancelSelect(); + } + } + + function logFeedAndEvict() { + linesEverWritten++; + if (initialOscLinkEvictionReady && isBufferFull()) initialOscLinkRowOffset += 1; + if (selMode === 'select' && sel && isBufferFull()) { + sel.anchor.row -= 1; + sel.focus.row -= 1; + checkEviction(); + repositionOverlay(); + } + } + +` diff --git a/mobile/src/terminal/terminal-webview-html/fragment-07.ts b/mobile/src/terminal/terminal-webview-html/smooth-scroll-and-cell-geometry.ts similarity index 54% rename from mobile/src/terminal/terminal-webview-html/fragment-07.ts rename to mobile/src/terminal/terminal-webview-html/smooth-scroll-and-cell-geometry.ts index 22c3372b26f..de9db152fbf 100644 --- a/mobile/src/terminal/terminal-webview-html/fragment-07.ts +++ b/mobile/src/terminal/terminal-webview-html/smooth-scroll-and-cell-geometry.ts @@ -1,82 +1,4 @@ -export const TERMINAL_HTML_FRAGMENT_07 = ` var sgrCol = cell.col + 1; - var sgrRow = cell.row + 1; - if (!isSafeSgrMouseCoordinate(sgrCol) || !isSafeSgrMouseCoordinate(sgrRow)) return ''; - var sgrPress = ESC + '[<0;' + sgrCol + ';' + sgrRow + 'M'; - if (mouseTrackingMode === 'x10') return sgrPress; - return sgrPress + ESC + '[<0;' + sgrCol + ';' + sgrRow + 'm'; - } - // Why: non-SGR click coordinates use printable ASCII bytes on the mobile - // bridge; unsafe wide-terminal cells must not turn into corrupted input. - var col = cell.col + 1 + 32; - var row = cell.row + 1 + 32; - if (col > 126 || row > 126) return ''; - var press = ESC + '[M' + String.fromCharCode(32) + String.fromCharCode(col) + String.fromCharCode(row); - if (mouseTrackingMode === 'x10') return press; - return press + ESC + '[M' + String.fromCharCode(35) + String.fromCharCode(col) + String.fromCharCode(row); - } - - function isClickMouseTrackingMode(mode) { - return mode !== 'none'; - } - - function isWheelMouseTrackingMode(mode) { - return mode !== 'none' && mode !== 'x10'; - } - - function shouldRouteScrollToTerminalInput() { - return isWheelMouseTrackingMode(getMouseTrackingMode()) || isAlternateBufferActive(); - } - - function buildMouseWheelScrollInput(lines, clientX, clientY) { - var count = Math.min(Math.abs(lines), 32); - if (count === 0) return ''; - var sequence = buildMouseWheelSequence(lines, clientX, clientY); - if (!sequence) return ''; - return repeatSequence(sequence, count); - } - - function buildTuiScrollInput(lines, clientX, clientY) { - var count = Math.min(Math.abs(lines), 32); - if (count === 0) return ''; - var mouseTrackingMode = getMouseTrackingMode(); - var sequence = ''; - if (isWheelMouseTrackingMode(mouseTrackingMode)) { - sequence = buildMouseWheelSequence(lines, clientX, clientY); - } - if (!sequence) sequence = buildArrowScrollSequence(lines); - return repeatSequence(sequence, count); - } - - function routeScrollLines(lines, clientX, clientY) { - if (!term || lines === 0) return; - var mouseTrackingMode = getMouseTrackingMode(); - var alternateBufferActive = isAlternateBufferActive(); - if (isWheelMouseTrackingMode(mouseTrackingMode)) { - // Why: xterm sends wheel events to mouse-aware TUIs before considering - // scrollback, even if the app stays on the normal buffer. - var mouseInput = buildMouseWheelScrollInput(lines, clientX, clientY); - if (mouseInput) { - notify({ type: 'terminal-input', bytes: mouseInput }); - return; - } - // Why: default mouse encoding can be unrepresentable in our ASCII-safe - // RPC path on wide terminals. Send bounded arrows instead of local - // scrollback/no-op while a mouse-aware app owns scroll gestures. - var fallbackInput = buildTuiScrollInput(lines, clientX, clientY); - if (fallbackInput) notify({ type: 'terminal-input', bytes: fallbackInput }); - return; - } - if (alternateBufferActive) { - // Why: alternate-screen TUIs own their scroll state and xterm has no - // scrollback there, so mobile scroll gestures must become terminal input. - var input = buildTuiScrollInput(lines, clientX, clientY); - if (input) notify({ type: 'terminal-input', bytes: input }); - return; - } - term.scrollLines(lines); - } - - function clampNormalScrollLines(lines) { +export const TERMINAL_HTML_SMOOTH_SCROLL_AND_CELL_GEOMETRY = ` function clampNormalScrollLines(lines) { if (!term || !term.buffer || !term.buffer.active || lines === 0) return 0; var buffer = term.buffer.active; if (lines > 0) { diff --git a/mobile/src/terminal/terminal-webview-html/fragment-09.ts b/mobile/src/terminal/terminal-webview-html/surface-touch-gestures.ts similarity index 62% rename from mobile/src/terminal/terminal-webview-html/fragment-09.ts rename to mobile/src/terminal/terminal-webview-html/surface-touch-gestures.ts index 06ffa927c65..515d7fd9d26 100644 --- a/mobile/src/terminal/terminal-webview-html/fragment-09.ts +++ b/mobile/src/terminal/terminal-webview-html/surface-touch-gestures.ts @@ -2,7 +2,8 @@ import { TERMINAL_TAP_DISPATCH_JS } from '../terminal-webview-tap-dispatch-injec import { TERMINAL_WHEEL_SCROLL_JS } from '../terminal-webview-wheel-scroll-injected' import { TERMINAL_MOUSE_CLICK_DRAG_JS } from '../terminal-webview-mouse-click-drag-injected' -export const TERMINAL_HTML_FRAGMENT_09 = ` ${TERMINAL_TAP_DISPATCH_JS} +// Also wires the selection menu's Copy/Select All buttons, which sit here in the emitted document. +export const TERMINAL_HTML_SURFACE_TOUCH_GESTURES = ` ${TERMINAL_TAP_DISPATCH_JS} // External mouse / trackpad scroll: see // terminal-webview-wheel-scroll-injected.ts (extracted for max-lines). @@ -135,4 +136,93 @@ export const TERMINAL_HTML_FRAGMENT_09 = ` ${TERMINAL_TAP_DISPATCH_JS} if (term.element && term.element.scrollWidth * getTotalScale() > window.innerWidth + 1) { panX += x - ts.lastX; clampPan(); + updateTransform(); + } + + var deltaY = ts.lastY - y; + ts.lastTime = now; + if (shouldRouteScrollToTerminalInput()) { + updateTouchVelocity(deltaY, dt); + resetSmoothScrollOffset(); + var effectiveCellH = getCellHeight() * getTotalScale(); + ts.accumDelta += deltaY; + var lines = Math.trunc(ts.accumDelta / effectiveCellH); + if (lines !== 0) { + ts.accumDelta -= lines * effectiveCellH; + routeScrollLines(lines, x, y); + } + } else { + if (enqueueNormalBufferScrollDelta(deltaY)) { + updateTouchVelocity(deltaY, dt); + } else { + ts.velY = 0; + } + } + ts.lastX = x; + ts.lastY = y; + } + }, { capture: true, passive: false }); + + targetSurface.addEventListener('touchend', function(e) { + if (dispatcherShouldBlockSurface()) return; + if (!term) return; + + if (ts.isPinching && e.touches.length < 2) { + ts.isPinching = false; + // Why: a finished pinch snaps to the nearest preset and becomes the new + // font size (reflowing the grid), so pinch-to-zoom IS the in-terminal way + // to set the text size. The CSS pinch zoom (userScale) is reset; the real + // size change reflows columns and RN persists + resizes the PTY to match. + var target = snapToTextScalePreset(currentTextScale * userScale); + var changed = target !== currentTextScale; + userScale = 1; + panX = 0; panY = 0; + applyTextScale(target); + updateTransform(); + notify({ type: 'font-scale-changed', fontScale: target }); + if (changed) notify({ type: 'haptic', kind: 'selection' }); + if (e.touches.length === 1) { + ts.lastX = e.touches[0].clientX; + ts.lastY = e.touches[0].clientY; + ts.lastTime = Date.now(); + ts.velY = 0; + ts.accumDelta = 0; + } + return; + } + + if (e.touches.length === 0) { + var vel = ts.velY; + var FRICTION = 0.972; + var MIN_VEL = 0.012; + function momentumStep() { + vel *= FRICTION; + if (Math.abs(vel) < MIN_VEL) { ts.momentumId = null; return; } + var delta = vel * 16; + if (shouldRouteScrollToTerminalInput()) { + resetSmoothScrollOffset(); + var effectiveCellH = getCellHeight() * getTotalScale(); + ts.accumDelta += delta; + var lines = Math.trunc(ts.accumDelta / effectiveCellH); + if (lines !== 0) { + ts.accumDelta -= lines * effectiveCellH; + routeScrollLines(lines, ts.lastX, ts.lastY); + } + } else { + if (!applyNormalBufferScrollDelta(delta)) { + ts.momentumId = null; + return; + } + } + ts.momentumId = requestAnimationFrame(momentumStep); + } + if (Math.abs(vel) > MIN_VEL) { + ts.momentumId = requestAnimationFrame(momentumStep); + } + } + }, { capture: true, passive: true }); + } + + attachSurfaceEventHandlers(surface); + ` diff --git a/mobile/src/terminal/terminal-webview-html/term-observers-and-mode-mirroring.ts b/mobile/src/terminal/terminal-webview-html/term-observers-and-mode-mirroring.ts new file mode 100644 index 00000000000..b69547affb5 --- /dev/null +++ b/mobile/src/terminal/terminal-webview-html/term-observers-and-mode-mirroring.ts @@ -0,0 +1,67 @@ +import { TERMINAL_KEYBOARD_AVOIDANCE_METRICS_JS } from '../terminal-keyboard-avoidance-metrics-injected' + +export const TERMINAL_HTML_OBSERVERS_AND_MODE_MIRRORING = ` function emitModesIfChanged() { + if (!term) return; + var bp = !!(term.modes && term.modes.bracketedPasteMode); + var alt = false; + var mouseTrackingMode = getMouseTrackingMode(); + try { alt = term.buffer && term.buffer.active && term.buffer.active.type === 'alternate'; } catch (e) {} + if ( + bp !== lastEmittedModes.bracketedPasteMode || + alt !== lastEmittedModes.altScreen || + mouseTrackingMode !== lastEmittedModes.mouseTrackingMode || + sgrMouseMode !== lastEmittedModes.sgrMouseMode || + sgrMousePixelsMode !== lastEmittedModes.sgrMousePixelsMode + ) { + lastEmittedModes = { + bracketedPasteMode: bp, + altScreen: alt, + mouseTrackingMode: mouseTrackingMode, + sgrMouseMode: sgrMouseMode, + sgrMousePixelsMode: sgrMousePixelsMode + }; + notify({ + type: 'modes', + bracketedPasteMode: bp, + altScreen: alt, + mouseTrackingMode: mouseTrackingMode, + sgrMouseMode: sgrMouseMode, + sgrMousePixelsMode: sgrMousePixelsMode + }); + } + } + var lastEmittedModes = { + bracketedPasteMode: false, + altScreen: false, + mouseTrackingMode: 'none', + sgrMouseMode: false, + sgrMousePixelsMode: false + }; + + ${TERMINAL_KEYBOARD_AVOIDANCE_METRICS_JS} + + function attachTermObservers() { + if (!term) return; + disposeTermObservers(); + try { termObserverDisposables.push(term.onLineFeed(logFeedAndEvict)); } catch (e) {} + try { + termObserverDisposables.push(term.onScroll(function() { updateScrollIndicator(false); })); + } catch (e) {} + // Why: emit modes on every parsed write so RN's mirror stays current + // without round-trip; covers \\x1b[?2004h/l and alt-screen toggles. + try { + if (term.onWriteParsed) { + termObserverDisposables.push(term.onWriteParsed(function() { + emitModesIfChanged(); + emitKeyboardAvoidanceMetrics(); + })); + } + } catch (e) {} + // Initial emit once buffer settles. + afterWritesDrained(function() { + emitModesIfChanged(); + emitKeyboardAvoidanceMetrics(); + }); + } + +` diff --git a/mobile/src/terminal/terminal-webview-html/terminal-fit-scale.ts b/mobile/src/terminal/terminal-webview-html/terminal-fit-scale.ts new file mode 100644 index 00000000000..b756bcb550c --- /dev/null +++ b/mobile/src/terminal/terminal-webview-html/terminal-fit-scale.ts @@ -0,0 +1,130 @@ +import { TERMINAL_WEBVIEW_THEME_JS } from '../terminal-webview-theme-injected' + +// Opens with the injected theme block: it lands at this point in the emitted document. +export const TERMINAL_HTML_FIT_SCALE = `${TERMINAL_WEBVIEW_THEME_JS} + + function getCellHeight() { + if (!term || !term._core) return 15; + var core = term._core; + if (core._renderService && core._renderService.dimensions) { + return core._renderService.dimensions.css.cell.height || 15; + } + return 15; + } + + // Why: clamp pan so the terminal content always covers the viewport + // when zoomed in. When content is smaller than viewport in a + // dimension, pin to top-left (no floating in the middle). + function clampPan() { + if (!term || !term.element) return; + var ts = getTotalScale(); + var cw = term.element.scrollWidth * ts; + var ch = term.element.scrollHeight * ts; + var vpW = window.innerWidth; + var vpH = window.innerHeight; + if (cw > vpW) { + panX = Math.min(0, Math.max(vpW - cw, panX)); + } else { + panX = 0; + } + if (ch > vpH) { + panY = Math.min(0, Math.max(vpH - ch, panY)); + } else { + panY = 0; + } + } + + // Why: intentional no-op. Mobile replays a live PTY snapshot then applies + // live cursor-relative chunks from that same PTY; resizing only the WebView + // xterm changes cursor coordinates and makes TUI repaint chunks duplicate or + // overlap. Kept as a no-op so its call sites stay legible. + function adjustRowsForViewport() {} + + // Why: cold-start fit. After init() opens xterm, the renderer needs + // several frames before cell dimensions are computed. Reading too early + // gives cellWidth=0 (renderer service not ready) or scrollWidth=0 (DOM + // not laid out), and computeFitScale returns 1 → no zoom. + // + // Gate: cellWidth × cols is the canonical "logical width" of the grid + // and reflects xterm's layout decision, independent of buffer content. + // We commit when cellWidth becomes positive (renderer ready). Fallback: + // if cellWidth never becomes available, gate on stable positive + // scrollWidth (xterm rendered something). Cap at 60 frames (~1s @60Hz) + // so a backgrounded WebView never spins forever. + var FIT_RETRY_MAX_FRAMES = 60; + var fitRetryToken = 0; + function applyFitScale(reason) { + if (!term || !term.element) return; + var token = ++fitRetryToken; + var attempts = 0; + var lastScrollWidth = -1; + function attempt() { + if (token !== fitRetryToken) return; + if (!term || !term.element) return; + attempts++; + var cellW = getCellWidth(); + if (cellW > 0 && term.cols > 0) { + commitFitScale(reason, attempts, 'cellW'); + return; + } + var w = term.element.scrollWidth; + if (w > 0 && w === lastScrollWidth) { + commitFitScale(reason, attempts, 'stableSW'); + return; + } + lastScrollWidth = w; + if (attempts >= FIT_RETRY_MAX_FRAMES) { + flog('commit-timeout', { + reason: reason, + attempts: attempts, + cellW: cellW, + scrollWidth: w, + cols: term.cols + }); + commitFitScale(reason, attempts, 'timeout'); + return; + } + requestAnimationFrame(attempt); + } + requestAnimationFrame(attempt); + } + + function commitFitScale(reason, attempts, gate) { + if (!term || !term.element) return; + var preSnapScale = computeFitScale(); + currentScale = preSnapScale; + // Why: when scale is very close to 1 (e.g. 0.97 from xterm scrollbar + // sub-pixels) snap to 1 to avoid imperceptible shrinkage that prevents + // a second applyFitScale from observing a "no-op needed" state. + if (currentScale >= 0.95) currentScale = 1; + userScale = 1; + panX = 0; + panY = 0; + smoothScrollOffsetY = 0; + updateTransform(); + adjustRowsForViewport(); + + var cellW = getCellWidth(); + var sw = term.element.scrollWidth; + var vpW = window.innerWidth; + var expectedW = cellW * term.cols; + var suspect = + currentScale === 1 && term.cols > 0 && expectedW > vpW + 1; // expected wider than viewport but no zoom + if (suspect) { + flog('commit-SUSPECT', { + reason: reason, + attempts: attempts, + gate: gate, + preSnapScale: preSnapScale, + finalScale: currentScale, + cellW: cellW, + cols: term.cols, + expectedW: expectedW, + scrollWidth: sw, + vpWidth: vpW + }); + } + repositionOverlay(); + } + +` diff --git a/mobile/src/terminal/terminal-webview-html/fragment-04.ts b/mobile/src/terminal/terminal-webview-html/terminal-init-and-write.ts similarity index 98% rename from mobile/src/terminal/terminal-webview-html/fragment-04.ts rename to mobile/src/terminal/terminal-webview-html/terminal-init-and-write.ts index c2c9863be79..90dba7cbdbc 100644 --- a/mobile/src/terminal/terminal-webview-html/fragment-04.ts +++ b/mobile/src/terminal/terminal-webview-html/terminal-init-and-write.ts @@ -1,7 +1,7 @@ import { TERMINAL_WEBGL_RECOVERY_JS } from '../terminal-webview-webgl-recovery-injected' import { MOBILE_TERMINAL_CARET_OPTIONS } from './theme' -export const TERMINAL_HTML_FRAGMENT_04 = `${TERMINAL_WEBGL_RECOVERY_JS} +export const TERMINAL_HTML_INIT_AND_WRITE = `${TERMINAL_WEBGL_RECOVERY_JS} function init(cols, rows, initialData, nextTheme, nextFontScale, preserveScroll, nextOscLinks) { if (typeof nextFontScale === 'number' && nextFontScale > 0) currentTextScale = nextFontScale; diff --git a/mobile/src/terminal/terminal-webview-html/write-queue.ts b/mobile/src/terminal/terminal-webview-html/write-queue.ts new file mode 100644 index 00000000000..ae8ed85297f --- /dev/null +++ b/mobile/src/terminal/terminal-webview-html/write-queue.ts @@ -0,0 +1,110 @@ +// Also carries disposeTermObservers() and extractMouseModeScanTail(): both belong to +// other concerns, but emitted-document order pins them inside this queue. +export const TERMINAL_HTML_WRITE_QUEUE = ` function resetWriteQueue() { + writeQueue = []; + writeQueueHead = 0; + } + + function isStatusDotPresentationSelector(value) { + return value === TEXT_PRESENTATION_SELECTOR || value === EMOJI_PRESENTATION_SELECTOR; + } + + function endsWithStatusDotPresentationSequence(data) { + var i = data.length - 1; + while (i >= 0 && isStatusDotPresentationSelector(data.charAt(i))) i--; + return i >= 0 && data.charAt(i) === CLAUDE_STATUS_DOT; + } + + // Why: iOS WebKit promotes Claude's record/status dot to a colorful emoji glyph. + function normalizeStatusDotPresentation(data) { + if (typeof data !== 'string' || data.length === 0) return data; + if (statusDotPendingSelector) { + statusDotPendingSelector = false; + var strippedPendingSelectors = false; + while (data.length > 0 && isStatusDotPresentationSelector(data.charAt(0))) data = data.slice(1); + strippedPendingSelectors = data.length === 0; + if (strippedPendingSelectors) { + statusDotPendingSelector = true; + return ''; + } + } + var normalized = data.replace(CLAUDE_STATUS_DOT_PATTERN, CLAUDE_STATUS_DOT + TEXT_PRESENTATION_SELECTOR); + statusDotPendingSelector = endsWithStatusDotPresentationSequence(data); + return normalized; + } + + function enqueueWrite(data) { + writeQueue.push(normalizeStatusDotPresentation(data)); + } + + function enqueueWriteBoundary(callback) { + writeQueue.push(callback); + } + + function nextQueuedWrite() { + if (writeQueueHead >= writeQueue.length) { + resetWriteQueue(); + return undefined; + } + var next = writeQueue[writeQueueHead]; + writeQueueHead++; + // Why: high-throughput terminals can enqueue faster than xterm parses; + // compact consumed slots so drain work stays O(1) without retaining old chunks. + if (writeQueueHead > 128 && writeQueueHead * 2 > writeQueue.length) { + writeQueue = writeQueue.slice(writeQueueHead); + writeQueueHead = 0; + } + return next; + } + + function disposeTermObservers() { + var disposables = termObserverDisposables; + termObserverDisposables = []; + for (var i = 0; i < disposables.length; i++) { + try { disposables[i] && disposables[i].dispose && disposables[i].dispose(); } catch (e) {} + } + } + + function extractMouseModeScanTail(input) { + var start = Math.max(input.lastIndexOf(ESC), input.lastIndexOf(C1_CSI)); + if (start === -1) return ''; + var tail = input.slice(start); + // Why: PTY/SSH chunks can split a long combined DECSET before the final h/l. + // Keep parser state far beyond normal mode lists while still bounding memory. + if (tail.length > PRIVATE_MODE_SCAN_TAIL_LIMIT) return ''; + if (tail === ESC || tail === ESC + '[' || tail === C1_CSI) return tail; + if (tail.indexOf(ESC + '[?') === 0) { + return /^[0-9;]*$/.test(tail.slice(3)) ? tail : ''; + } + if (tail.indexOf(C1_CSI + '?') === 0) { + return /^[0-9;]*$/.test(tail.slice(2)) ? tail : ''; + } + return ''; + } + + function pumpWrites(gen) { + if (!ready || !term || writesDraining || gen !== terminalGeneration) return; + var next = nextQueuedWrite(); + if (typeof next !== 'string') { + if (typeof next === 'function') return next(), pumpWrites(gen); + var callbacks = afterDrainCallbacks; + afterDrainCallbacks = []; + for (var i = 0; i < callbacks.length; i++) callbacks[i](); + return; + } + writesDraining = true; + // Why: xterm.write() parses asynchronously. Row adjustment/resizing must + // wait until replayed SGR attributes have landed in the buffer. + term.write(next, function() { + if (gen !== terminalGeneration) return; + writesDraining = false; + pumpWrites(gen); + }); + } + + function afterWritesDrained(callback) { + afterDrainCallbacks.push(callback); + pumpWrites(terminalGeneration); + } + +` diff --git a/mobile/src/terminal/terminal-webview-payload-hash.test.ts b/mobile/src/terminal/terminal-webview-payload-hash.test.ts new file mode 100644 index 00000000000..f8bfa4bd134 --- /dev/null +++ b/mobile/src/terminal/terminal-webview-payload-hash.test.ts @@ -0,0 +1,17 @@ +import { createHash } from 'node:crypto' +import { describe, expect, it } from 'vitest' +import { XTERM_HTML } from './terminal-webview-html' + +// Why: every other WebView test exercises one slice of the document, so an edit to an +// uncovered region ships silently. A diff here means the emitted WebView source changed — +// update these values only when that change is deliberate, and only after checking the +// document still runs. Refactors that merely move slice boundaries must leave them alone. +const EXPECTED_SHA256 = '42cc000faddc3b58b8fd4855f848c7878f0cd6166c613f66d733645e8e1b9608' +const EXPECTED_LENGTH = 729776 + +describe('terminal WebView payload', () => { + it('composes the expected document', () => { + expect(XTERM_HTML.length).toBe(EXPECTED_LENGTH) + expect(createHash('sha256').update(XTERM_HTML, 'utf8').digest('hex')).toBe(EXPECTED_SHA256) + }) +}) diff --git a/mobile/src/terminal/terminal-webview-scroll-routing.test.ts b/mobile/src/terminal/terminal-webview-scroll-routing.test.ts index 962cfddaa4d..9218e5d6ad9 100644 --- a/mobile/src/terminal/terminal-webview-scroll-routing.test.ts +++ b/mobile/src/terminal/terminal-webview-scroll-routing.test.ts @@ -1,5 +1,6 @@ import { readFileSync } from 'node:fs' import { describe, expect, it } from 'vitest' +import { readTerminalWebViewHtmlSource } from './terminal-webview-html-source.test-support' // The in-WebView JS lives in terminal-webview-html.ts; the RN wrapper in // TerminalWebView.tsx. Concatenate both so assertions resolve regardless of file. @@ -8,16 +9,7 @@ const source = readFileSync(new URL('./terminal-webview-pending-messages.ts', import.meta.url), 'utf8') + readFileSync(new URL('./terminal-webview-url-tap.ts', import.meta.url), 'utf8') + readFileSync(new URL('./terminal-webview-tap-dispatch-injected.ts', import.meta.url), 'utf8') + - readFileSync(new URL('./terminal-webview-html.ts', import.meta.url), 'utf8') + - Array.from({ length: 10 }, (_, index) => - readFileSync( - new URL( - `./terminal-webview-html/fragment-${String(index + 1).padStart(2, '0')}.ts`, - import.meta.url - ), - 'utf8' - ) - ).join('') + readTerminalWebViewHtmlSource() const sessionSource = readFileSync( new URL('../session/use-mobile-session-terminal-input.ts', import.meta.url), 'utf8' diff --git a/mobile/src/terminal/terminal-webview-text-zoom.test.ts b/mobile/src/terminal/terminal-webview-text-zoom.test.ts index 0deee79f670..d775237ddcc 100644 --- a/mobile/src/terminal/terminal-webview-text-zoom.test.ts +++ b/mobile/src/terminal/terminal-webview-text-zoom.test.ts @@ -11,8 +11,8 @@ const terminalHtmlModuleSource = readFileSync( new URL('./terminal-webview-html.ts', import.meta.url), 'utf8' ) -const terminalHtmlFragmentSource = readFileSync( - new URL('./terminal-webview-html/fragment-01.ts', import.meta.url), +const terminalHtmlDocumentShellSource = readFileSync( + new URL('./terminal-webview-html/document-shell.ts', import.meta.url), 'utf8' ) // Read behavior from the assembled document; the module source only contains @@ -150,7 +150,7 @@ describe('TerminalWebView text zoom', () => { }) it('loads Unicode 11 before replaying mobile terminal bytes', () => { - expect(terminalHtmlFragmentSource).toContain('XTERM_ENGINE_JS') + expect(terminalHtmlDocumentShellSource).toContain('XTERM_ENGINE_JS') expect(terminalHtmlSource).toContain('window.Unicode11Addon.Unicode11Addon') const open = terminalHtmlSource.indexOf('term.open(surface)') const unicode = terminalHtmlSource.indexOf("term.unicode.activeVersion = '11'") diff --git a/mobile/src/transport/rpc-client-live-recovery.test.ts b/mobile/src/transport/rpc-client-live-recovery.test.ts index 471a53be740..bef276f918d 100644 --- a/mobile/src/transport/rpc-client-live-recovery.test.ts +++ b/mobile/src/transport/rpc-client-live-recovery.test.ts @@ -59,7 +59,8 @@ function e2eeDecrypt(encrypted: string, sharedKey: Uint8Array): string | null { // fail with EADDRINUSE; the full scenario restarts on the captured port // because the client keeps reconnecting to its original URL. function startServer(port = 0): Promise { - const wss = new WebSocketServer({ port }) + // host must match the 127.0.0.1 clients dial: a wildcard bind lets a foreign loopback listener claim the port and answer here. + const wss = new WebSocketServer({ host: '127.0.0.1', port }) wss.on('connection', (ws: ServerSocket) => { let sharedKey: Uint8Array | null = null let authenticated = false diff --git a/resources/linux/packaging/after-install.sh b/resources/linux/packaging/after-install.sh index a5b598e2bf2..06e77ecbaaa 100755 --- a/resources/linux/packaging/after-install.sh +++ b/resources/linux/packaging/after-install.sh @@ -11,6 +11,19 @@ set -e link="/usr/bin/orca-ide" +is_owned_link() { + [ -L "$link" ] || return 1 + local link_target candidate candidate_target + link_target="$(readlink -f -- "$link" 2>/dev/null || true)" + for candidate in /opt/Orca/resources/bin/orca-ide /opt/orca-ide/resources/bin/orca-ide /opt/orca/resources/bin/orca-ide; do + candidate_target="$(readlink -f -- "$candidate" 2>/dev/null || true)" + if [ -n "$candidate_target" ] && [ "$link_target" = "$candidate_target" ]; then + return 0 + fi + done + return 1 +} + for dir in /opt/Orca /opt/orca-ide /opt/orca; do sandbox="$dir/chrome-sandbox" if [ -f "$sandbox" ]; then @@ -22,8 +35,8 @@ for dir in /opt/Orca /opt/orca-ide /opt/orca; do shim="$dir/resources/bin/orca-ide" if [ -x "$shim" ]; then # Only manage our own symlink; never clobber an unrelated /usr/bin/orca-ide. - if [ ! -e "$link" ] || [ -L "$link" ]; then - ln -sf "$shim" "$link" + if { [ ! -e "$link" ] && [ ! -L "$link" ]; } || is_owned_link; then + ln -sfn -- "$shim" "$link" fi break fi diff --git a/src/cli/handler-group-manifest.test.ts b/src/cli/handler-group-manifest.test.ts index d17c3446db1..3849f39a7c9 100644 --- a/src/cli/handler-group-manifest.test.ts +++ b/src/cli/handler-group-manifest.test.ts @@ -1,5 +1,5 @@ import { readdirSync } from 'node:fs' -import { join } from 'node:path' +import { join, relative, sep } from 'node:path' import { describe, expect, it } from 'vitest' import { buildHandlerRoutes, dispatch, type HandlerContext } from './dispatch' @@ -9,6 +9,32 @@ import { HANDLER_GROUPS, type HandlerGroup } from './handler-group-manifest' // group. These tests are the only thing standing between that trust and a // silently unreachable command, so they load every group for real. +// Why: __dirname works under both Vitest and the CommonJS tsc emit that +// build:cli type-checks this file against; import.meta.dirname does not. +const HANDLERS_DIR = join(__dirname, 'handlers') + +// Why: both the plural records and the single-command `*_HANDLER` ones that +// nested modules export get spread into a group, so both must route. +const HANDLER_RECORD_EXPORT = /_HANDLERS?$/ + +function listHandlerModules(dir: string): string[] { + return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { + const path = join(dir, entry.name) + if (entry.isDirectory()) { + return listHandlerModules(path) + } + return entry.name.endsWith('.ts') && !entry.name.endsWith('.test.ts') ? [path] : [] + }) +} + +function isHandlerRecord(value: unknown): value is Record { + return ( + typeof value === 'object' && + value !== null && + Object.values(value).every((entry) => typeof entry === 'function') + ) +} + describe('handler group manifest', () => { it('lists a loadable group for every entry', async () => { for (const group of HANDLER_GROUPS) { @@ -54,25 +80,35 @@ describe('handler group manifest', () => { }) // Why: dropping a group from the manifest silently unregisters its commands — - // scan the directory so a new or forgotten handler file fails here, not in prod. - it('registers every handler module that exports a handler group', async () => { - // Why: __dirname works under both Vitest and the CommonJS tsc emit that - // build:cli type-checks this file against; import.meta.dirname does not. - const dir = join(__dirname, 'handlers') - const modules = readdirSync(dir).filter( - (file) => file.endsWith('.ts') && !file.endsWith('.test.ts') - ) - const registered = new Set(HANDLER_GROUPS.map((group) => group.name)) - const missing: string[] = [] - for (const file of modules) { - const name = file.slice(0, -'.ts'.length) - const exports: Record = await import(join(dir, file)) - const exportsGroup = Object.keys(exports).some((key) => key.endsWith('_HANDLERS')) - if (exportsGroup && !registered.has(name)) { - missing.push(name) + // walk the tree so a new or forgotten handler file fails here, not in prod. + // Nested modules are spread into a parent group rather than registered under + // their own name, so routability, not file name, is the invariant that holds. + it('routes every command exported by a handler module', async () => { + const routes = buildHandlerRoutes(HANDLER_GROUPS) + const unroutable: string[] = [] + for (const file of listHandlerModules(HANDLERS_DIR)) { + const exports: Record = await import(file) + for (const [name, value] of Object.entries(exports)) { + if (!HANDLER_RECORD_EXPORT.test(name) || !isHandlerRecord(value)) { + continue + } + for (const key of Object.keys(value)) { + if (!routes.has(key)) { + unroutable.push(`${relative(HANDLERS_DIR, file)} ${name}: ${key}`) + } + } } } - expect(missing).toEqual([]) + expect(unroutable).toEqual([]) + }) + + it('finds the modules it is meant to guard', () => { + // Why: a walk that missed the tree would make the guard above vacuously pass. + const modules = listHandlerModules(HANDLERS_DIR) + expect(modules.length).toBeGreaterThanOrEqual(40) + expect( + modules.filter((file) => relative(HANDLERS_DIR, file).includes(sep)).length + ).toBeGreaterThanOrEqual(7) }) }) diff --git a/src/cli/serve-electron-flag-parity.test.ts b/src/cli/serve-electron-flag-parity.test.ts index 1abcf84ef64..4213d360a41 100644 --- a/src/cli/serve-electron-flag-parity.test.ts +++ b/src/cli/serve-electron-flag-parity.test.ts @@ -57,8 +57,11 @@ describe('serve flag parity between the CLI spec and the Electron argv rewrite', // both ends of the contract are only readable statically. Without this leg the rewrite could // emit a name nothing reads and every behavioural assertion above would still pass. const launchSource = readFileSync(join(process.cwd(), 'src/cli/runtime/launch.ts'), 'utf8') - const mainSource = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') - const start = mainSource.indexOf('function getServeOptions(') + const mainSource = readFileSync( + join(process.cwd(), 'src/main/startup/main-process-serve.ts'), + 'utf8' + ) + const start = mainSource.indexOf('export function getServeOptions(') // Why bound the anchor: an unresolved indexOf slices to EOF and passes vacuously. expect(start).toBeGreaterThanOrEqual(0) const end = mainSource.indexOf('\n}', start) diff --git a/src/main/agent-awake-service.ts b/src/main/agent-awake-service.ts index 29e866d27f2..b79612e2b9c 100644 --- a/src/main/agent-awake-service.ts +++ b/src/main/agent-awake-service.ts @@ -117,6 +117,11 @@ export class AgentAwakeService { } } + /** Agents this runtime has seen working recently, independent of the awake setting. */ + getWorkingAgentCount(): number { + return this.getEligibleRunningStatusCount() + } + subscribe(listener: (status: ComputerAwakeStatus) => void): () => void { this.statusListeners.add(listener) return () => this.statusListeners.delete(listener) diff --git a/src/main/agent-hooks/server.ts b/src/main/agent-hooks/server.ts index 11c1088315f..f03484f14f9 100644 --- a/src/main/agent-hooks/server.ts +++ b/src/main/agent-hooks/server.ts @@ -1,3526 +1,30 @@ -/* eslint-disable max-lines -- Why: this file owns the loopback HTTP adapter, the on-disk last-status persistence layer (hydrate, sanitize, TTL, atomic write, drop), and the relay ingest path in one place so the cache lifecycle (set → schedule → drain) lives next to the surfaces that mutate it. Splitting would force mutual `private` accessor scaffolding for a single class. */ -// Why: this main-process adapter keeps listener internals in shared/ (`src/shared/agent-hook-listener.ts`) so the relay can host the same pipeline without Electron; parsing that drifts back into this file stops applying to SSH panes. -import { createServer, type IncomingMessage, type ServerResponse } from 'node:http' -import { createHash, randomBytes, randomUUID } from 'node:crypto' -import { chmodSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs' -import { join } from 'node:path' - -import { track } from '../telemetry/client' -import { getCohortAtEmit } from '../telemetry/cohort-classifier' -import { AGENT_KIND_VALUES, type AgentKind } from '../../shared/telemetry-events' -import { - ORCA_HOOK_PROTOCOL_VERSION, - ORCA_HOOK_RAW_JSON_TRANSPORT -} from '../../shared/agent-hook-types' -import { - clearAllListenerCaches, - clearPaneCacheState, - paneHasStateClaims, - createHookListenerState, - movePaneCacheState, - type HookListenerState -} from '../../shared/agent-hook-listener/listener-state' -import { - clearClaudeAnsweredQuestionWait, - markClaudeLeadTurnInterrupted, - reapRestoredClaudeSubagentsForDeadPane, - seedClaudeLeadTurnFromPersistedStatus, - seedClaudeSubagentRosterFromSnapshots -} from '../../shared/agent-hook-listener/providers/claude-roster-state' -import { - getEndpointFileName, - writeEndpointFile -} from '../../shared/agent-hook-listener/endpoint-publication' -import { - hasCodexTranscriptSubagents, - markCodexLeadTurnInterrupted, - reconcileRemoteCodexState, - seedCodexStateFromSnapshot -} from '../../shared/agent-hook-listener/providers/codex-state' -import { - hasPendingAgentResultText, - preparePendingGrokResultDiscovery -} from '../../shared/agent-hook-listener/grok-result-discovery' -import { - HOOK_REQUEST_SLOWLORIS_MS, - MAX_PANE_KEY_LEN, - normalizeClaudePromptId, - warnOnHookEnvOrVersionMismatch -} from '../../shared/agent-hook-listener/listener-limits' -import { isNewTurnEvent } from '../../shared/agent-hook-listener/provider-event-routing' +// This main-process adapter keeps listener internals in shared/ so the relay can host the same pipeline without Electron. +import { clearAllListenerCaches } from '../../shared/agent-hook-listener/listener-state' import { normalizeHookPayload } from '../../shared/agent-hook-listener' -import { mergeAgentHookRequestHeaders } from '../../shared/agent-hook-listener/hook-envelope' -import { - parseFormEncodedBody, - readRequestBody -} from '../../shared/agent-hook-listener/request-body' -import { resolveHookSource } from '../../shared/agent-hook-listener/source-routing' +import { parseFormEncodedBody } from '../../shared/agent-hook-listener/request-body' import type { AgentHookEventPayload } from '../../shared/agent-hook-listener/listener-event' -import { - canAcceptClaudeCompactCompletion, - isClaudeCompactCompletionConsumed, - markClaudeCompactCompletionConsumed, - resolveLegacyCompactTrigger -} from '../../shared/claude-compact-completion' -import { - createHookTransportInterferenceTracker, - describeHookTransportInterference, - isHookRequestTruncatedError, - type HookTransportInterferenceReport -} from '../../shared/agent-hook-transport-interference' -import { - claudeTeammateIdMatchesName, - claudeRosterHasRestoredSnapshotSubagent, - claudeRosterHasWorkingSubagent, - claudeRosterToSnapshots -} from '../../shared/claude-subagent-roster' -import { - isAgentHookSource, - restoreShedStatusFields, - type AgentHookSource -} from '../../shared/agent-hook-relay' -import { - CLAUDE_STATUSLINE_PATHNAME, - parseClaudeStatusLineBody, - type ClaudeStatusLineRateLimits -} from '../../shared/claude-statusline-rate-limits' -import { - AGENT_STATUS_STALE_AFTER_MS, - type AgentStatusClearIpcPayload, - type AgentStatusIpcPayload, - type AgentType, - type AgentStatusState, - type ParsedAgentStatusPayload, - normalizeAgentStatusPayload -} from '../../shared/agent-status-types' -import { terminalStatusPayloadMatchesHook } from '../../shared/agent-terminal-status-equivalence' -import { - AgentStatusObservationSequencer, - createAgentStatusAuthorityId, - type AgentStatusObservation, - type AgentStatusObservationOrigin -} from '../../shared/agent-status-observation' -import { - resolveAgentStatusIdentity, - shouldSuppressInheritedTerminalStatus -} from '../../shared/agent-status-identity' -import { - isAgentInterruptInputIntent, - type AgentInterruptInferenceRequest -} from '../../shared/agent-interrupt-intent' -import { - isAskUserQuestionTool, - type AgentQuestionAnsweredInferenceRequest -} from '../../shared/agent-question-answered-intent' -import { canRegisterPaneKeyAlias, isOpaqueRemintedPaneKey } from '../../shared/pane-key-alias' -import { parseLegacyNumericPaneKey, parsePaneKey } from '../../shared/stable-pane-id' -import type { LegacyPaneKeyAliasEntry } from '../../shared/persisted-state-types' -import { - getAgentResumeArgv, - normalizeAgentProviderSession, - type AgentProviderSessionMetadata -} from '../../shared/agent-session-resume' -import { isCommandCodeNewTurnWhileWorking } from '../../shared/command-code-turn-boundary' -import { - buildSpoolHookBody, - drainAgentHookSpool, - launchTokenHash, - type SpoolRecord -} from '../../shared/agent-hook-spool' -import { CodexSubagentPollScheduler } from '../../shared/codex-subagent-poll-scheduler' +import type { AgentHookSource } from '../../shared/agent-hook-relay' +import { AgentHookServerLifecycle } from './server/server-lifecycle' +import { isValidPaneKey } from './server/server-status-identity' +export type { + AgentHookAuthorityAttestation, + AgentHookAuthorityEvidence, + AgentHookProviderSessionIdentity, + AgentHookStatusChangeEntry, + EnrichedAgentHookEventPayload +} from './server/server-types' export type { AgentHookSource } - -// Why: server-side enrichment — receivedAt = latest event arrival, stateStartedAt = when the current state first appeared; extra fields ride the shared map untouched (it only writes/clears). -type EnrichedAgentHookEventPayload = AgentHookEventPayload & { - receivedAt: number - stateStartedAt: number - /** Provenance/ordering stamped by this server as the pane authority (STA-4293). Read by nothing yet. */ - observation?: AgentStatusObservation - /** Stamped at hydrate for nonterminal states; never persisted (hydrate re-stamps) and cleared by any accepted live event replacing the entry. */ - restoredUnconfirmed?: true - /** User-hidden resume identity retained solely for destructive liveness checks. */ - retainedForLiveness?: true - /** Persisted proof that a lead boundary was held working only by child agents. */ - claudeLeadBoundaryChildOnly?: true -} - -type NormalizedLocalHook = { - event: AgentHookEventPayload | null - onAccepted?: () => void -} - -type PersistedAgentHookEventPayload = Omit< - EnrichedAgentHookEventPayload, - | 'claudeRunningNonAgentTask' - | 'launchToken' - | 'promptInteractionKey' - | 'restoredUnconfirmed' - // Why: revision counters are in-memory and the authority id is regenerated per process, so - // a stored observation could only rehydrate as a stale ordering claim from a dead authority. - | 'observation' -> & { - launchTokenHash?: string -} - -type PersistedAgentHookAuthorityCommitment = { - paneKey: string - launchTokenHash: string - connectionId: string | null - tabId?: string - worktreeId?: string - observedAt: number -} - -export type AgentHookStatusChangeEntry = { - state: AgentStatusState - receivedAt: number - observedInCurrentRuntime: boolean -} - -export type AgentHookProviderSessionIdentity = { - paneKey: string - sessionId: string - transcriptPath?: string - worktreeId?: string -} - -export type AgentHookAuthorityEvidence = Readonly<{ - paneKey: string - launchTokenHash: string - connectionId: string | null - tabId?: string - worktreeId?: string - observedAt: number -}> - -export type AgentHookAuthorityAttestation = Readonly<{ - paneKey: string - source: 'current_hook' | 'hydrated_commitment' -}> - -type StatusChangeListener = (statuses: AgentHookStatusChangeEntry[]) => void -type ProviderSessionChangeListener = (providerSessions: AgentHookProviderSessionIdentity[]) => void -type PaneStatusClearListener = (clear: AgentStatusClearIpcPayload) => void -type StatusDropListener = (paneKey: string) => void -type PaneKeyAliasPersistenceListener = (entries: LegacyPaneKeyAliasEntry[]) => void -type PaneKeyAliasEntry = { - stablePaneKey: string - ptyId: string | null - updatedAt: number - authorityVerified: boolean -} -type RetiredPaneAlias = { physicalPaneKey: string; entry: PaneKeyAliasEntry } -/** What one retirement fenced, so a re-attach can lift exactly that set and no more. */ -type RetiredPaneFence = { - paneKeys: readonly string[] - aliases: readonly RetiredPaneAlias[] -} - -// Why: co-located with the endpoint file in userData/agent-hooks/ so hook-server cross-restart artifacts stay together. -const LAST_STATUS_FILE_NAME = 'last-status.json' -const ASSISTANT_MESSAGE_RETRY_ATTEMPTS = 5 -const ASSISTANT_MESSAGE_RETRY_MS = 50 -const CODEX_SUBAGENT_POLL_MS = 1_000 -const INTERRUPTED_DONE_LATE_WORKING_SUPPRESSION_MS = 15_000 - -type CodexSubagentPoll = { - source: AgentHookSource - body: unknown - original: EnrichedAgentHookEventPayload -} - -// Why: starts at 2 — pre-merge v1 lacked receivedAt/stateStartedAt (never shipped); a mismatched version hydrates empty (treated as corrupt). -const LAST_STATUS_FILE_VERSION = 2 - -// Why: trailing-edge debounce so a burst of hook events yields one disk write, not N; quit-time flushStatusPersistSync() guarantees the final flush. -const STATUS_PERSIST_DEBOUNCE_MS = 250 -const TOOL_PROGRESS_HOOK_EVENTS = new Set(['PreToolUse', 'PostToolUse', 'PostToolUseFailure']) -const AGENT_PROMPT_SENT_AGENT_KINDS = new Set(AGENT_KIND_VALUES) - -// Why: bound file growth from PTYs that never re-attach; 7 days is the "still relevant?" horizon beyond which entries shouldn't resurrect on hydrate. -const HYDRATE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000 - -// Why: a long-closed tab can't receive status events; bound the set so it can't grow one entry per close for the whole session. -export const CLOSED_AGENT_STATUS_TAB_IDS_MAX = 1024 -export const CLOSED_AGENT_STATUS_PANE_KEYS_MAX = 1024 -export const PANE_KEY_ALIASES_MAX = 1024 -export const RETIRED_PANE_FENCES_MAX = 1024 - -type LastStatusFile = { - version: number - entries: Record - authorityCommitments?: Record -} - -type AgentPromptSentDedupeEntry = { - agentKind: AgentKind - promptHash: string - promptInteractionKey?: string -} - -function agentTypeToPromptSentAgentKind(agentType: AgentType | undefined): AgentKind { - const normalized = agentType?.trim().toLowerCase() - if (!normalized || normalized === 'unknown') { - return 'other' - } - if (normalized === 'claude') { - return 'claude-code' - } - return AGENT_PROMPT_SENT_AGENT_KINDS.has(normalized as AgentKind) - ? (normalized as AgentKind) - : 'other' -} - -function equivalentInterruptAgentType( - actual: AgentType | undefined, - baseline: AgentType | undefined -): boolean { - const normalizedActual = actual === 'unknown' ? undefined : actual - const normalizedBaseline = baseline === 'unknown' ? undefined : baseline - return normalizedActual === normalizedBaseline -} - -// Why: validate the durable `${tabId}:${leafUuid}` leaf suffix at write/hydrate so legacy numeric rows fail closed. -export function isValidPaneKey(value: unknown): value is string { - return ( - typeof value === 'string' && value.length <= MAX_PANE_KEY_LEN && parsePaneKey(value) !== null - ) -} - -function dropHydratedIdleClaudeSubagents( - payload: ParsedAgentStatusPayload -): ParsedAgentStatusPayload { - if ( - payload.agentType !== 'claude' || - !payload.subagents?.some((subagent) => subagent.state === 'idle') - ) { - return payload - } - const activeSubagents = payload.subagents.filter((subagent) => subagent.state !== 'idle') - // Why: an idle teammate's liveness can't be proven across a restart (its TeammateIdle confirmation is in-memory); prune so a dead pile can't resurrect — a live teammate re-earns its row via SubagentStart. - return { - ...payload, - subagents: activeSubagents.length > 0 ? activeSubagents : undefined - } -} - -// Why: remote metadata-only rows are currently a Pi contract; user-dismissed rows use an internal persisted marker instead. -function isValidPiProviderSessionOnly( - providerSession: AgentProviderSessionMetadata | undefined, - agentType: AgentType | undefined -): boolean { - return Boolean(providerSession && agentType === 'pi' && getAgentResumeArgv('pi', providerSession)) -} - -function sanitizeHydratedEntry( - paneKey: string, - rawEntry: unknown -): EnrichedAgentHookEventPayload | null { - const parsedPaneKey = parsePaneKey(paneKey) - if (!parsedPaneKey) { - return null - } - if (typeof rawEntry !== 'object' || rawEntry === null) { - return null - } - const record = rawEntry as Record - if (record.paneKey !== paneKey) { - return null - } - const tabId = record.tabId - if (tabId !== undefined && (typeof tabId !== 'string' || tabId.length === 0)) { - return null - } - // Why: a stored tabId that diverges from the paneKey's tab segment is corruption; drop instead of hydrating an inconsistent row. - if (typeof tabId === 'string' && tabId !== parsedPaneKey.tabId) { - return null - } - const worktreeId = record.worktreeId - if (worktreeId !== undefined && (typeof worktreeId !== 'string' || worktreeId.length === 0)) { - return null - } - const receivedAt = record.receivedAt - if (typeof receivedAt !== 'number' || !Number.isFinite(receivedAt) || receivedAt <= 0) { - return null - } - const stateStartedAt = record.stateStartedAt - if ( - typeof stateStartedAt !== 'number' || - !Number.isFinite(stateStartedAt) || - stateStartedAt <= 0 - ) { - return null - } - // Why: connectionId is null (local) or string (relay); any other shape is rejected to keep the typed surface honest. - const connectionIdRaw = record.connectionId - let connectionId: string | null - if (connectionIdRaw === null || connectionIdRaw === undefined) { - connectionId = null - } else if (typeof connectionIdRaw === 'string') { - connectionId = connectionIdRaw - } else { - return null - } - const payload = normalizeAgentStatusPayload(record.payload) - if (!payload) { - return null - } - const providerSession = normalizeAgentProviderSession(record.providerSession) ?? undefined - const providerSessionOnly = record.providerSessionOnly === true - const retainedForLiveness = record.retainedForLiveness === true - const validRetainedIdentity = Boolean( - retainedForLiveness && providerSession && payload.agentType && payload.agentType !== 'unknown' - ) - if ( - providerSessionOnly && - !isValidPiProviderSessionOnly(providerSession, payload.agentType) && - !validRetainedIdentity - ) { - return null - } - const source = isAgentHookSource(record.source) ? record.source : undefined - const providerPromptId = - source === 'claude' ? normalizeClaudePromptId(record.providerPromptId) : undefined - const compactTrigger = - source === 'claude' && (record.compactTrigger === 'manual' || record.compactTrigger === 'auto') - ? record.compactTrigger - : undefined - return { - paneKey, - source, - tabId: typeof tabId === 'string' ? tabId : undefined, - worktreeId: typeof worktreeId === 'string' ? worktreeId : undefined, - connectionId, - hasExplicitPrompt: record.hasExplicitPrompt === true ? true : undefined, - hookEventName: typeof record.hookEventName === 'string' ? record.hookEventName : undefined, - providerPromptId, - compactTrigger, - toolUseId: typeof record.toolUseId === 'string' ? record.toolUseId : undefined, - toolAgentId: typeof record.toolAgentId === 'string' ? record.toolAgentId : undefined, - teammateName: typeof record.teammateName === 'string' ? record.teammateName : undefined, - toolAgentType: typeof record.toolAgentType === 'string' ? record.toolAgentType : undefined, - claudeLeadBoundaryChildOnly: record.claudeLeadBoundaryChildOnly === true ? true : undefined, - providerSession, - providerSessionOnly: providerSessionOnly ? true : undefined, - retainedForLiveness: retainedForLiveness ? true : undefined, - payload, - receivedAt, - stateStartedAt - } -} - -function readPersistedLaunchTokenHash(rawEntry: unknown): string | null { - if (typeof rawEntry !== 'object' || rawEntry === null) { - return null - } - const record = rawEntry as Record - const launchTokenHash = - typeof record.launchTokenHash === 'string' ? record.launchTokenHash.trim() : '' - if (/^[a-f0-9]{64}$/.test(launchTokenHash)) { - return launchTokenHash - } - const legacyLaunchToken = typeof record.launchToken === 'string' ? record.launchToken.trim() : '' - return legacyLaunchToken ? createHash('sha256').update(legacyLaunchToken).digest('hex') : null -} - -function sanitizePersistedAuthorityCommitment( - paneKey: string, - value: unknown -): AgentHookAuthorityEvidence | null { - if (!isValidPaneKey(paneKey) || typeof value !== 'object' || value === null) { - return null - } - const record = value as Record - const launchTokenHash = - typeof record.launchTokenHash === 'string' ? record.launchTokenHash.trim() : '' - const connectionId = record.connectionId - const observedAt = record.observedAt - if ( - !/^[a-f0-9]{64}$/.test(launchTokenHash) || - (connectionId !== null && typeof connectionId !== 'string') || - typeof observedAt !== 'number' || - !Number.isFinite(observedAt) - ) { - return null - } - return Object.freeze({ - paneKey, - launchTokenHash, - connectionId, - ...(typeof record.tabId === 'string' ? { tabId: record.tabId } : {}), - ...(typeof record.worktreeId === 'string' ? { worktreeId: record.worktreeId } : {}), - observedAt - }) -} - -function authorityCommitmentsMatch( - left: AgentHookAuthorityEvidence, - right: AgentHookAuthorityEvidence -): boolean { - return ( - left.paneKey === right.paneKey && - left.launchTokenHash === right.launchTokenHash && - left.connectionId === right.connectionId && - left.tabId === right.tabId && - left.worktreeId === right.worktreeId - ) -} - -function toAgentStatusIpcPayload(entry: EnrichedAgentHookEventPayload): AgentStatusIpcPayload { - return { - paneKey: entry.paneKey, - ...(entry.launchToken ? { launchToken: entry.launchToken } : {}), - tabId: entry.tabId, - worktreeId: entry.worktreeId, - connectionId: entry.connectionId, - receivedAt: entry.receivedAt, - stateStartedAt: entry.stateStartedAt, - ...(entry.providerSession ? { providerSession: entry.providerSession } : {}), - ...(entry.providerSessionOnly ? { providerSessionOnly: true } : {}), - ...(entry.promptInteractionKey ? { promptInteractionKey: entry.promptInteractionKey } : {}), - ...(entry.restoredUnconfirmed ? { restoredUnconfirmed: true } : {}), - ...(entry.observation ? { observation: entry.observation } : {}), - ...entry.payload - } -} - -function trackEmptyPaneKeyHook(body: unknown): void { - if (typeof body !== 'object' || body === null) { - return - } - const paneKey = (body as Record).paneKey - if (typeof paneKey === 'string' && paneKey.trim().length > 0) { - return - } - track('agent_hook_unattributed', { reason: 'empty_pane_key' }) -} - -function isToolProgressWorkingAfterInterrupt(next: AgentHookEventPayload): boolean { - if (next.payload.state !== 'working') { - return false - } - if (next.payload.agentType !== 'claude' && next.payload.agentType !== 'codex') { - return false - } - // Why: a same-prompt retry is another UserPromptSubmit, while late post-Ctrl+C progress arrives as tool lifecycle work. - return next.hookEventName !== undefined && TOOL_PROGRESS_HOOK_EVENTS.has(next.hookEventName) -} - -function paneCacheKeyTabId(key: string): string | null { - const paneKey = key.split('\0', 1)[0] ?? key - return parsePaneKey(paneKey)?.tabId ?? parseLegacyNumericPaneKey(paneKey)?.tabId ?? null -} - -function paneCacheKeyMatchesTab(key: string, tabId: string): boolean { - return paneCacheKeyTabId(key) === tabId -} - -function attachClaudeChildOnlyBoundary( - previous: EnrichedAgentHookEventPayload | undefined, - next: AgentHookEventPayload -): AgentHookEventPayload & { claudeLeadBoundaryChildOnly?: true } { - const establishesBoundary = - next.payload.agentType === 'claude' && - (next.hookEventName === 'Stop' || next.hookEventName === 'StopFailure') && - !next.toolAgentId && - next.payload.state === 'working' && - next.payload.subagents?.some((subagent) => subagent.state === 'working') === true && - next.claudeRunningNonAgentTask === false - const carriesBoundary = - previous?.claudeLeadBoundaryChildOnly === true && - next.payload.agentType === 'claude' && - next.claudeRunningNonAgentTask === false && - (next.toolAgentId !== undefined || - next.hookEventName === 'SubagentStart' || - next.hookEventName === 'SubagentStop' || - next.hookEventName === 'TeammateIdle') - return establishesBoundary || carriesBoundary - ? { ...next, claudeLeadBoundaryChildOnly: true } - : next -} - -function invalidateClaudeChildOnlyBoundary( - previous: EnrichedAgentHookEventPayload | undefined, - next: AgentHookEventPayload -): EnrichedAgentHookEventPayload | undefined { - if ( - previous?.claudeLeadBoundaryChildOnly !== true || - attachClaudeChildOnlyBoundary(previous, next).claudeLeadBoundaryChildOnly === true - ) { - return previous - } - const { claudeLeadBoundaryChildOnly: _boundary, ...withoutBoundary } = previous - return withoutBoundary -} - -function shouldKeepClaudePermissionVisible( - previous: EnrichedAgentHookEventPayload | undefined, - next: AgentHookEventPayload -): boolean { - if (previous?.restoredUnconfirmed) { - return false - } - if ( - previous?.payload.agentType !== 'claude' || - previous.payload.state !== 'waiting' || - previous.hookEventName !== 'PermissionRequest' || - next.payload.agentType !== 'claude' || - next.payload.state !== 'working' - ) { - return false - } - if (next.hasExplicitPrompt === true) { - return false - } - if (isClaudePermissionOwningChildEnding(previous, next)) { - return false - } - if (isClaudePermissionResumingApprovedTool(previous, next)) { - return false - } - // Why: only real permission requests stay sticky; newer Claude reports AskUserQuestion as a PermissionRequest, so tool name (not event) decides. - if (isAskUserQuestionTool(previous.payload.toolName)) { - return false - } - return true -} - -function isClaudePermissionOwningChildEnding( - previous: EnrichedAgentHookEventPayload, - next: AgentHookEventPayload -): boolean { - const ownerId = previous.toolAgentId?.trim() - if (!ownerId) { - return false - } - if (next.hookEventName === 'SubagentStop') { - return ownerId === next.toolAgentId?.trim() - } - return ( - next.hookEventName === 'TeammateIdle' && - next.teammateName !== undefined && - claudeTeammateIdMatchesName(ownerId, next.teammateName) - ) -} - -function isClaudePermissionResumingApprovedTool( - previous: EnrichedAgentHookEventPayload, - next: AgentHookEventPayload -): boolean { - const previousToolUseId = previous.toolUseId?.trim() || undefined - const nextToolUseId = next.toolUseId?.trim() || undefined - const previousAgentId = previous.toolAgentId?.trim() || undefined - const nextAgentId = next.toolAgentId?.trim() || undefined - const hasAgentId = previousAgentId !== undefined || nextAgentId !== undefined - const previousAgentType = previous.toolAgentType?.trim() || undefined - const nextAgentType = next.toolAgentType?.trim() || undefined - const hasMatchingConcreteAgentId = - previousAgentId !== undefined && previousAgentId === nextAgentId - const hasSameExplicitAgentType = - !hasAgentId && previousAgentType !== undefined && previousAgentType === nextAgentType - const sameToolName = - previous.payload.toolName !== undefined && previous.payload.toolName === next.payload.toolName - const sameKnownToolInput = - previous.payload.toolInput !== undefined && - previous.payload.toolInput === next.payload.toolInput - const sameUnknownInputFromConcreteAgent = - hasMatchingConcreteAgentId && - previous.payload.toolInput === undefined && - next.payload.toolInput === undefined - const hasMatchingToolUseId = - previousToolUseId !== undefined && previousToolUseId === nextToolUseId - const hasConflictingToolUseId = - previousToolUseId !== undefined && - nextToolUseId !== undefined && - previousToolUseId !== nextToolUseId - const sameUnknownInputFromToolUseId = - hasMatchingToolUseId && - previous.payload.toolInput === undefined && - next.payload.toolInput === undefined - - return ( - (next.hookEventName === 'PreToolUse' || next.hookEventName === 'PostToolUse') && - nextToolUseId !== undefined && - !hasConflictingToolUseId && - // Why: subagents share agent_type, so a concrete agent id (or the preserved PostToolUse tool_use_id) is the safest resume signal. - (hasMatchingConcreteAgentId || hasSameExplicitAgentType || hasMatchingToolUseId) && - sameToolName && - (sameKnownToolInput || sameUnknownInputFromConcreteAgent || sameUnknownInputFromToolUseId) - ) -} - -function shouldInheritClaudeToolUseIdForPermission( - previous: EnrichedAgentHookEventPayload | undefined, - next: AgentHookEventPayload -): boolean { - if ( - previous?.restoredUnconfirmed || - previous?.payload.agentType !== 'claude' || - previous.payload.state !== 'working' || - previous.hookEventName !== 'PreToolUse' || - typeof previous.toolUseId !== 'string' || - previous.toolUseId.trim().length === 0 || - next.payload.agentType !== 'claude' || - next.payload.state !== 'waiting' || - next.hookEventName !== 'PermissionRequest' || - next.toolUseId !== undefined - ) { - return false - } - const sameKnownToolInput = - previous.payload.toolInput !== undefined && - previous.payload.toolInput === next.payload.toolInput - const sameUnknownToolInput = - previous.payload.toolInput === undefined && next.payload.toolInput === undefined - if ( - previous.toolAgentId !== next.toolAgentId || - previous.toolAgentType !== next.toolAgentType || - previous.payload.toolName === undefined || - previous.payload.toolName !== next.payload.toolName || - (!sameKnownToolInput && !sameUnknownToolInput) - ) { - return false - } - return true -} - -function attachClaudePermissionToolUseId( - previous: EnrichedAgentHookEventPayload | undefined, - next: AgentHookEventPayload -): AgentHookEventPayload { - const inheritedToolUseId = previous?.toolUseId - if ( - !shouldInheritClaudeToolUseIdForPermission(previous, next) || - typeof inheritedToolUseId !== 'string' - ) { - return next - } - return { - ...next, - // Why: Claude emits PermissionRequest without tool_use_id, then PostToolUse carries the original PreToolUse id. - toolUseId: inheritedToolUseId - } -} - -export class AgentHookServer { - private server: ReturnType | null = null - private port = 0 - private token = '' - // Why: identifies this Orca instance so the server can detect dev vs. prod cross-talk; set at start() from packaged-build knowledge. - private env = 'production' - private onAgentStatus: ((payload: EnrichedAgentHookEventPayload) => void) | null = null - private onClaudeStatusLine: ((event: ClaudeStatusLineRateLimits) => void) | null = null - private onPaneStatusCleared: PaneStatusClearListener | null = null - private paneStatusClearListeners = new Set() - private statusDropListeners = new Set() - private statusChangeListeners = new Set() - private providerSessionChangeListeners = new Set() - // Why: setListener is a single slot owned by the main-window fanout; the - // plugin event bus (and future consumers) need an additive subscription - // that also works in headless serve, where no window listener exists. - private enrichedStatusListeners = new Set<(payload: EnrichedAgentHookEventPayload) => void>() - // Why: set via start()'s userDataPath so the class has no direct Electron dependency (mockable in vitest node env). - private endpointDir: string | null = null - private endpointFilePathCache: string | null = null - private endpointFileWritten = false - // Why: per-instance (not module-level) so tests can spin up multiple servers without state cross-contamination. - private state: HookListenerState = createHookListenerState() - private onTransportInterference: ((report: HookTransportInterferenceReport) => void) | null = null - private transportInterference = createHookTransportInterferenceTracker((report) => { - console.warn(describeHookTransportInterference(report)) - this.onTransportInterference?.(report) - }) - // Why: hydrated rows give UI continuity but aren't evidence of live agent work in this runtime. - private runtimeObservedStatusPaneKeys = new Set() - private hydratedAuthorityCommitments: readonly AgentHookAuthorityEvidence[] = Object.freeze([]) - private hydratedLaunchTokenHashByPaneKey = new Map() - private persistedAuthorityCommitmentsByPaneKey = new Map() - private revokedHydratedAuthorityCommitments = new WeakSet() - private currentAuthorityObservations = new Map() - private legacyPaneKeyAliases = new Map() - // Why: indexed by every key the retirement fenced, so a re-attach on any of them - // (owner, physical, or a deleted alias) finds the same record. Bounded like the maps - // it mirrors; an evicted record simply degrades to lifting the key it was handed. - private retiredPaneFencesByKey = new Map() - private paneKeyAliasPersistenceListener: PaneKeyAliasPersistenceListener | null = null - // Why: on-disk last-status cache path; null without a userDataPath (tests), where persistence is a no-op and only in-memory replay applies. - private lastStatusFilePath: string | null = null - // Why: trailing-edge debounce timer, per-instance so test servers in one process don't share state. - private statusPersistTimer: ReturnType | null = null - private assistantMessageRetryTimers = new Map>() - private codexSubagentPollScheduler = new CodexSubagentPollScheduler( - CODEX_SUBAGENT_POLL_MS, - (paneKey, poll) => this.runCodexSubagentPoll(paneKey, poll) - ) - private promptSentDedupeByPaneKey = new Map() - private activeHookTurnCompletedAtByPaneKey = new Map() - private promptSentHashSalt = randomBytes(16).toString('hex') - private closedAgentStatusTabIds = new Set() - private closedAgentStatusPaneKeys = new Set() - private restartedStatusLaunchTokenHashByPaneKey = new Map() - private connectionTimestampWatermarkById = new Map() - // Why: skip disk writes when the JSON exactly matches the last write; guards against re-firing trailing timers when nothing changed. - private lastWrittenJson: string | null = null - // Why: main is the pane authority for local/WSL/SSH panes — hook HTTP, relay, and its own - // OSC parse all converge on applyNormalizedStatus, so one sequencer covers every ingress here. - private readonly observations = new AgentStatusObservationSequencer( - createAgentStatusAuthorityId('main-agent-hooks') - ) - - /** - * Notified once per process when repeated hook POSTs are cut off mid-body (#11217). - * Why: the listener fails open on every request error, so without this the only symptom is - * agent status quietly going stale — for every runtime at once, since they share this transport. - */ - setTransportInterferenceListener( - listener: ((report: HookTransportInterferenceReport) => void) | null - ): void { - this.onTransportInterference = listener - } - - setListener(listener: ((payload: EnrichedAgentHookEventPayload) => void) | null): void { - this.onAgentStatus = listener - if (!listener) { - return - } - // Why: replay is best-effort per pane so one throwing listener can't starve the rest. - for (const payload of this.state.lastStatusByPaneKey.values()) { - try { - // Why: cache always holds enriched payloads; the map's declared type is the bare shape only because the shared module never reads it. - listener({ ...(payload as EnrichedAgentHookEventPayload), isReplay: true }) - } catch (err) { - console.error('[agent-hooks] replay listener threw', err) - } - } - } - - // Why: statusline posts carry live Claude usage windows, not agent status; they feed RateLimitService directly. - setClaudeStatusLineListener( - listener: ((event: ClaudeStatusLineRateLimits) => void) | null - ): void { - this.onClaudeStatusLine = listener - } - - subscribeStatusChanges(listener: StatusChangeListener): () => void { - this.statusChangeListeners.add(listener) - return () => { - this.statusChangeListeners.delete(listener) - } - } - - subscribeProviderSessionChanges(listener: ProviderSessionChangeListener): () => void { - this.providerSessionChangeListeners.add(listener) - return () => { - this.providerSessionChangeListeners.delete(listener) - } - } - - /** Multi-subscriber tap on every enriched status change (no replay). */ - subscribeEnrichedStatus(listener: (payload: EnrichedAgentHookEventPayload) => void): () => void { - this.enrichedStatusListeners.add(listener) - return () => { - this.enrichedStatusListeners.delete(listener) - } - } - - /** Replay is durable evidence from a prior runtime, not a live observation. */ - private withdrawReplayObservation(paneKey: string): void { - if (this.runtimeObservedStatusPaneKeys.delete(paneKey)) { - this.notifyStatusChangeListeners() - } - } - - private ingestSpoolRecord(record: SpoolRecord): void { - if (!isAgentHookSource(record.source)) { - return - } - const body = this.normalizeHookBodyPaneKeyAlias(buildSpoolHookBody(record)) - const normalized = this.normalizeLocalHookPayload(record.source, body) - if (!normalized.event) { - return - } - const replay = { ...normalized.event, isReplay: true as const } - const statusDisposition = this.getAgentStatusDisposition(replay.paneKey, { - source: record.source, - hookEventName: replay.hookEventName, - isReplay: true, - hasExplicitPrompt: replay.hasExplicitPrompt, - launchToken: replay.launchToken - }) - if (statusDisposition === 'suppress') { - return - } - const event = statusDisposition === 'restart' ? { ...replay, launchToken: undefined } : replay - if (statusDisposition === 'restart') { - this.observations.rebind(event.paneKey) - } - this.recordCurrentAuthorityObservation(event) - this.applyNormalizedStatus(event, normalized.onAccepted) - if (event.payload.state !== 'done') { - this.withdrawReplayObservation(this.resolvePaneKeyAlias(event.paneKey)) - } - } - - setPaneStatusClearListener(listener: PaneStatusClearListener | null): void { - this.onPaneStatusCleared = listener - } - - /** Multi-subscriber tap on pane status clears. Unlike `setPaneStatusClearListener` - * (a single slot the main window owns and drops on close) this survives window - * teardown and exists at all under headless serve, which never opens one. */ - subscribePaneStatusClear(listener: PaneStatusClearListener): () => void { - this.paneStatusClearListeners.add(listener) - return () => { - this.paneStatusClearListeners.delete(listener) - } - } - - /** Multi-subscriber tap on definitive live-row deletions. `dropStatusEntry` is a user - * dismissal, so it never routes through the pane-status-clear fan-out — pane-owned - * cleanup (synthetic spinners) still has to retire with the row it was driving. */ - subscribeStatusDrop(listener: StatusDropListener): () => void { - this.statusDropListeners.add(listener) - return () => { - this.statusDropListeners.delete(listener) - } - } - - private emitStatusDropped(paneKey: string): void { - for (const listener of this.statusDropListeners) { - // Why: matches every other fan-out here — one throwing subscriber must not strand the rest. - try { - listener(paneKey) - } catch (err) { - console.error('[agent-hooks] status-drop listener threw', err) - } - } - } - - private emitPaneStatusCleared(clear: AgentStatusClearIpcPayload): void { - this.onPaneStatusCleared?.(clear) - for (const listener of this.paneStatusClearListeners) { - // Why: callers are pane/connection teardown paths; one throwing subscriber must - // not strand the rest, matching every other fan-out here. - try { - listener(clear) - } catch (err) { - console.error('[agent-hooks] pane-status-clear listener threw', err) - } - } - } - - /** Snapshot of cached statuses in IPC shape. Used by `agentStatus:getSnapshot` after tabs hydrate so the - * dashboard catches up on hook events that fired during startup. */ - getStatusSnapshot(): AgentStatusIpcPayload[] { - return Array.from(this.state.lastStatusByPaneKey.values(), (entry) => - toAgentStatusIpcPayload(entry as EnrichedAgentHookEventPayload) - ) - } - - /** Provider-session identities, including Pi's metadata-only rows. */ - getProviderSessionIdentities(): AgentHookProviderSessionIdentity[] { - return this.buildStatusChangeNotification().providerSessions - } - - getStatusSnapshotForPane(paneKey: string): AgentStatusIpcPayload[] { - const entry = this.state.lastStatusByPaneKey.get(paneKey) - return entry ? [toAgentStatusIpcPayload(entry as EnrichedAgentHookEventPayload)] : [] - } - - getHydratedAuthorityCommitments(): readonly AgentHookAuthorityEvidence[] { - return this.hydratedAuthorityCommitments - } - - getCurrentAuthorityObservations(): readonly AgentHookAuthorityEvidence[] { - return Object.freeze( - Array.from(this.currentAuthorityObservations.values(), (entry) => Object.freeze({ ...entry })) - ) - } - - attestCompatibilityAuthority(candidate: { - paneKey: string - launchTokenHash: string - connectionId: string | null - terminalProvenance: 'current_runtime' | 'restored' - }): AgentHookAuthorityAttestation | null { - const paneKey = this.resolvePaneKeyAlias(candidate.paneKey) - const matchesCandidate = (entry: AgentHookAuthorityEvidence): boolean => - entry.launchTokenHash === candidate.launchTokenHash && - entry.connectionId === candidate.connectionId - const commitments = this.hydratedAuthorityCommitments.filter( - (entry) => matchesCandidate(entry) && !this.revokedHydratedAuthorityCommitments.has(entry) - ) - const current = Array.from(this.currentAuthorityObservations.values()) - const observations = current.filter(matchesCandidate) - const paneObservations = current.filter( - (entry) => this.resolvePaneKeyAlias(entry.paneKey) === paneKey - ) - const hasUniqueCurrentObservation = - observations.length === 1 && - paneObservations.length === 1 && - this.resolvePaneKeyAlias(observations[0]!.paneKey) === paneKey - if (candidate.terminalProvenance === 'current_runtime') { - return hasUniqueCurrentObservation ? Object.freeze({ paneKey, source: 'current_hook' }) : null - } - if (commitments.length !== 1 || this.resolvePaneKeyAlias(commitments[0]!.paneKey) !== paneKey) { - return null - } - if (observations.length === 0 && paneObservations.length === 0) { - return Object.freeze({ paneKey, source: 'hydrated_commitment' }) - } - if (!hasUniqueCurrentObservation) { - return null - } - return Object.freeze({ paneKey, source: 'current_hook' }) - } - - inferInterrupt(request: AgentInterruptInferenceRequest): boolean { - if (!isValidPaneKey(request.paneKey)) { - return false - } - if (!isAgentInterruptInputIntent(request.intent)) { - return false - } - const existing = this.state.lastStatusByPaneKey.get(request.paneKey) as - | EnrichedAgentHookEventPayload - | undefined - if (!existing) { - return false - } - if (existing.providerSessionOnly) { - return false - } - // Why: inference must not fabricate a `done` onto a row whose `working` was never confirmed this runtime. - if (existing.restoredUnconfirmed) { - return false - } - const payload = existing.payload - const agentType: AgentType | undefined = payload.agentType - // Why: Droid's Ctrl+C exits the CLI (handled by PTY lifecycle) rather than interrupting the current turn. - if (agentType === 'droid' && request.intent === 'ctrl-c') { - return false - } - // Why: these agents use the first Escape as a TUI cancel that can leave the turn running; only a double Escape infers an interrupt. - if ( - (agentType === 'opencode' || agentType === 'copilot') && - request.intent === 'plain-escape' && - request.inputCount !== 2 - ) { - return false - } - const dismissesClaudeQuestion = - agentType === 'claude' && - request.intent === 'plain-escape' && - payload.state === 'waiting' && - isAskUserQuestionTool(payload.toolName) - if (dismissesClaudeQuestion) { - return this.inferQuestionAnswered(request) - } - // Why: inference is a fallback for a missing final hook; a strict baseline match keeps a delayed timer from clobbering any newer hook. - if ( - payload.state !== 'working' || - !equivalentInterruptAgentType(agentType, request.baselineAgentType) || - payload.prompt !== request.baselinePrompt || - existing.receivedAt !== request.baselineUpdatedAt || - existing.stateStartedAt !== request.baselineStateStartedAt || - Date.now() - existing.receivedAt > AGENT_STATUS_STALE_AFTER_MS - ) { - return false - } - // Why: a 'working' pane can be child-driven; Ctrl+C doesn't stop background children, so inferring done would retire live child rows. - if (payload.subagents?.some((subagent) => subagent.state !== 'idle')) { - return false - } - // Why: Escape/Ctrl+C at Claude's idle prompt does not stop provider-owned shells or session crons. - if ( - agentType === 'claude' && - (this.state.claudeRunningNonAgentTaskPaneKeys.has(existing.paneKey) || - this.state.claudeActiveSessionCronPaneKeys.has(existing.paneKey)) - ) { - return false - } - - // Why: keep the Claude lead-turn record in sync, or a later child event re-emits the stale 'working' state and resurrects the cancelled pane. - if (agentType === 'claude') { - markClaudeLeadTurnInterrupted(this.state, existing.paneKey) - } - if (agentType === 'codex') { - markCodexLeadTurnInterrupted(this.state, existing.paneKey) - } - const inferred = this.applyNormalizedStatus({ - paneKey: existing.paneKey, - tabId: existing.tabId, - worktreeId: existing.worktreeId, - connectionId: existing.connectionId, - providerSession: existing.providerSession, - payload: { - state: 'done', - prompt: payload.prompt, - agentType, - ...(payload.model ? { model: payload.model } : {}), - interrupted: true, - // Why: idle children are display state; dropping them on an inferred interrupt blanks rows a later hook would restore. - ...(payload.subagents ? { subagents: payload.subagents } : {}) - } - }) - console.debug('[agent-hooks] inferred interrupted agent status', { - paneKey: inferred.paneKey, - agentType, - intent: request.intent - }) - return true - } - - /** Guarded fallback for the hook Claude omits after answering or dismissing AskUserQuestion. */ - inferQuestionAnswered(request: AgentQuestionAnsweredInferenceRequest): boolean { - if (!isValidPaneKey(request.paneKey)) { - return false - } - const existing = this.state.lastStatusByPaneKey.get(request.paneKey) as - | EnrichedAgentHookEventPayload - | undefined - if (!existing) { - return false - } - // Why: inference must not fabricate a transition onto a row whose state was never confirmed this runtime. - if (existing.restoredUnconfirmed) { - return false - } - const payload = existing.payload - // Why: only Claude's interactive question clears on typed input — tool name (not hook event) discriminates; real permission waits stay sticky. - if ( - payload.agentType !== 'claude' || - payload.state !== 'waiting' || - !isAskUserQuestionTool(payload.toolName) - ) { - return false - } - if ( - payload.agentType !== request.baselineAgentType || - payload.prompt !== request.baselinePrompt || - existing.receivedAt !== request.baselineUpdatedAt || - existing.stateStartedAt !== request.baselineStateStartedAt || - Date.now() - existing.receivedAt > AGENT_STATUS_STALE_AFTER_MS - ) { - return false - } - // Why: sync the listener's lead-turn record too, or a later child event re-emits the stale waiting state and resurrects the card. - const restored = clearClaudeAnsweredQuestionWait(this.state, existing.paneKey) - const inferred = this.applyNormalizedStatus({ - paneKey: existing.paneKey, - tabId: existing.tabId, - worktreeId: existing.worktreeId, - connectionId: existing.connectionId, - providerSession: existing.providerSession, - payload: { - state: restored.state, - ...(restored.workingMode ? { workingMode: restored.workingMode } : {}), - prompt: payload.prompt, - agentType: payload.agentType, - ...(restored.state === 'done' && restored.interrupted ? { interrupted: true } : {}), - ...(restored.turnCompletedAt !== undefined - ? { turnCompletedAt: restored.turnCompletedAt } - : {}), - ...(payload.subagents ? { subagents: payload.subagents } : {}) - } - }) - console.debug('[agent-hooks] inferred resolved question status', { - paneKey: inferred.paneKey, - state: inferred.payload.state - }) - return true - } - - getStatusChangeSnapshot(): AgentHookStatusChangeEntry[] { - return this.buildStatusChangeNotification().statuses - } - - private buildStatusChangeNotification(): { - statuses: AgentHookStatusChangeEntry[] - providerSessions: AgentHookProviderSessionIdentity[] - } { - const statuses: AgentHookStatusChangeEntry[] = [] - const providerSessions: AgentHookProviderSessionIdentity[] = [] - for (const [paneKey, entry] of this.state.lastStatusByPaneKey) { - const enriched = entry as EnrichedAgentHookEventPayload - if (enriched.providerSession) { - providerSessions.push({ - paneKey, - sessionId: enriched.providerSession.id, - ...(enriched.providerSession.transcriptPath - ? { transcriptPath: enriched.providerSession.transcriptPath } - : {}), - ...(enriched.worktreeId ? { worktreeId: enriched.worktreeId } : {}) - }) - } - if (!enriched.providerSessionOnly) { - statuses.push({ - state: enriched.payload.state, - receivedAt: enriched.receivedAt, - observedInCurrentRuntime: this.runtimeObservedStatusPaneKeys.has(paneKey) - }) - } - } - return { statuses, providerSessions } - } - - private notifyStatusChangeListeners(): void { - if (this.statusChangeListeners.size === 0 && this.providerSessionChangeListeners.size === 0) { - return - } - const { statuses, providerSessions } = this.buildStatusChangeNotification() - for (const listener of this.statusChangeListeners) { - try { - listener(statuses) - } catch (err) { - console.error('[agent-hooks] status-change listener threw', err) - } - } - for (const listener of this.providerSessionChangeListeners) { - try { - listener(providerSessions) - } catch (err) { - console.error('[agent-hooks] provider-session listener threw', err) - } - } - } - - private markTabClosedForAgentStatus(tabId: string): void { - // Delete-then-add keeps recently closed tabs most-recent so eviction sheds only the oldest ids. - this.closedAgentStatusTabIds.delete(tabId) - this.closedAgentStatusTabIds.add(tabId) - while (this.closedAgentStatusTabIds.size > CLOSED_AGENT_STATUS_TAB_IDS_MAX) { - const oldest = this.closedAgentStatusTabIds.keys().next().value - if (oldest === undefined) { - break - } - this.closedAgentStatusTabIds.delete(oldest) - } - } - - private getAgentStatusDisposition( - paneKey: string, - event?: { - source?: AgentHookSource - /** Raw wire value, so the gate can tell "field absent" from "field present but unknown". */ - rawSource?: unknown - hookEventName?: string - isReplay?: boolean - hasExplicitPrompt?: boolean - launchToken?: string - } - ): 'accept' | 'restart' | 'suppress' { - const ownerPaneKey = this.resolvePaneKeyAlias(paneKey) - const paneRetired = - this.closedAgentStatusPaneKeys.has(paneKey) || - this.closedAgentStatusPaneKeys.has(ownerPaneKey) - const tabId = parsePaneKey(ownerPaneKey)?.tabId - if (tabId && this.closedAgentStatusTabIds.has(tabId)) { - return 'suppress' - } - if (!paneRetired) { - const tokenFence = this.restartedStatusLaunchTokenHashByPaneKey.get(ownerPaneKey) - // Why: deferred retirement lets a new process start in a still-authorized pane, so - // its tokened SessionStart re-fences; prompts recur, so a stale process would win. - if ( - event?.hookEventName === 'SessionStart' && - event.isReplay !== true && - tokenFence !== undefined - ) { - const startedLaunchToken = event.launchToken?.trim() - if (startedLaunchToken) { - this.restartedStatusLaunchTokenHashByPaneKey.set( - ownerPaneKey, - createHash('sha256').update(startedLaunchToken).digest('hex') - ) - return 'accept' - } - } - if (event && tokenFence) { - const launchToken = event.launchToken?.trim() - if (!launchToken || createHash('sha256').update(launchToken).digest('hex') !== tokenFence) { - return 'suppress' - } - } - return 'accept' - } - // Why: command completion retires launch authority but leaves its shell pane reusable. - // A live new-turn event proves a new agent process owns the retired pane just like a - // fresh prompt does — without it, a session resumed in a reused pane stays rowless (STA-3386). - // Why the classifier, not literals: only 5 of 18 sources name their boundary - // `UserPromptSubmit`/`SessionStart`; the rest stayed retired forever. - // Why four branches: `source` collapses to undefined when an older relay omits the field, - // when a newer host sends an unknown string, and when the wire value is malformed. Only an - // unknown string is valid future-provider evidence. Unreachable from the local path, which - // 404s an unresolvable source. - const isNewTurn = - event?.source !== undefined - ? isNewTurnEvent(event.source, event.hookEventName) - : typeof event?.rawSource === 'string' && event.rawSource.trim().length > 0 - ? // Why fail OPEN for an unknown provider: its boundary event is unknowable here, and - // the costs are asymmetric — a stranded pane is invisible and permanent with no user - // recovery, while a spurious revive decays after AGENT_STATUS_STALE_AFTER_MS. - true - : event?.rawSource === undefined - ? // Why literals here: an older relay omits `source` entirely. Legacy shim only — it - // cannot revive a provider whose boundary event is named anything else. - event?.hookEventName === 'UserPromptSubmit' || event?.hookEventName === 'SessionStart' - : false - // Why in addition to the classifier: the OpenCode family carries its mid-session boundary in - // an explicit-prompt MessagePart, which isNewTurnEvent cannot name — and mimo-code has no - // SessionStart at all, so without this its retired panes never come back. - const freshOpenCodeFamilyPrompt = - (event?.source === 'opencode' || event?.source === 'mimo-code') && - event.hookEventName === 'MessagePart' && - event.hasExplicitPrompt === true - // Why the token is minted here: a revive proves a live lifecycle, and fencing follow-up - // status on that launch token stops a stale process reclaiming the pane's row without - // restoring retired orchestration authority. - if ((isNewTurn || freshOpenCodeFamilyPrompt) && event?.isReplay !== true) { - this.closedAgentStatusPaneKeys.delete(paneKey) - this.closedAgentStatusPaneKeys.delete(ownerPaneKey) - const launchToken = event?.launchToken?.trim() - if (launchToken) { - this.restartedStatusLaunchTokenHashByPaneKey.set( - ownerPaneKey, - createHash('sha256').update(launchToken).digest('hex') - ) - } else { - this.restartedStatusLaunchTokenHashByPaneKey.delete(ownerPaneKey) - } - return 'restart' - } - return 'suppress' - } - - // Why: a fence can span tabs (a pane detached into another tab), and legacy numeric - // keys never parse as stable ones — resolve both forms so neither slips the tab check. - private isClosedAgentStatusTabForPaneKey(paneKey: string): boolean { - const tabId = - parsePaneKey(paneKey)?.tabId ?? parseLegacyNumericPaneKey(paneKey)?.tabId ?? undefined - return tabId !== undefined && this.closedAgentStatusTabIds.has(tabId) - } - - private recordRetiredPaneFence( - paneKeys: ReadonlySet, - aliases: readonly RetiredPaneAlias[] - ): void { - const fence: RetiredPaneFence = { paneKeys: [...paneKeys], aliases } - for (const key of paneKeys) { - // Delete-then-set keeps the newest fence most-recent so eviction sheds only the oldest. - this.retiredPaneFencesByKey.delete(key) - this.retiredPaneFencesByKey.set(key, fence) - } - while (this.retiredPaneFencesByKey.size > RETIRED_PANE_FENCES_MAX) { - const oldest = this.retiredPaneFencesByKey.keys().next().value - if (oldest === undefined) { - break - } - this.retiredPaneFencesByKey.delete(oldest) - } - } - - private markPaneClosedForAgentStatus(paneKey: string): void { - this.closedAgentStatusPaneKeys.delete(paneKey) - this.closedAgentStatusPaneKeys.add(paneKey) - while (this.closedAgentStatusPaneKeys.size > CLOSED_AGENT_STATUS_PANE_KEYS_MAX) { - const oldest = this.closedAgentStatusPaneKeys.keys().next().value - if (oldest === undefined) { - break - } - this.closedAgentStatusPaneKeys.delete(oldest) - } - } - - private attachStatusTiming( - payload: AgentHookEventPayload, - now = Date.now() - ): EnrichedAgentHookEventPayload { - const previous = this.state.lastStatusByPaneKey.get(payload.paneKey) as - | EnrichedAgentHookEventPayload - | undefined - const commandCodeNewTurn = - previous !== undefined && - isCommandCodeNewTurnWhileWorking({ - agentType: payload.payload.agentType, - previousState: previous.payload.state, - incomingState: payload.payload.state, - previousPrompt: previous.payload.prompt, - incomingPrompt: payload.payload.prompt, - hasExplicitPrompt: payload.hasExplicitPrompt, - previousPromptInteractionKey: previous.promptInteractionKey, - incomingPromptInteractionKey: payload.promptInteractionKey - }) - const stateStartedAt = - previous && previous.payload.state === payload.payload.state && !commandCodeNewTurn - ? previous.stateStartedAt - : now - return { - ...payload, - receivedAt: now, - stateStartedAt - } - } - - private hashPromptForTelemetryDedupe(prompt: string): string { - return createHash('sha256') - .update(this.promptSentHashSalt) - .update('\0') - .update(prompt) - .digest('hex') - } - - private maybeTrackAgentPromptSent( - payload: AgentHookEventPayload, - previousStatus: EnrichedAgentHookEventPayload | undefined - ): void { - if (payload.isReplay === true || payload.hasExplicitPrompt !== true) { - return - } - const prompt = payload.payload.prompt?.trim() ?? '' - if (prompt.length === 0) { - return - } - const agentKind = agentTypeToPromptSentAgentKind(payload.payload.agentType) - const promptHash = this.hashPromptForTelemetryDedupe(prompt) - const promptInteractionKey = - typeof payload.promptInteractionKey === 'string' && - payload.promptInteractionKey.trim().length > 0 - ? payload.promptInteractionKey.trim() - : undefined - const previousDedupe = this.promptSentDedupeByPaneKey.get(payload.paneKey) - const isCompletedTurnBoundary = - previousStatus?.payload.state === 'done' && payload.payload.state === 'working' - if ( - previousDedupe?.agentKind === agentKind && - previousDedupe.promptInteractionKey !== undefined && - previousDedupe.promptInteractionKey === promptInteractionKey && - (agentKind === 'opencode' || previousDedupe.promptHash === promptHash) - ) { - return - } - if ( - previousDedupe?.agentKind === agentKind && - previousDedupe.promptHash === promptHash && - !( - previousStatus?.payload.state === 'done' && - payload.payload.state === 'done' && - previousDedupe.promptInteractionKey !== undefined && - promptInteractionKey !== undefined && - previousDedupe.promptInteractionKey !== promptInteractionKey - ) && - !isCompletedTurnBoundary - ) { - return - } - this.promptSentDedupeByPaneKey.set(payload.paneKey, { - agentKind, - promptHash, - promptInteractionKey - }) - try { - // Why: hooks prove a turn was submitted but not which UI launched the terminal; keep attribution low-cardinality. - track('agent_prompt_sent', { - agent_kind: agentKind, - launch_source: 'unknown', - request_kind: 'followup', - ...getCohortAtEmit() - }) - } catch (err) { - console.error('[agent-hooks] prompt-sent telemetry failed', err) - } - } - - /** Stamp who observed this event, in what order, on main's clock. Nothing reads it yet - * (STA-4293) — it is stamped here because every main-side ingress funnels through - * applyNormalizedStatus, so no origin can silently arrive untagged. */ - private stampObservation( - payload: AgentHookEventPayload, - origin: AgentStatusObservationOrigin, - observedAt: number - ): AgentStatusObservation { - return this.observations.observe(payload.paneKey, { - origin, - observedAt, - // Why: reuse the listener's own per-provider classifier; a second list of raw event-name - // literals here would strand the providers whose boundary event is named anything else. - boundary: - payload.source !== undefined && isNewTurnEvent(payload.source, payload.hookEventName), - kind: payload.providerSessionOnly - ? 'identity-only' - : // Why: a replay restates a turn that already happened, and OSC 9999 repaints the - // current state rather than announcing a change — neither is a fresh transition. - payload.isReplay === true || origin === 'osc' - ? 'snapshot' - : 'transition' - }) - } - - private applyNormalizedStatus( - payload: AgentHookEventPayload, - onAccepted?: () => void, - origin: AgentStatusObservationOrigin = 'hook' - ): EnrichedAgentHookEventPayload { - if (payload.hookEventName === 'UserPromptSubmit') { - // Why: the prompt boundary is authoritative even when text is unchanged; its next OSC working row must not inherit the prior cron/background turn stamp. - this.activeHookTurnCompletedAtByPaneKey.delete(payload.paneKey) - } - let previous = this.state.lastStatusByPaneKey.get(payload.paneKey) as - | EnrichedAgentHookEventPayload - | undefined - const connectionClearWatermark = payload.connectionId - ? this.connectionTimestampWatermarkById.get(payload.connectionId) - : undefined - // Why: renderer ordering rejects older rows; live evidence must sort after reconnect clears and restored rows across clock rollback. - const restoredStatusWatermark = previous?.restoredUnconfirmed ? previous.receivedAt : undefined - const now = Math.max( - Date.now(), - (connectionClearWatermark ?? -1) + 1, - (restoredStatusWatermark ?? -1) + 1 - ) - if (payload.connectionId) { - this.connectionTimestampWatermarkById.set(payload.connectionId, now) - } - if (payload.providerSessionOnly) { - // Why: identity-only rows survive replay but must not emit prompt telemetry or a fabricated status. - onAccepted?.() - const enriched = { - ...this.attachStatusTiming(payload, now), - observation: this.stampObservation(payload, origin, now) - } - this.clearAssistantMessageRetry(enriched.paneKey) - this.runtimeObservedStatusPaneKeys.delete(enriched.paneKey) - this.state.lastStatusByPaneKey.set(enriched.paneKey, enriched) - this.scheduleStatusPersist() - this.notifyStatusChangeListeners() - this.emitEnrichedStatus(enriched) - return enriched - } - const stateReconciledPayload = - payload.connectionId && payload.payload.agentType === 'codex' && payload.hookEventName - ? { - ...payload, - payload: reconcileRemoteCodexState( - this.state, - payload.paneKey, - payload.hookEventName, - payload.toolAgentId, - payload.payload, - previous?.payload - ) - } - : payload - const previousCodexRoot = - stateReconciledPayload.payload.agentType === 'codex' && - stateReconciledPayload.toolAgentId && - previous?.payload.agentType === 'codex' - ? previous - : undefined - const preservedProviderSession = !stateReconciledPayload.providerSession - ? previousCodexRoot?.providerSession - : undefined - const preservedRootModel = !stateReconciledPayload.payload.model - ? previousCodexRoot?.payload.model - : undefined - // Why: an SSH relay restart forgets root-only fields; child hooks must not erase durable resume/model identity. - const rootContextPreservingPayload = - preservedProviderSession || preservedRootModel - ? { - ...stateReconciledPayload, - ...(preservedProviderSession ? { providerSession: preservedProviderSession } : {}), - payload: preservedRootModel - ? { ...stateReconciledPayload.payload, model: preservedRootModel } - : stateReconciledPayload.payload - } - : stateReconciledPayload - const boundaryReconciledPrevious = invalidateClaudeChildOnlyBoundary( - previous, - rootContextPreservingPayload - ) - if (boundaryReconciledPrevious !== previous) { - previous = boundaryReconciledPrevious - if (previous) { - this.state.lastStatusByPaneKey.set(previous.paneKey, previous) - this.scheduleStatusPersist() - } - } - const identity = resolveAgentStatusIdentity({ - existing: previous - ? { - agentType: previous.payload.agentType, - state: previous.payload.state, - updatedAt: previous.receivedAt, - restoredUnconfirmed: previous.restoredUnconfirmed - } - : undefined, - incoming: rootContextPreservingPayload.payload.agentType, - now - }) - if ( - previous && - shouldSuppressInheritedTerminalStatus({ - inheritedFromActivePane: identity.inheritedFromActivePane, - incomingState: rootContextPreservingPayload.payload.state - }) - ) { - return previous - } - const identityResolvedPayload = - identity.agentType === rootContextPreservingPayload.payload.agentType - ? rootContextPreservingPayload - : { - ...rootContextPreservingPayload, - payload: { - ...rootContextPreservingPayload.payload, - agentType: identity.agentType - } - } - const effectivePayload = attachClaudePermissionToolUseId(previous, identityResolvedPayload) - const boundaryAwarePayload = attachClaudeChildOnlyBoundary(previous, effectivePayload) - if (previous && shouldKeepClaudePermissionVisible(previous, effectivePayload)) { - return previous - } - // Why: some TUIs emit a delayed tool/working hook after Ctrl+C stopped the turn; don't let it resurrect the row. - if ( - previous?.payload.state === 'done' && - previous.payload.interrupted === true && - effectivePayload.payload.state === 'done' && - previous.payload.agentType === effectivePayload.payload.agentType && - previous.payload.prompt === effectivePayload.payload.prompt && - Date.now() - previous.receivedAt <= INTERRUPTED_DONE_LATE_WORKING_SUPPRESSION_MS - ) { - return previous - } - if ( - previous?.payload.state === 'done' && - previous.payload.interrupted === true && - effectivePayload.payload.state === 'working' && - previous.payload.agentType === effectivePayload.payload.agentType && - previous.payload.prompt === effectivePayload.payload.prompt && - (effectivePayload.isReplay === true || - isToolProgressWorkingAfterInterrupt(effectivePayload) || - (effectivePayload.hasExplicitPrompt !== true && - Date.now() - previous.receivedAt <= INTERRUPTED_DONE_LATE_WORKING_SUPPRESSION_MS)) - ) { - if (effectivePayload.payload.agentType === 'codex') { - markCodexLeadTurnInterrupted(this.state, effectivePayload.paneKey) - } - return previous - } - if ( - effectivePayload.payload.state !== 'done' || - effectivePayload.payload.lastAssistantMessage - ) { - this.clearAssistantMessageRetry(effectivePayload.paneKey) - } - onAccepted?.() - if (!identity.inheritedFromActivePane) { - this.maybeTrackAgentPromptSent(effectivePayload, previous) - } - const enriched = { - ...this.attachStatusTiming(boundaryAwarePayload, now), - observation: this.stampObservation(boundaryAwarePayload, origin, now) - } - if ( - typeof enriched.payload.turnCompletedAt === 'number' && - Number.isFinite(enriched.payload.turnCompletedAt) - ) { - this.activeHookTurnCompletedAtByPaneKey.set( - enriched.paneKey, - enriched.payload.turnCompletedAt - ) - } - // Why: an identity-matched event can still leave the aggregate backed only by another restored child; keep liveness reconciliation eligible. - if (enriched.restoredUnconfirmed) { - this.runtimeObservedStatusPaneKeys.delete(enriched.paneKey) - } else { - this.runtimeObservedStatusPaneKeys.add(enriched.paneKey) - } - this.state.lastStatusByPaneKey.set(enriched.paneKey, enriched) - this.scheduleStatusPersist() - this.notifyStatusChangeListeners() - this.emitEnrichedStatus(enriched) - return enriched - } - - // Why: every status emit must reach plugins too, so a new early-return path - // upstream cannot silently leave the plugin tap behind the main-window fanout. - private emitEnrichedStatus(enriched: EnrichedAgentHookEventPayload): void { - this.onAgentStatus?.(enriched) - for (const listener of this.enrichedStatusListeners) { - try { - listener(enriched) - } catch (err) { - console.error('[agent-hooks] enriched status listener threw', err) - } - } - } - - private clearAssistantMessageRetry(paneKey: string): void { - const timer = this.assistantMessageRetryTimers.get(paneKey) - if (!timer) { - return - } - clearTimeout(timer) - this.assistantMessageRetryTimers.delete(paneKey) - } - - private clearCodexSubagentPoll(paneKey: string): void { - this.codexSubagentPollScheduler.clear(paneKey) - } - - private scheduleCodexSubagentPoll( - source: AgentHookSource, - body: unknown, - original: EnrichedAgentHookEventPayload - ): void { - // Why: a nested non-codex CLI inherits ORCA_PANE_KEY, so clearing here would silently end a live codex poll. - if (source !== 'codex') { - return - } - this.codexSubagentPollScheduler.clear(original.paneKey) - if (!hasCodexTranscriptSubagents(this.state, original.paneKey)) { - return - } - this.codexSubagentPollScheduler.schedule(original.paneKey, { source, body, original }) - } - - private runCodexSubagentPoll(paneKey: string, poll: CodexSubagentPoll): void { - const { source, body, original } = poll - // Keep the identity check at callback time: a newer event supersedes this - // payload even when its pane still has transcript children. - if ( - paneKey !== original.paneKey || - !this.server || - this.state.lastStatusByPaneKey.get(original.paneKey) !== original - ) { - return - } - const normalized = normalizeHookPayload(this.state, source, body, this.env) - if (!normalized) { - return - } - const subagentsChanged = - JSON.stringify(normalized.payload.subagents) !== JSON.stringify(original.payload.subagents) - const next = subagentsChanged ? this.applyNormalizedStatus(normalized) : original - this.scheduleCodexSubagentPoll(source, body, next) - } - - private scheduleAssistantMessageRetry( - source: AgentHookSource, - body: unknown, - original: EnrichedAgentHookEventPayload, - attempt = 1, - discoveryReady = false - ): void { - if ( - original.payload.lastAssistantMessage || - !hasPendingAgentResultText(source, body) || - attempt > ASSISTANT_MESSAGE_RETRY_ATTEMPTS - ) { - return - } - this.clearAssistantMessageRetry(original.paneKey) - if (!discoveryReady) { - const discovery = preparePendingGrokResultDiscovery(source, body) - if (discovery) { - // Why: slug-group discovery can outlive the bounded flush timers; its completion must drive the first retry deterministically. - void discovery - .then(() => { - if (this.server) { - this.applyAssistantMessageRetry(source, body, original, 1, true) - } - }) - .catch((err) => { - console.error('[agent-hooks] Grok result discovery failed:', err) - }) - return - } - } - const timer = setTimeout(() => { - try { - this.assistantMessageRetryTimers.delete(original.paneKey) - this.applyAssistantMessageRetry(source, body, original, attempt + 1, discoveryReady) - } catch (err) { - console.error('[agent-hooks] assistant message retry failed:', err) - } - }, ASSISTANT_MESSAGE_RETRY_MS) - this.assistantMessageRetryTimers.set(original.paneKey, timer) - if (typeof timer.unref === 'function') { - timer.unref() - } - } - - private applyAssistantMessageRetry( - source: AgentHookSource, - body: unknown, - original: EnrichedAgentHookEventPayload, - nextAttempt: number, - requireExactOriginal: boolean - ): void { - const current = this.state.lastStatusByPaneKey.get(original.paneKey) as - | EnrichedAgentHookEventPayload - | undefined - if ( - !current || - (requireExactOriginal && current !== original) || - current.payload.agentType !== original.payload.agentType || - current.payload.prompt !== original.payload.prompt || - current.payload.lastAssistantMessage - ) { - return - } - const normalized = this.normalizeLocalHookPayload(source, body) - if (!normalized.event?.payload.lastAssistantMessage) { - this.scheduleAssistantMessageRetry(source, body, original, nextAttempt, requireExactOriginal) - return - } - // Why: some agents POST Stop before their transcript line is flushed; discovery is event-driven, later content retries stay timed. - this.applyNormalizedStatus(normalized.event, normalized.onAccepted) - } - - setPaneKeyAliasPersistenceListener(listener: PaneKeyAliasPersistenceListener | null): void { - this.paneKeyAliasPersistenceListener = listener - } - - private getPersistedPaneKeyAliases(): LegacyPaneKeyAliasEntry[] { - return Array.from(this.legacyPaneKeyAliases.entries()).flatMap(([legacyPaneKey, entry]) => - entry.ptyId - ? [ - { - ptyId: entry.ptyId, - legacyPaneKey, - stablePaneKey: entry.stablePaneKey, - updatedAt: entry.updatedAt - } - ] - : [] - ) - } - - private notifyPaneKeyAliasPersistenceListener(): void { - this.paneKeyAliasPersistenceListener?.(this.getPersistedPaneKeyAliases()) - } - - private boundPaneKeyAliases(): void { - while (this.legacyPaneKeyAliases.size > PANE_KEY_ALIASES_MAX) { - // Why: renderer-originated aliases are untrusted; insertion-order eviction bounds memory and per-message cleanup. - const oldestKey = this.legacyPaneKeyAliases.keys().next().value - if (!oldestKey) { - break - } - this.legacyPaneKeyAliases.delete(oldestKey) - } - } - - private getPhysicalPaneKeyForAuthority(paneKey: string, ptyId?: string): string { - const ownerPaneKey = this.resolvePaneKeyAlias(paneKey) - let fallbackPaneKey = paneKey - for (const [physicalPaneKey, entry] of this.legacyPaneKeyAliases) { - if ( - entry.stablePaneKey === ownerPaneKey && - (!ptyId || !entry.ptyId || entry.ptyId === ptyId) - ) { - if (entry.authorityVerified) { - return physicalPaneKey - } - fallbackPaneKey = physicalPaneKey - } - } - return fallbackPaneKey - } - - canTransferPaneAuthority( - fromPaneKey: string, - ptyId: string | undefined, - ownsPty: (physicalPaneKey: string, ptyId: string) => boolean - ): boolean { - if (!isValidPaneKey(fromPaneKey)) { - return false - } - const ownerPaneKey = this.resolvePaneKeyAlias(fromPaneKey) - const physicalPaneKey = this.getPhysicalPaneKeyForAuthority(fromPaneKey, ptyId) - const alias = this.legacyPaneKeyAliases.get(physicalPaneKey) - if (ptyId) { - return Boolean( - (alias?.authorityVerified && alias.ptyId === ptyId) || - ownsPty(physicalPaneKey, ptyId) || - (ownerPaneKey !== physicalPaneKey && ownsPty(ownerPaneKey, ptyId)) - ) - } - // Why: hook status is renderer evidence, not PTY ownership; ID-less moves are safe only after a verified transfer minted an alias. - return alias?.authorityVerified === true - } - - registerPaneKeyAlias( - legacyPaneKey: string, - stablePaneKey: string, - ptyId?: string, - updatedAt = Date.now(), - options?: { overwriteExisting?: boolean; authorityVerified?: boolean } - ): void { - const fromPaneKey = legacyPaneKey.trim() - const toPaneKey = stablePaneKey.trim() - if (!canRegisterPaneKeyAlias(fromPaneKey, toPaneKey)) { - return - } - const existing = this.legacyPaneKeyAliases.get(fromPaneKey) - if (existing && options?.overwriteExisting === false) { - return - } - // Why: remint tokens have no embedded tab id; first pane wins so a later spawn - // cannot steal leftover $$…:L$$ posts onto a different tab:leaf. - if (existing && existing.stablePaneKey !== toPaneKey && isOpaqueRemintedPaneKey(fromPaneKey)) { - return - } - const normalizedPtyId = - typeof ptyId === 'string' && ptyId.trim().length > 0 ? ptyId.trim() : existing?.ptyId - const normalizedUpdatedAt = - Number.isFinite(updatedAt) && updatedAt > 0 ? updatedAt : (existing?.updatedAt ?? Date.now()) - const authorityVerified = options?.authorityVerified ?? false - if ( - existing && - existing.stablePaneKey === toPaneKey && - existing.ptyId === (normalizedPtyId ?? null) && - existing.updatedAt === normalizedUpdatedAt && - existing.authorityVerified === authorityVerified - ) { - return - } - this.legacyPaneKeyAliases.set(fromPaneKey, { - stablePaneKey: toPaneKey, - ptyId: normalizedPtyId ?? null, - updatedAt: normalizedUpdatedAt, - authorityVerified - }) - this.boundPaneKeyAliases() - if (normalizedPtyId) { - this.notifyPaneKeyAliasPersistenceListener() - } - } - - transferPaneAuthority( - fromPaneKey: string, - toPaneKey: string, - ptyId?: string, - updatedAt = Date.now(), - options?: { authorityVerified?: boolean } - ): void { - if (!isValidPaneKey(fromPaneKey) || !isValidPaneKey(toPaneKey)) { - return - } - const previousOwnerPaneKey = this.resolvePaneKeyAlias(fromPaneKey) - const physicalPaneKey = this.getPhysicalPaneKeyForAuthority(fromPaneKey, ptyId) - const existing = this.legacyPaneKeyAliases.get(physicalPaneKey) - const normalizedPtyId = ptyId?.trim() || existing?.ptyId || null - const hadStatus = this.state.lastStatusByPaneKey.has(previousOwnerPaneKey) - movePaneCacheState(this.state, previousOwnerPaneKey, toPaneKey) - const movedStatus = this.state.lastStatusByPaneKey.get(toPaneKey) as - | EnrichedAgentHookEventPayload - | undefined - if (movedStatus) { - const owner = parsePaneKey(toPaneKey) - this.state.lastStatusByPaneKey.set(toPaneKey, { - ...movedStatus, - paneKey: toPaneKey, - tabId: owner?.tabId - }) - } - const hydratedLaunchTokenHash = this.hydratedLaunchTokenHashByPaneKey.get(previousOwnerPaneKey) - if (hydratedLaunchTokenHash) { - this.hydratedLaunchTokenHashByPaneKey.delete(previousOwnerPaneKey) - this.hydratedLaunchTokenHashByPaneKey.set(toPaneKey, hydratedLaunchTokenHash) - } - const persistedAuthority = this.persistedAuthorityCommitmentsByPaneKey.get(previousOwnerPaneKey) - if (persistedAuthority) { - const owner = parsePaneKey(toPaneKey) - this.persistedAuthorityCommitmentsByPaneKey.delete(previousOwnerPaneKey) - this.persistedAuthorityCommitmentsByPaneKey.set( - toPaneKey, - Object.freeze({ - ...persistedAuthority, - paneKey: toPaneKey, - ...(owner?.tabId ? { tabId: owner.tabId } : {}) - }) - ) - } - if (this.runtimeObservedStatusPaneKeys.delete(previousOwnerPaneKey)) { - this.runtimeObservedStatusPaneKeys.add(toPaneKey) - } - const restartedTokenHash = - this.restartedStatusLaunchTokenHashByPaneKey.get(previousOwnerPaneKey) - this.restartedStatusLaunchTokenHashByPaneKey.delete(previousOwnerPaneKey) - this.restartedStatusLaunchTokenHashByPaneKey.delete(toPaneKey) - if (restartedTokenHash) { - this.restartedStatusLaunchTokenHashByPaneKey.set(toPaneKey, restartedTokenHash) - } - const activeTurnCompletedAt = this.activeHookTurnCompletedAtByPaneKey.get(previousOwnerPaneKey) - if (activeTurnCompletedAt !== undefined) { - this.activeHookTurnCompletedAtByPaneKey.delete(previousOwnerPaneKey) - this.activeHookTurnCompletedAtByPaneKey.set(toPaneKey, activeTurnCompletedAt) - } - const authorityObservation = this.currentAuthorityObservations.get(previousOwnerPaneKey) - if (authorityObservation) { - const owner = parsePaneKey(toPaneKey) - this.currentAuthorityObservations.delete(previousOwnerPaneKey) - this.currentAuthorityObservations.set( - toPaneKey, - Object.freeze({ - ...authorityObservation, - paneKey: toPaneKey, - tabId: owner?.tabId - }) - ) - } - const promptDedupe = this.promptSentDedupeByPaneKey.get(previousOwnerPaneKey) - if (promptDedupe !== undefined) { - this.promptSentDedupeByPaneKey.delete(previousOwnerPaneKey) - this.promptSentDedupeByPaneKey.set(toPaneKey, promptDedupe) - } - this.clearAssistantMessageRetry(previousOwnerPaneKey) - this.clearCodexSubagentPoll(previousOwnerPaneKey) - // Why: the live process keeps posting the physical source key after detach; persist a chain-safe mapping to the current owner. - this.legacyPaneKeyAliases.set(physicalPaneKey, { - stablePaneKey: toPaneKey, - ptyId: normalizedPtyId, - updatedAt, - authorityVerified: options?.authorityVerified ?? true - }) - this.boundPaneKeyAliases() - this.closedAgentStatusPaneKeys.delete(toPaneKey) - this.notifyPaneKeyAliasPersistenceListener() - if (hadStatus || persistedAuthority) { - this.scheduleStatusPersist() - this.notifyStatusChangeListeners() - } - } - - retirePaneAuthority(paneKey: string): void { - const ownerPaneKey = this.resolvePaneKeyAlias(paneKey) - const paneKeys = new Set([paneKey, ownerPaneKey]) - const retiredAliases: RetiredPaneAlias[] = [] - let aliasChanged = false - for (const [physicalPaneKey, entry] of this.legacyPaneKeyAliases) { - if (physicalPaneKey === paneKey || entry.stablePaneKey === ownerPaneKey) { - this.legacyPaneKeyAliases.delete(physicalPaneKey) - retiredAliases.push({ physicalPaneKey, entry }) - paneKeys.add(physicalPaneKey) - paneKeys.add(entry.stablePaneKey) - aliasChanged = true - } - } - this.recordRetiredPaneFence(paneKeys, retiredAliases) - const authorityChanged = this.revokeHydratedAuthorityForPaneKeys(paneKeys) - const hadStatus = [...paneKeys].some((key) => this.state.lastStatusByPaneKey.has(key)) - for (const key of paneKeys) { - this.markPaneClosedForAgentStatus(key) - this.restartedStatusLaunchTokenHashByPaneKey.delete(key) - this.clearAssistantMessageRetry(key) - this.clearCodexSubagentPoll(key) - clearPaneCacheState(this.state, key) - this.activeHookTurnCompletedAtByPaneKey.delete(key) - this.runtimeObservedStatusPaneKeys.delete(key) - this.currentAuthorityObservations.delete(key) - this.promptSentDedupeByPaneKey.delete(key) - this.observations.forget(key) - } - if (aliasChanged) { - this.notifyPaneKeyAliasPersistenceListener() - } - if (hadStatus || authorityChanged) { - this.scheduleStatusPersist() - this.notifyStatusChangeListeners() - } - } - - // Why: retirement fences a pane and every alias of it, then deletes those aliases. - // Lifting only the key we are handed strands the rest — a detached pane's process - // keeps posting the key it launched under, so it would stay suppressed forever with - // the fence apparently lifted. Replay the recorded fence instead: same key set, same - // aliases. Keys and aliases belonging to a closed tab are skipped, so the stronger - // claim survives and a live process is never routed back into a closed tab. - private restoreRetiredPaneFence(fence: RetiredPaneFence): void { - let aliasChanged = false - for (const { physicalPaneKey, entry } of fence.aliases) { - if ( - this.isClosedAgentStatusTabForPaneKey(physicalPaneKey) || - this.isClosedAgentStatusTabForPaneKey(entry.stablePaneKey) || - // Why: the pane was rebound in the meantime; the newer alias is the truth. - this.legacyPaneKeyAliases.has(physicalPaneKey) - ) { - continue - } - this.legacyPaneKeyAliases.set(physicalPaneKey, entry) - aliasChanged = true - } - for (const key of fence.paneKeys) { - if (this.retiredPaneFencesByKey.get(key) === fence) { - this.retiredPaneFencesByKey.delete(key) - } - } - if (aliasChanged) { - this.boundPaneKeyAliases() - this.notifyPaneKeyAliasPersistenceListener() - } - } - - // Why: retirement is a claim that a pane is gone. Re-attaching a live PTY to that - // exact pane disproves the claim at the moment it stops being true, so the fence - // lifts here instead of waiting for the agent to speak again — an agent re-attached - // mid-turn or left idle would otherwise stay suppressed for the rest of its life - // (STA-4114). A closed *tab* is a separate, stronger claim and is left standing. - restorePaneAuthority(paneKey: string): boolean { - const ownerPaneKey = this.resolvePaneKeyAlias(paneKey) - if (this.isClosedAgentStatusTabForPaneKey(ownerPaneKey)) { - return false - } - const fence = - this.retiredPaneFencesByKey.get(paneKey) ?? this.retiredPaneFencesByKey.get(ownerPaneKey) - let restored = false - for (const key of new Set([paneKey, ownerPaneKey, ...(fence?.paneKeys ?? [])])) { - if (this.isClosedAgentStatusTabForPaneKey(key)) { - continue - } - if (this.closedAgentStatusPaneKeys.delete(key)) { - restored = true - } - } - if (fence) { - this.restoreRetiredPaneFence(fence) - } - return restored - } - - clearPaneKeyAliasesForPty( - ptyId: string, - options?: { shouldClearStablePaneKey?: (paneKey: string) => boolean } - ): void { - let aliasChanged = false - let statusChanged = false - const clearedStatusPaneKeys = new Set() - for (const [legacyPaneKey, entry] of this.legacyPaneKeyAliases) { - if (entry.ptyId === ptyId) { - const shouldClearStablePaneKey = - options?.shouldClearStablePaneKey?.(entry.stablePaneKey) ?? true - const revokedPaneKeys = new Set([legacyPaneKey]) - if (shouldClearStablePaneKey) { - revokedPaneKeys.add(entry.stablePaneKey) - } - if (this.revokeHydratedAuthorityForPaneKeys(revokedPaneKeys)) { - statusChanged = true - } - this.legacyPaneKeyAliases.delete(legacyPaneKey) - clearPaneCacheState(this.state, legacyPaneKey) - this.activeHookTurnCompletedAtByPaneKey.delete(legacyPaneKey) - this.currentAuthorityObservations.delete(legacyPaneKey) - this.promptSentDedupeByPaneKey.delete(legacyPaneKey) - if (shouldClearStablePaneKey && this.state.lastStatusByPaneKey.has(entry.stablePaneKey)) { - statusChanged = true - clearedStatusPaneKeys.add(entry.stablePaneKey) - } - if (shouldClearStablePaneKey) { - // Why: hydrated rows live under the stable key; if this PTY dies before ptyPaneKey rebuilds, alias cleanup is the only evictor. - clearPaneCacheState(this.state, entry.stablePaneKey) - this.activeHookTurnCompletedAtByPaneKey.delete(entry.stablePaneKey) - this.runtimeObservedStatusPaneKeys.delete(entry.stablePaneKey) - this.currentAuthorityObservations.delete(entry.stablePaneKey) - this.promptSentDedupeByPaneKey.delete(entry.stablePaneKey) - } - aliasChanged = true - } - } - if (aliasChanged) { - this.notifyPaneKeyAliasPersistenceListener() - } - if (statusChanged) { - this.scheduleStatusPersist() - this.notifyStatusChangeListeners() - for (const paneKey of clearedStatusPaneKeys) { - this.emitPaneStatusCleared({ paneKey }) - } - } - } - - private resolvePaneKeyAlias(paneKey: string): string { - return this.legacyPaneKeyAliases.get(paneKey)?.stablePaneKey ?? paneKey - } - - private revokeHydratedAuthorityForPaneKeys(paneKeys: ReadonlySet): boolean { - let changed = false - for (const commitment of this.hydratedAuthorityCommitments) { - if ( - paneKeys.has(commitment.paneKey) || - paneKeys.has(this.resolvePaneKeyAlias(commitment.paneKey)) - ) { - this.revokedHydratedAuthorityCommitments.add(commitment) - changed = true - } - } - for (const paneKey of paneKeys) { - const resolvedPaneKey = this.resolvePaneKeyAlias(paneKey) - changed = this.hydratedLaunchTokenHashByPaneKey.delete(paneKey) || changed - changed = this.hydratedLaunchTokenHashByPaneKey.delete(resolvedPaneKey) || changed - changed = this.persistedAuthorityCommitmentsByPaneKey.delete(paneKey) || changed - changed = this.persistedAuthorityCommitmentsByPaneKey.delete(resolvedPaneKey) || changed - } - return changed - } - - private normalizeHookBodyPaneKeyAlias(body: unknown): unknown { - if (typeof body !== 'object' || body === null) { - return body - } - const record = body as Record - const rawPaneKey = typeof record.paneKey === 'string' ? record.paneKey.trim() : '' - const stablePaneKey = this.legacyPaneKeyAliases.get(rawPaneKey)?.stablePaneKey - if (!stablePaneKey) { - return body - } - // Why: detached shells keep posting the immutable physical pane key; normalize pane and tab identity to the current owner. - return { ...record, paneKey: stablePaneKey, tabId: parsePaneKey(stablePaneKey)?.tabId } - } - - private normalizeLocalHookPayload(source: AgentHookSource, body: unknown): NormalizedLocalHook { - if (source !== 'claude' || typeof body !== 'object' || body === null) { - return { event: normalizeHookPayload(this.state, source, body, this.env) } - } - const rawPaneKey = (body as Record).paneKey - const paneKey = typeof rawPaneKey === 'string' ? rawPaneKey.trim() : '' - if (!paneKey) { - return { event: normalizeHookPayload(this.state, source, body, this.env) } - } - const previousRunningTask = this.state.claudeRunningNonAgentTaskPaneKeys.has(paneKey) - const previousActiveCron = this.state.claudeActiveSessionCronPaneKeys.has(paneKey) - const event = normalizeHookPayload(this.state, source, body, this.env) - const nextRunningTask = this.state.claudeRunningNonAgentTaskPaneKeys.has(paneKey) - const nextActiveCron = this.state.claudeActiveSessionCronPaneKeys.has(paneKey) - this.setClaudeBackgroundEvidence(paneKey, previousRunningTask, previousActiveCron) - if (!event || event.paneKey !== paneKey) { - return { event } - } - // Why: nested CLIs may inherit the pane key; only accepted statuses may mutate its background-work gate. - return { - event, - onAccepted: () => this.setClaudeBackgroundEvidence(paneKey, nextRunningTask, nextActiveCron) - } - } - - private setClaudeBackgroundEvidence( - paneKey: string, - hasRunningTask: boolean, - hasActiveCron: boolean - ): void { - if (hasRunningTask) { - this.state.claudeRunningNonAgentTaskPaneKeys.add(paneKey) - } else { - this.state.claudeRunningNonAgentTaskPaneKeys.delete(paneKey) - } - if (hasActiveCron) { - this.state.claudeActiveSessionCronPaneKeys.add(paneKey) - } else { - this.state.claudeActiveSessionCronPaneKeys.delete(paneKey) - } - } - - ingestTerminalStatus(event: { - paneKey: string - tabId?: string - worktreeId?: string - connectionId?: string | null - payload: ParsedAgentStatusPayload - }): void { - const physicalPaneKey = event.paneKey.trim() - const paneKey = this.resolvePaneKeyAlias(physicalPaneKey) - const parsedPaneKey = parsePaneKey(paneKey) - if (paneKey.length === 0) { - track('agent_hook_unattributed', { reason: 'empty_pane_key' }) - return - } - if (paneKey.length > MAX_PANE_KEY_LEN || !parsedPaneKey) { - return - } - const reportedTabId = - event.tabId !== undefined && event.tabId.trim().length > 0 ? event.tabId.trim() : undefined - if ( - paneKey === physicalPaneKey && - reportedTabId !== undefined && - reportedTabId !== parsedPaneKey.tabId - ) { - return - } - const tabId = paneKey !== physicalPaneKey ? parsedPaneKey.tabId : reportedTabId - if (this.getAgentStatusDisposition(paneKey) !== 'accept') { - return - } - const worktreeId = - event.worktreeId !== undefined && event.worktreeId.trim().length > 0 - ? event.worktreeId.trim() - : undefined - const connectionId = - typeof event.connectionId === 'string' && event.connectionId.trim().length > 0 - ? event.connectionId.trim() - : null - const previous = this.state.lastStatusByPaneKey.get(paneKey) as - | EnrichedAgentHookEventPayload - | undefined - if ( - previous?.claudeLeadBoundaryChildOnly === true && - previous.payload.agentType === 'claude' && - event.payload.agentType === 'claude' - ) { - // Why: OSC has no child identity or lead boundary, so it cannot replace a persisted child-only proof before the lifecycle hook arrives. - return - } - const preserveActiveTurnStamp = - previous?.payload.turnCompletedAt !== undefined && - previous.payload.turnCompletedAt === this.activeHookTurnCompletedAtByPaneKey.get(paneKey) - if ( - !previous?.restoredUnconfirmed && - previous?.connectionId === connectionId && - previous.tabId === tabId && - previous.worktreeId === worktreeId && - terminalStatusPayloadMatchesHook(previous.payload, event.payload, preserveActiveTurnStamp) - ) { - return - } - // Why: the OSC 9999 wire payload has no providerSession field at all, so an OSC observation is - // never evidence that the session ended — yet overwriting the row dropped the cached identity. - // That erased it from persisted rows (lost across restart) and from headless `orca serve`, which - // serves these rows to mobile directly instead of the renderer store, blanking Chat UI (#10630). - // A new turn after `done` still starts clean so a reused pane cannot inherit a finished session. - // Why: mirror resolveAgentStatusIdentity, which treats a literal 'unknown' exactly like an - // omitted type — an OSC ping that names no agent makes no claim about the pane's identity, so - // it must not be read as a mismatch and strip the session the renderer would have kept. - const claimedAgentType = - event.payload.agentType && event.payload.agentType !== 'unknown' - ? event.payload.agentType - : undefined - const preservedProviderSession = - previous?.providerSession && - (claimedAgentType === undefined || claimedAgentType === previous.payload.agentType) && - (previous.payload.state !== 'done' || event.payload.state === 'done') - ? previous.providerSession - : undefined - // Why: OSC status is a runtime observation, not a prompt boundary; keep prompt-sent telemetry tied to native hooks. - this.applyNormalizedStatus( - { - paneKey, - tabId, - worktreeId, - connectionId, - ...(preservedProviderSession ? { providerSession: preservedProviderSession } : {}), - payload: event.payload - }, - undefined, - 'osc' - ) - } - - /** Ingest a payload from the relay JSON-RPC channel (not the local HTTP server); connectionId is stamped here. Main is still the SSH trust boundary, so re-run the canonical normalizer before caching. */ - ingestRemote( - envelope: { - paneKey: string - tabId?: string - worktreeId?: string - env?: string - version?: string - launchToken?: string - hasExplicitPrompt?: boolean - promptInteractionKey?: string - hookEventName?: string - source?: unknown - providerPromptId?: unknown - compactTrigger?: unknown - toolUseId?: string - toolAgentId?: string - teammateName?: string - toolAgentType?: string - providerSession?: unknown - providerSessionOnly?: unknown - isReplay?: boolean - /** Payload fields the relay dropped to fit an oversized frame; validated below. */ - shedFields?: unknown - claudeRunningNonAgentTask?: unknown - payload: unknown - }, - connectionId: string | null - ): void { - // Why: wire crosses a trust boundary — re-check/trim so an empty connectionId can't poison caches. - if (connectionId !== null && typeof connectionId !== 'string') { - return - } - const trimmedConnectionId = connectionId?.trim() ?? null - if (trimmedConnectionId !== null && trimmedConnectionId.length === 0) { - return - } - if (!envelope || typeof envelope.paneKey !== 'string') { - return - } - // Why: trim paneKey to match the HTTP path, else remote-vs-local events for one pane diverge. - const physicalPaneKey = envelope.paneKey.trim() - const paneKey = this.resolvePaneKeyAlias(physicalPaneKey) - const parsedPaneKey = parsePaneKey(paneKey) - if (paneKey.length === 0) { - track('agent_hook_unattributed', { reason: 'empty_pane_key' }) - return - } - if (paneKey.length > MAX_PANE_KEY_LEN) { - return - } - if (!parsedPaneKey) { - return - } - // Why: fence relay spool replay at main so stale generations cannot overwrite hydrated state. - if (envelope.isReplay === true) { - const expectedLaunchTokenHash = this.hydratedLaunchTokenHashByPaneKey.get(paneKey) - const actualLaunchTokenHash = launchTokenHash(envelope.launchToken) - if (expectedLaunchTokenHash && actualLaunchTokenHash !== expectedLaunchTokenHash) { - return - } - } - if (envelope.tabId !== undefined && typeof envelope.tabId !== 'string') { - return - } - if (envelope.worktreeId !== undefined && typeof envelope.worktreeId !== 'string') { - return - } - // Why: mirror the HTTP path's readStringField — trim and treat empty-after-trim as undefined. - const reportedTabId = - envelope.tabId !== undefined && envelope.tabId.trim().length > 0 - ? envelope.tabId.trim() - : undefined - if ( - paneKey === physicalPaneKey && - reportedTabId !== undefined && - reportedTabId !== parsedPaneKey.tabId - ) { - return - } - const tabId = paneKey !== physicalPaneKey ? parsedPaneKey.tabId : reportedTabId - const hookEventName = - typeof envelope.hookEventName === 'string' && envelope.hookEventName.trim().length > 0 - ? envelope.hookEventName.trim() - : undefined - const source = isAgentHookSource(envelope.source) ? envelope.source : undefined - const providerPromptId = - source === 'claude' ? normalizeClaudePromptId(envelope.providerPromptId) : undefined - const compactTrigger = - source === 'claude' && - (envelope.compactTrigger === 'manual' || envelope.compactTrigger === 'auto') - ? envelope.compactTrigger - : undefined - const statusDisposition = this.getAgentStatusDisposition(paneKey, { - source, - rawSource: envelope.source, - hookEventName, - isReplay: envelope.isReplay === true, - hasExplicitPrompt: envelope.hasExplicitPrompt === true, - launchToken: envelope.launchToken - }) - if (statusDisposition === 'suppress') { - return - } - if (statusDisposition === 'restart') { - // Why: same rebind as the HTTP path — a retired pane taking a new turn is a new session. - // Why paneKey, not envelope.paneKey: alias resolution already mapped it to the - // stable pane, so the rebind cannot land on a legacy key. - this.observations.rebind(paneKey) - } - const worktreeId = - envelope.worktreeId !== undefined && envelope.worktreeId.trim().length > 0 - ? envelope.worktreeId.trim() - : undefined - const promptInteractionKey = - typeof envelope.promptInteractionKey === 'string' && - envelope.promptInteractionKey.trim().length > 0 - ? envelope.promptInteractionKey.trim() - : undefined - const toolUseId = - typeof envelope.toolUseId === 'string' && envelope.toolUseId.trim().length > 0 - ? envelope.toolUseId.trim() - : undefined - const toolAgentId = - typeof envelope.toolAgentId === 'string' && envelope.toolAgentId.trim().length > 0 - ? envelope.toolAgentId.trim() - : undefined - const teammateName = - typeof envelope.teammateName === 'string' && envelope.teammateName.trim().length > 0 - ? envelope.teammateName.trim() - : undefined - const toolAgentType = - typeof envelope.toolAgentType === 'string' && envelope.toolAgentType.trim().length > 0 - ? envelope.toolAgentType.trim() - : undefined - const providerSession = normalizeAgentProviderSession(envelope.providerSession) ?? undefined - // Why: relay crosses a trust boundary — re-run the canonical normalizer to enforce caps/invariants (returns null on malformed). - const validatedPayload = normalizeAgentStatusPayload(envelope.payload) - if (!validatedPayload) { - return - } - // Why: restore a shed roster only when its digest and turn identity still match the cache. - let normalizedPayload = restoreShedStatusFields( - validatedPayload, - envelope.shedFields, - this.state.lastStatusByPaneKey.get(paneKey)?.payload - ) - const previousStatus = this.state.lastStatusByPaneKey.get(paneKey) - let acceptedCompactCompletion = false - if (hookEventName === 'PreCompact' || hookEventName === 'PostCompact') { - // Why: PreCompact is never registered and proves nothing (an aborted compact emits it alone); - // reject it here too so a host on any version cannot drive pane state from it. - if (hookEventName === 'PreCompact' || source !== 'claude') { - return - } - // Why: a relay predating this change strips `compactTrigger` from its cached PostCompact - // before replaying it, so the replay has no manual/auto discriminator. That relay's mapping is - // fixed and known — manual produced `done`, auto produced `working` — so the payload state - // stands in for the missing trigger. Trigger substitution only; ownership is still checked. - const effectiveTrigger = resolveLegacyCompactTrigger(compactTrigger, normalizedPayload.state) - // Why: an auto compact happens inside a turn that resumes and emits its own Stop. An older - // relay maps it to `working`, and this ingest applies the relay's payload verbatim — so - // without this drop, every auto compact on such a host mints exactly the stuck `working` this - // change removes. - if (effectiveTrigger !== 'manual' || normalizedPayload.agentType !== source) { - return - } - if ( - isClaudeCompactCompletionConsumed( - this.state.claudeConsumedCompactPromptIdByPaneKey, - paneKey, - providerPromptId - ) || - !canAcceptClaudeCompactCompletion(previousStatus, { - source, - connectionId: trimmedConnectionId, - providerPromptId, - providerSession - }) - ) { - return - } - markClaudeCompactCompletionConsumed( - this.state.claudeConsumedCompactPromptIdByPaneKey, - paneKey, - providerPromptId - ) - // Why: an older relay built this payload before the boundary flag existed, so it arrives as a - // plain `done` — which every completion-reactive consumer reads as a finished turn. Stamp the - // boundary here so a compact stays silent regardless of which relay normalized it. - if (normalizedPayload.sessionBoundary !== true) { - normalizedPayload = { ...normalizedPayload, sessionBoundary: true } - } - acceptedCompactCompletion = true - } - // Why: keyed on "did we accept a completion", not on the trigger surviving the wire — the - // trigger-stripped replay is exactly the shape that arrives without one, and it is still the - // compact's own promptless event, so it still needs the summarized turn's label. - if ( - source === 'claude' && - (compactTrigger !== undefined || acceptedCompactCompletion) && - normalizedPayload.prompt.length === 0 && - previousStatus?.payload.prompt - ) { - normalizedPayload = { ...normalizedPayload, prompt: previousStatus.payload.prompt } - } - if ( - envelope.providerSessionOnly === true && - !isValidPiProviderSessionOnly(providerSession, normalizedPayload.agentType) - ) { - return - } - const applyClaudeBackgroundWork = - normalizedPayload.agentType === 'claude' && - typeof envelope.claudeRunningNonAgentTask === 'boolean' && - // Why: reconnect replay may seed a restarted listener, but cannot override any observation made by this runtime. - (envelope.isReplay !== true || !this.runtimeObservedStatusPaneKeys.has(paneKey)) - // Why: run the HTTP path's warn-once version/env-mismatch diagnostics with this.env as expected. - warnOnHookEnvOrVersionMismatch(this.state, { - version: envelope.version, - env: envelope.env, - expectedEnv: this.env - }) - const event: AgentHookEventPayload = { - paneKey, - source, - launchToken: statusDisposition === 'restart' ? undefined : envelope.launchToken, - tabId, - worktreeId, - connectionId: trimmedConnectionId, - hasExplicitPrompt: envelope.hasExplicitPrompt === true ? true : undefined, - promptInteractionKey, - hookEventName, - providerPromptId, - compactTrigger, - toolUseId, - toolAgentId, - teammateName, - toolAgentType, - providerSession, - providerSessionOnly: envelope.providerSessionOnly === true ? true : undefined, - isReplay: envelope.isReplay === true ? true : undefined, - claudeRunningNonAgentTask: - typeof envelope.claudeRunningNonAgentTask === 'boolean' - ? envelope.claudeRunningNonAgentTask - : undefined, - payload: normalizedPayload - } - this.recordCurrentAuthorityObservation(event) - this.applyNormalizedStatus( - event, - applyClaudeBackgroundWork - ? () => { - if (envelope.claudeRunningNonAgentTask) { - this.state.claudeRunningNonAgentTaskPaneKeys.add(paneKey) - } else { - this.state.claudeRunningNonAgentTaskPaneKeys.delete(paneKey) - } - } - : undefined - ) - } - - async start(options?: { - env?: string - userDataPath?: string - endpointNamespace?: string - }): Promise { - if (this.server) { - return - } - - if (options?.env) { - this.env = options.env - } - if (options?.userDataPath) { - // Why: dev builds share one userData path; namespace per instance while packaged keeps the stable path for PTY reconnect. - this.endpointDir = options.endpointNamespace - ? join(options.userDataPath, 'agent-hooks', options.endpointNamespace) - : join(options.userDataPath, 'agent-hooks') - this.endpointFilePathCache = join(this.endpointDir, getEndpointFileName()) - this.lastStatusFilePath = join(this.endpointDir, LAST_STATUS_FILE_NAME) - } - this.token = randomUUID() - this.endpointFileWritten = false - this.lastWrittenJson = null - // Why: hydrate before binding the listener so an early hook POST runs against a populated map. - if (this.lastStatusFilePath) { - this.hydrateLastStatusFromDisk() - } - this.captureHydratedAuthorityCommitments() - // Drain before binding the listener so replay cannot race a live hook during startup. - if (this.endpointDir) { - drainAgentHookSpool({ - endpointDir: this.endpointDir, - getPersistedLaunchTokenHash: (paneKey) => - this.hydratedLaunchTokenHashByPaneKey.get(this.resolvePaneKeyAlias(paneKey)), - ingest: (record: SpoolRecord) => this.ingestSpoolRecord(record) - }) - } - const handleRequest = async (req: IncomingMessage, res: ServerResponse): Promise => { - if (req.method !== 'POST') { - res.writeHead(404) - res.end() - return - } - - if (req.headers['x-orca-agent-hook-token'] !== this.token) { - res.writeHead(403) - res.end() - return - } - - // Why: bound request time so a stalled client can't hold a socket open (slowloris). - // Why: track our own destroy so the slowloris cap can't be misread as outside interference. - let destroyedBySlowlorisCap = false - req.setTimeout(HOOK_REQUEST_SLOWLORIS_MS, () => { - destroyedBySlowlorisCap = true - req.destroy() - }) - - const pathname = new URL(req.url ?? '/', 'http://127.0.0.1').pathname - try { - const body = await readRequestBody(req) - if (pathname === CLAUDE_STATUSLINE_PATHNAME) { - const statusLineEvent = parseClaudeStatusLineBody(body) - if (statusLineEvent) { - this.onClaudeStatusLine?.(statusLineEvent) - } - res.writeHead(204) - res.end() - return - } - const source = resolveHookSource(pathname) - if (!source) { - res.writeHead(404) - res.end() - return - } - - const hookBody = mergeAgentHookRequestHeaders(body, req.headers) - trackEmptyPaneKeyHook(hookBody) - const aliasedBody = this.normalizeHookBodyPaneKeyAlias(hookBody) - const normalized = this.normalizeLocalHookPayload(source, aliasedBody) - const statusDisposition = normalized.event - ? this.getAgentStatusDisposition(normalized.event.paneKey, { - source, - hookEventName: normalized.event.hookEventName, - isReplay: normalized.event.isReplay, - hasExplicitPrompt: normalized.event.hasExplicitPrompt, - launchToken: normalized.event.launchToken - }) - : 'suppress' - if (normalized.event && statusDisposition !== 'suppress') { - const event = - statusDisposition === 'restart' - ? { ...normalized.event, launchToken: undefined } - : normalized.event - if (statusDisposition === 'restart') { - // Why: a retired pane accepting a new turn is a different agent session behind the - // same key — later observations must not be ordered against the retired one. - this.observations.rebind(event.paneKey) - } - this.recordCurrentAuthorityObservation(event) - const enriched = this.applyNormalizedStatus(event, normalized.onAccepted) - this.scheduleAssistantMessageRetry(source, aliasedBody, enriched) - this.scheduleCodexSubagentPoll(source, aliasedBody, enriched) - } - - res.writeHead(204) - res.end() - } catch (error) { - // Why (#11217): an authenticated POST whose body dies short of its own Content-Length was cut - // by something on the loopback path, not by a bad payload. Fail open as before, but count it — - // this is the one failure mode that silently stops status for every runtime at once. - if (isHookRequestTruncatedError(error) && !destroyedBySlowlorisCap) { - this.transportInterference.record({ source: resolveHookSource(pathname) ?? null, error }) - } - // Why: fail open — return success on malformed payloads so a broken hook never blocks the agent. - res.writeHead(204) - res.end() - } - } - // Why: node ignores a returned promise, so the handler must settle it itself; handleRequest never rejects. - this.server = createServer((req, res) => { - void handleRequest(req, res) - }) - - await new Promise((resolve, reject) => { - // Why: swap the startup reject-handler for a logging one so a later runtime 'error' can't crash main as an unhandled event. - const onStartupError = (err: Error): void => { - this.server?.off('listening', onListening) - reject(err) - } - const onListening = (): void => { - this.server?.off('error', onStartupError) - this.server?.on('error', (err) => { - console.error('[agent-hooks] server error', err) - }) - const address = this.server!.address() - if (address && typeof address === 'object') { - this.port = address.port - } - this.maybeWriteEndpointFile() - resolve() - } - this.server!.once('error', onStartupError) - this.server!.listen(0, '127.0.0.1', onListening) - }) - } - - stop(): void { - // Why: flush the pending debounced write before clearing the map, else a hook <250ms before quit is lost on relaunch. - this.flushStatusPersistSync() - this.server?.close() - this.server = null - this.port = 0 - this.token = '' - this.env = 'production' - this.onAgentStatus = null - this.onPaneStatusCleared = null - for (const timer of this.assistantMessageRetryTimers.values()) { - clearTimeout(timer) - } - this.assistantMessageRetryTimers.clear() - this.codexSubagentPollScheduler.clearAll() - // Why: don't unlink the endpoint file — a stale file matches fail-open and avoids a TOCTOU race with a concurrent Orca. - this.endpointDir = null - this.endpointFilePathCache = null - this.endpointFileWritten = false - this.lastStatusFilePath = null - this.lastWrittenJson = null - this.runtimeObservedStatusPaneKeys.clear() - this.hydratedAuthorityCommitments = Object.freeze([]) - this.hydratedLaunchTokenHashByPaneKey.clear() - this.persistedAuthorityCommitmentsByPaneKey.clear() - this.revokedHydratedAuthorityCommitments = new WeakSet() - this.currentAuthorityObservations.clear() - this.promptSentDedupeByPaneKey.clear() - this.closedAgentStatusTabIds.clear() - this.closedAgentStatusPaneKeys.clear() - this.restartedStatusLaunchTokenHashByPaneKey.clear() - this.retiredPaneFencesByKey.clear() - this.connectionTimestampWatermarkById.clear() - this.legacyPaneKeyAliases.clear() - clearAllListenerCaches(this.state) - this.notifyStatusChangeListeners() - } - - /** The resume-identity remnant of a dropped row: a `providerSessionOnly` entry carries no state - * claim — it cannot gate a pane `working` — so it survives teardowns that end the pane's live - * claims. Returns null when the row has no resumable session to keep. */ - private toRetainedProviderSessionRow( - entry: EnrichedAgentHookEventPayload | null | undefined - ): EnrichedAgentHookEventPayload | null { - if ( - !entry?.providerSession || - !entry.payload.agentType || - entry.payload.agentType === 'unknown' - ) { - return null - } - const { launchToken: _launchToken, ...resumeIdentity } = entry - return { ...resumeIdentity, providerSessionOnly: true, retainedForLiveness: true } - } - - /** Drop only the status row (user dismissal); do NOT wipe prompt/tool caches since the pane's agent may still be alive. Use clearPaneState for PTY-teardown. */ - dropStatusEntry(paneKey: string): void { - const deleted = this.deleteStatusEntry(paneKey, { preserveAuthority: true }) - if (!deleted) { - return - } - const retained = this.toRetainedProviderSessionRow(deleted) - if (retained) { - this.state.lastStatusByPaneKey.set(deleted.paneKey, retained) - } - this.scheduleStatusPersist() - this.notifyStatusChangeListeners() - this.emitStatusDropped(deleted.paneKey) - } - - /** Retire panes whose owning process is certifiably dead. - * - * The ordinary teardown already does this: every attributable PTY exit reaches - * `clearProviderPtyState`, which resolves the pane key and calls `clearPaneState`. But that - * resolution depends on the spawn-time `ptyPaneKey` mapping, which a restored/reattached PTY may - * never rebuild — so those panes keep a `working` row and its latches for good, with no hook left - * to retire them. This is the same operation reached from the runtime's own pane-key knowledge, - * so a dead pane is cleaned up identically however its keys were resolved. */ - reconcileEndedProcessForPaneKeys( - paneKeys: Iterable, - options?: { - /** The pane's PTY outlived its agent (a confirmed shell foreground), so the session can still - * be resumed in place — keep the `providerSessionOnly` remnant the paired `agentStatus:drop` - * minted for exactly this case. A certified PTY exit passes nothing: there is no pane left to - * resume into, and dropping it matches what `clearProviderPtyState` already does. */ - preserveResumeIdentity?: boolean - } - ): number { - let cleared = 0 - for (const paneKey of paneKeys) { - const resolvedPaneKey = this.resolvePaneKeyAlias(paneKey) - if (!this.hasLiveClaimsForPaneKey(resolvedPaneKey)) { - continue - } - const retained = options?.preserveResumeIdentity - ? this.toRetainedProviderSessionRow( - this.state.lastStatusByPaneKey.get(resolvedPaneKey) as - | EnrichedAgentHookEventPayload - | undefined - ) - : null - this.clearPaneState(resolvedPaneKey) - if (retained) { - this.state.lastStatusByPaneKey.set(resolvedPaneKey, retained) - this.scheduleStatusPersist() - this.notifyStatusChangeListeners() - } - cleared += 1 - } - return cleared - } - - /** Anything a dead pane could still be asserting: a row, or a latch that would re-gate one through - * `resolveClaudePaneState` on the pane's next event even after the row reads `done`. The list - * itself lives beside `clearPaneCacheState`, so adding a latch cannot leave this behind in a - * different file. */ - private hasLiveClaimsForPaneKey(paneKey: string): boolean { - return paneHasStateClaims(this.state, paneKey) - } - - /** Clear statuses proven to belong to one lost SSH transport. */ - clearStatusEntriesForConnection(connectionId: string): void { - const normalizedConnectionId = connectionId.trim() - if (normalizedConnectionId.length === 0) { - return - } - const clearedAt = Math.max( - Date.now(), - (this.connectionTimestampWatermarkById.get(normalizedConnectionId) ?? -1) + 1 - ) - this.connectionTimestampWatermarkById.set(normalizedConnectionId, clearedAt) - let statusChanged = false - for (const [paneKey, rawEntry] of this.state.lastStatusByPaneKey) { - const entry = rawEntry as EnrichedAgentHookEventPayload - // Why: unstamped rows can't be attributed to one host; leave them for normal pane teardown. - if (entry.connectionId !== normalizedConnectionId) { - continue - } - const deleted = this.deleteStatusEntry(paneKey, { preserveAuthority: true }) - if (deleted) { - statusChanged = true - if (deleted.payload.agentType === 'codex') { - // Why: a replacement remote process may reuse the pane; don't merge it with the lost connection's children. - this.state.codexSubagentRosterByPaneKey.delete(paneKey) - this.state.codexLeadStateByPaneKey.delete(paneKey) - } else if (deleted.payload.agentType === 'claude') { - this.state.claudeSubagentRosterByPaneKey.delete(paneKey) - this.state.claudeLeadStateByPaneKey.delete(paneKey) - this.state.claudeRunningNonAgentTaskPaneKeys.delete(paneKey) - this.state.claudeActiveSessionCronPaneKeys.delete(paneKey) - this.state.claudeSessionOwnerByPaneKey.delete(paneKey) - } - } - } - for (const [paneKey, evidence] of this.currentAuthorityObservations) { - if (evidence.connectionId === normalizedConnectionId) { - this.currentAuthorityObservations.delete(paneKey) - } - } - if (statusChanged) { - // Why: persist/notify once — one disconnect can own many panes. - this.scheduleStatusPersist() - this.notifyStatusChangeListeners() - } - // Why: always send the cutoff even with no matched entry — another host may have overwritten this pane's row. - this.emitPaneStatusCleared({ - transient: true, - connectionId: normalizedConnectionId, - clearedAt - }) - } - - private deleteStatusEntry( - paneKey: string, - options?: { preserveAuthority?: boolean } - ): EnrichedAgentHookEventPayload | null { - const resolvedPaneKey = this.resolvePaneKeyAlias(paneKey) - const existing = this.state.lastStatusByPaneKey.get(resolvedPaneKey) as - | EnrichedAgentHookEventPayload - | undefined - if (!existing) { - return null - } - this.state.lastStatusByPaneKey.delete(resolvedPaneKey) - this.activeHookTurnCompletedAtByPaneKey.delete(resolvedPaneKey) - if (!options?.preserveAuthority) { - this.hydratedLaunchTokenHashByPaneKey.delete(resolvedPaneKey) - this.persistedAuthorityCommitmentsByPaneKey.delete(resolvedPaneKey) - } - this.clearAssistantMessageRetry(resolvedPaneKey) - this.clearCodexSubagentPoll(resolvedPaneKey) - this.runtimeObservedStatusPaneKeys.delete(resolvedPaneKey) - this.currentAuthorityObservations.delete(resolvedPaneKey) - if (existing.payload.state === 'done') { - this.promptSentDedupeByPaneKey.delete(resolvedPaneKey) - } - return existing - } - - dropStatusEntriesByTabPrefix(tabId: string): void { - this.markTabClosedForAgentStatus(tabId) - const paneKeysToClear = new Set() - for (const key of this.state.lastStatusByPaneKey.keys()) { - if (paneCacheKeyMatchesTab(key, tabId)) { - paneKeysToClear.add(key) - } - } - for (const key of this.state.lastPromptByPaneKey.keys()) { - if (paneCacheKeyMatchesTab(key, tabId)) { - paneKeysToClear.add(key.split('\0', 1)[0] ?? key) - } - } - for (const key of this.state.lastToolByPaneKey.keys()) { - if (paneCacheKeyMatchesTab(key, tabId)) { - paneKeysToClear.add(key.split('\0', 1)[0] ?? key) - } - } - for (const key of this.state.antigravityCompletedTranscriptByPaneKey.keys()) { - if (paneCacheKeyMatchesTab(key, tabId)) { - paneKeysToClear.add(key.split('\0', 1)[0] ?? key) - } - } - for (const key of this.state.ampCompletedCacheKeys) { - if (paneCacheKeyMatchesTab(key, tabId)) { - paneKeysToClear.add(key.split('\0', 1)[0] ?? key) - } - } - for (const paneKey of this.runtimeObservedStatusPaneKeys) { - if (paneCacheKeyMatchesTab(paneKey, tabId)) { - paneKeysToClear.add(paneKey) - } - } - for (const paneKey of this.promptSentDedupeByPaneKey.keys()) { - if (paneCacheKeyMatchesTab(paneKey, tabId)) { - paneKeysToClear.add(paneKey) - } - } - for (const commitment of this.hydratedAuthorityCommitments) { - if (paneCacheKeyMatchesTab(commitment.paneKey, tabId)) { - paneKeysToClear.add(commitment.paneKey) - } - } - - let aliasChanged = false - for (const [legacyPaneKey, entry] of this.legacyPaneKeyAliases) { - const ownerMatches = paneCacheKeyMatchesTab(entry.stablePaneKey, tabId) - if (ownerMatches) { - this.legacyPaneKeyAliases.delete(legacyPaneKey) - paneKeysToClear.add(legacyPaneKey) - paneKeysToClear.add(entry.stablePaneKey) - this.markPaneClosedForAgentStatus(legacyPaneKey) - this.markPaneClosedForAgentStatus(entry.stablePaneKey) - aliasChanged = true - } - } - const authorityChanged = this.revokeHydratedAuthorityForPaneKeys(paneKeysToClear) - - let statusChanged = false - for (const paneKey of paneKeysToClear) { - if (this.state.lastStatusByPaneKey.has(paneKey)) { - statusChanged = true - } - this.clearAssistantMessageRetry(paneKey) - this.clearCodexSubagentPoll(paneKey) - clearPaneCacheState(this.state, paneKey) - this.activeHookTurnCompletedAtByPaneKey.delete(paneKey) - this.runtimeObservedStatusPaneKeys.delete(paneKey) - this.currentAuthorityObservations.delete(paneKey) - this.promptSentDedupeByPaneKey.delete(paneKey) - this.restartedStatusLaunchTokenHashByPaneKey.delete(paneKey) - } - if (aliasChanged) { - this.notifyPaneKeyAliasPersistenceListener() - } - if (statusChanged || authorityChanged) { - this.scheduleStatusPersist() - this.notifyStatusChangeListeners() - } - } - - clearPaneState(paneKey: string): void { - const resolvedPaneKey = this.resolvePaneKeyAlias(paneKey) - const paneKeys = new Set([paneKey, resolvedPaneKey]) - // Why: only persist when a status entry was actually evicted; dropping prompt/tool caches doesn't change the file. - const hadStatus = this.state.lastStatusByPaneKey.has(resolvedPaneKey) - this.clearAssistantMessageRetry(resolvedPaneKey) - this.clearCodexSubagentPoll(resolvedPaneKey) - clearPaneCacheState(this.state, resolvedPaneKey) - this.activeHookTurnCompletedAtByPaneKey.delete(resolvedPaneKey) - this.currentAuthorityObservations.delete(resolvedPaneKey) - this.promptSentDedupeByPaneKey.delete(resolvedPaneKey) - this.restartedStatusLaunchTokenHashByPaneKey.delete(resolvedPaneKey) - let clearedAlias = false - for (const [legacyPaneKey, stablePaneKey] of this.legacyPaneKeyAliases) { - if (stablePaneKey.stablePaneKey === resolvedPaneKey) { - this.legacyPaneKeyAliases.delete(legacyPaneKey) - paneKeys.add(legacyPaneKey) - paneKeys.add(stablePaneKey.stablePaneKey) - clearPaneCacheState(this.state, legacyPaneKey) - this.activeHookTurnCompletedAtByPaneKey.delete(legacyPaneKey) - this.currentAuthorityObservations.delete(legacyPaneKey) - this.promptSentDedupeByPaneKey.delete(legacyPaneKey) - this.restartedStatusLaunchTokenHashByPaneKey.delete(legacyPaneKey) - clearedAlias = true - } - } - const authorityChanged = this.revokeHydratedAuthorityForPaneKeys(paneKeys) - if (clearedAlias) { - this.notifyPaneKeyAliasPersistenceListener() - } - if (hadStatus || authorityChanged) { - this.runtimeObservedStatusPaneKeys.delete(resolvedPaneKey) - this.scheduleStatusPersist() - this.notifyStatusChangeListeners() - this.emitPaneStatusCleared({ paneKey: resolvedPaneKey }) - } - } - - /** Second reap path for restored Claude subagent rows: drop the ones whose pane - * has no live local agent process behind it any more. A PTY that dies while Orca - * is down never runs the teardown that clears pane state, so hydrate rebuilds a - * roster nothing can ever retire — the inventory reap needs the parent to emit a - * complete `background_tasks` list and an idle parent never does. The row then - * gates the pane 'working' for the rest of its life and hibernation, which - * requires 'done', can never reclaim the agent's heap. - * - * Both the execution host and relay binding must prove local ownership before - * targeted PTY liveness is consulted. Panes that reported in this runtime are - * also skipped. Returns the number of panes changed. */ - async reapRestoredClaudeSubagentsWithoutLiveAgent( - isLocalExecutionHost: (worktreeId: string | undefined) => boolean, - isLocalPaneAgentLive: (paneKey: string) => Promise, - isLocalPaneLivenessEvidenceCurrent: (paneKey: string) => boolean - ): Promise { - const candidates: { paneKey: string; entry: EnrichedAgentHookEventPayload }[] = [] - for (const [paneKey, entry] of this.state.lastStatusByPaneKey) { - const enriched = entry as EnrichedAgentHookEventPayload - if ( - enriched.payload.agentType === 'claude' && - enriched.connectionId === null && - isLocalExecutionHost(enriched.worktreeId) && - // Why: a restored roster is only one shape of stranded claim. A lead row left non-terminal, - // or a background-task/cron latch nothing will refresh, strands the pane just as - // permanently — and unlike the roster case there is no child event left to reap it. - (claudeRosterHasRestoredSnapshotSubagent( - this.state.claudeSubagentRosterByPaneKey.get(paneKey) - ) || - enriched.payload.state !== 'done' || - this.state.claudeRunningNonAgentTaskPaneKeys.has(paneKey) || - this.state.claudeActiveSessionCronPaneKeys.has(paneKey)) && - !this.runtimeObservedStatusPaneKeys.has(paneKey) - ) { - candidates.push({ paneKey, entry: enriched }) - } - } - const liveness = await Promise.all( - candidates.map(async (candidate) => { - try { - return await isLocalPaneAgentLive(candidate.paneKey) - } catch { - return true - } - }) - ) - let changedPanes = 0 - for (const [index, candidate] of candidates.entries()) { - const { paneKey, entry: enriched } = candidate - if ( - liveness[index] || - !isLocalPaneLivenessEvidenceCurrent(paneKey) || - this.state.lastStatusByPaneKey.get(paneKey) !== enriched || - this.runtimeObservedStatusPaneKeys.has(paneKey) || - !isLocalExecutionHost(enriched.worktreeId) - ) { - continue - } - if (!reapRestoredClaudeSubagentsForDeadPane(this.state, paneKey)) { - // Why: the roster reap only speaks for restored child rows. A pane whose PTY is provably - // gone and whose claim is a lead row or a latch has nothing for it to reap, so retire the - // pane the same way an observed exit would — otherwise the widened candidate set is inert. - // - // Why delete rather than downgrade to `done` like the reap branch below: that branch has a - // real turn to describe — a parent whose children it just reaped — while these panes' only - // claim IS the stale non-terminal row. Rewriting a `waiting`/`blocked` row to `done` would - // invent a completion that never happened, and leaving it non-terminal keeps the bug. This - // sweep stands in for the exit Orca never observed, so it does what that exit does: - // `clearProviderPtyState` -> `clearPaneState`. - if (this.hasLiveClaimsForPaneKey(paneKey)) { - this.clearPaneState(paneKey) - changedPanes += 1 - } - continue - } - changedPanes += 1 - const roster = this.state.claudeSubagentRosterByPaneKey.get(paneKey) - const subagents = claudeRosterToSnapshots(roster) - // Why: the pane's persisted 'working' was the child gate holding a finished - // lead open (subagent events never set lead state). With the last working row - // gone and no process left to report, 'done' is the only truthful state — and - // the one hibernation needs once this pane's agent is restored. - const state = - enriched.payload.state === 'working' && !claudeRosterHasWorkingSubagent(roster) - ? 'done' - : enriched.payload.state - const stateChanged = state !== enriched.payload.state - const reconciledAt = stateChanged - ? Math.max(Date.now(), enriched.receivedAt + 1) - : enriched.receivedAt - // Why: a reconciled `done` is process-probe-verified, not hydrated guesswork — carrying - // restoredUnconfirmed onto it would make freshness gates suppress a legitimate completion. - const { restoredUnconfirmed, ...reconciledBase } = enriched - const reconciled: EnrichedAgentHookEventPayload = { - ...reconciledBase, - ...(state !== 'done' && restoredUnconfirmed ? { restoredUnconfirmed: true } : {}), - receivedAt: reconciledAt, - stateStartedAt: stateChanged ? reconciledAt : enriched.stateStartedAt, - payload: { - ...enriched.payload, - state, - workingMode: state === 'working' ? enriched.payload.workingMode : undefined, - subagents - } - } - this.state.lastStatusByPaneKey.set(paneKey, reconciled) - } - if (changedPanes > 0) { - this.scheduleStatusPersist() - this.notifyStatusChangeListeners() - } - return changedPanes - } - - buildPtyEnv(): Record { - if (this.port <= 0 || !this.token) { - return {} - } - - const env: Record = { - ORCA_AGENT_HOOK_PORT: String(this.port), - ORCA_AGENT_HOOK_TOKEN: this.token, - ORCA_AGENT_HOOK_ENV: this.env, - ORCA_AGENT_HOOK_VERSION: ORCA_HOOK_PROTOCOL_VERSION, - ORCA_AGENT_HOOK_TRANSPORT: ORCA_HOOK_RAW_JSON_TRANSPORT - } - // Why: hooks source this file at invocation; dev namespaces it so parallel `pnpm dev` runs don't steal each other's hooks. - if (this.endpointFileWritten && this.endpointFilePathCache) { - env.ORCA_AGENT_HOOK_ENDPOINT = this.endpointFilePathCache - } - return env - } - - get endpointFilePath(): string | null { - return this.endpointFilePathCache - } - - /** Test/diagnostic accessor for the on-disk last-status file path. */ - get lastStatusPath(): string | null { - return this.lastStatusFilePath - } - - private maybeWriteEndpointFile(): void { - if (!this.endpointDir || !this.endpointFilePathCache) { - return - } - this.endpointFileWritten = false - const ok = writeEndpointFile(this.endpointDir, this.endpointFilePathCache, { - port: this.port, - token: this.token, - env: this.env, - version: ORCA_HOOK_PROTOCOL_VERSION, - transport: ORCA_HOOK_RAW_JSON_TRANSPORT - }) - this.endpointFileWritten = ok - } - - private hydrateLastStatusFromDisk(): void { - if (!this.lastStatusFilePath) { - return - } - // Why: keep hydrate idempotent so a future re-start path can't merge prior-session state. - this.state.lastStatusByPaneKey.clear() - this.hydratedLaunchTokenHashByPaneKey.clear() - this.persistedAuthorityCommitmentsByPaneKey.clear() - let raw: string - try { - raw = readFileSync(this.lastStatusFilePath, 'utf8') - } catch (err) { - // Why: missing file is normal (first launch); other errors degrade to empty hydration + one warn. - if ((err as NodeJS.ErrnoException).code !== 'ENOENT') { - console.warn('[agent-hooks] failed to read last-status file:', err) - } - return - } - let parsed: unknown - try { - parsed = JSON.parse(raw) - } catch { - console.warn('[agent-hooks] last-status file is not valid JSON; ignoring') - return - } - if (typeof parsed !== 'object' || parsed === null) { - console.warn('[agent-hooks] last-status file is not an object; ignoring') - return - } - const file = parsed as Partial - if (file.version !== LAST_STATUS_FILE_VERSION) { - console.warn( - `[agent-hooks] last-status file version mismatch (${String( - file.version - )} != ${LAST_STATUS_FILE_VERSION}); ignoring` - ) - return - } - const entries = file.entries - if (typeof entries !== 'object' || entries === null) { - console.warn('[agent-hooks] last-status file entries missing or wrong shape; ignoring') - return - } - let hydrated = 0 - let dropped = 0 - let prunedLegacyClaudeSubagents = 0 - let scrubbedLegacyLaunchTokens = 0 - // Why: drop entries older than HYDRATE_MAX_AGE_MS to bound disk growth (one Date.now() for a consistent cutoff). - const ttlCutoff = Date.now() - HYDRATE_MAX_AGE_MS - for (const [paneKey, rawEntry] of Object.entries(entries)) { - const resolvedPaneKey = this.resolvePaneKeyAlias(paneKey) - const rawResolvedEntry = - resolvedPaneKey === paneKey || typeof rawEntry !== 'object' || rawEntry === null - ? rawEntry - : { ...(rawEntry as Record), paneKey: resolvedPaneKey } - const entry = sanitizeHydratedEntry(resolvedPaneKey, rawResolvedEntry) - if (entry && entry.receivedAt >= ttlCutoff) { - const launchTokenHash = readPersistedLaunchTokenHash(rawResolvedEntry) - if (launchTokenHash) { - this.hydratedLaunchTokenHashByPaneKey.set(resolvedPaneKey, launchTokenHash) - const evidence = this.toAuthorityEvidence(entry, launchTokenHash) - if (evidence) { - this.persistedAuthorityCommitmentsByPaneKey.set(resolvedPaneKey, evidence) - } - } - if ( - typeof rawResolvedEntry === 'object' && - rawResolvedEntry !== null && - typeof (rawResolvedEntry as Record).launchToken === 'string' - ) { - scrubbedLegacyLaunchTokens += 1 - } - const hydratedPayload = dropHydratedIdleClaudeSubagents(entry.payload) - if (hydratedPayload !== entry.payload) { - prunedLegacyClaudeSubagents += - (entry.payload.subagents?.length ?? 0) - (hydratedPayload.subagents?.length ?? 0) - entry.payload = hydratedPayload - } - if (entry.payload.state !== 'done') { - // Why: the terminal transition may have fired while no receiver was up; restore as unconfirmed, never as live truth. - entry.restoredUnconfirmed = true - } - this.state.lastStatusByPaneKey.set(resolvedPaneKey, entry) - if (entry.connectionId) { - // Why: a restart can see an earlier wall clock; seed ordering so new events stay after disk state. - const previousWatermark = this.connectionTimestampWatermarkById.get(entry.connectionId) - this.connectionTimestampWatermarkById.set( - entry.connectionId, - Math.max(previousWatermark ?? -1, entry.receivedAt) - ) - } - // Why: restore live child hierarchy immediately; provider-specific reconciliation reaps stale seeds. - if (entry.payload.agentType === 'codex') { - seedCodexStateFromSnapshot(this.state, resolvedPaneKey, entry.payload) - } else if (entry.payload.agentType === 'claude') { - seedClaudeLeadTurnFromPersistedStatus(this.state, resolvedPaneKey, entry, { - childOnlyBoundary: entry.claudeLeadBoundaryChildOnly === true - }) - if (entry.payload.subagents) { - seedClaudeSubagentRosterFromSnapshots( - this.state, - resolvedPaneKey, - entry.payload.subagents - ) - } - } - hydrated += 1 - } else { - dropped += 1 - } - } - for (const [paneKey, rawCommitment] of Object.entries(file.authorityCommitments ?? {})) { - const resolvedPaneKey = this.resolvePaneKeyAlias(paneKey) - const commitment = sanitizePersistedAuthorityCommitment(resolvedPaneKey, rawCommitment) - if (!commitment || commitment.observedAt < ttlCutoff) { - dropped += 1 - continue - } - const existing = this.persistedAuthorityCommitmentsByPaneKey.get(resolvedPaneKey) - if (existing && !authorityCommitmentsMatch(existing, commitment)) { - this.persistedAuthorityCommitmentsByPaneKey.delete(resolvedPaneKey) - this.hydratedLaunchTokenHashByPaneKey.delete(resolvedPaneKey) - dropped += 1 - continue - } - this.persistedAuthorityCommitmentsByPaneKey.set(resolvedPaneKey, commitment) - this.hydratedLaunchTokenHashByPaneKey.set(resolvedPaneKey, commitment.launchTokenHash) - } - if (dropped > 0) { - console.warn( - `[agent-hooks] last-status hydrate dropped ${dropped} entries (kept ${hydrated})` - ) - } - if (dropped > 0 || prunedLegacyClaudeSubagents > 0 || scrubbedLegacyLaunchTokens > 0) { - // Why: persist load-time pruning and bearer scrubbing once. - this.runStatusPersist() - } else if (hydrated > 0) { - // Why: prime dedup from raw bytes (not re-serialized) only when hydration was lossless. - this.lastWrittenJson = raw - } - } - - private captureHydratedAuthorityCommitments(): void { - this.revokedHydratedAuthorityCommitments = new WeakSet() - for (const entry of this.state.lastStatusByPaneKey.values()) { - const evidence = this.toAuthorityEvidence( - entry as EnrichedAgentHookEventPayload, - this.hydratedLaunchTokenHashByPaneKey.get(entry.paneKey) - ) - if (evidence && !this.persistedAuthorityCommitmentsByPaneKey.has(entry.paneKey)) { - this.persistedAuthorityCommitmentsByPaneKey.set(entry.paneKey, evidence) - } - } - this.hydratedAuthorityCommitments = Object.freeze( - Array.from(this.persistedAuthorityCommitmentsByPaneKey.values()) - ) - } - - private recordCurrentAuthorityObservation(payload: AgentHookEventPayload): void { - const evidence = this.toAuthorityEvidence(payload) - if (evidence) { - this.currentAuthorityObservations.set(evidence.paneKey, evidence) - this.persistedAuthorityCommitmentsByPaneKey.set(evidence.paneKey, evidence) - this.hydratedLaunchTokenHashByPaneKey.set(evidence.paneKey, evidence.launchTokenHash) - } - } - - private toAuthorityEvidence( - payload: AgentHookEventPayload | EnrichedAgentHookEventPayload, - launchTokenHashOverride?: string - ): AgentHookAuthorityEvidence | null { - const launchToken = payload.launchToken?.trim() - const launchTokenHash = - launchTokenHashOverride ?? - (launchToken ? createHash('sha256').update(launchToken).digest('hex') : null) - if (!launchTokenHash) { - return null - } - return Object.freeze({ - paneKey: payload.paneKey, - launchTokenHash, - connectionId: payload.connectionId, - ...(payload.tabId ? { tabId: payload.tabId } : {}), - ...(payload.worktreeId ? { worktreeId: payload.worktreeId } : {}), - observedAt: 'receivedAt' in payload ? payload.receivedAt : Date.now() - }) - } - - private serializeStatusFile(): string { - const entries: Record = {} - const authorityCommitments: Record = {} - const conflictedCommitments = new Set() - for (const [paneKey, commitment] of this.persistedAuthorityCommitmentsByPaneKey) { - authorityCommitments[paneKey] = { ...commitment } - } - for (const [paneKey, payload] of this.state.lastStatusByPaneKey) { - // Why: never persist invalid keys (matches the hydrate-path invariant). - if (!isValidPaneKey(paneKey)) { - continue - } - const enrichedPayload = payload as EnrichedAgentHookEventPayload - const childOnlyBoundary = enrichedPayload.claudeLeadBoundaryChildOnly === true - const { - claudeRunningNonAgentTask: _claudeRunningNonAgentTask, - promptInteractionKey: _promptInteractionKey, - // Why: never persisted — hydrate re-stamps it, so a stored copy could only drift. - restoredUnconfirmed: _restoredUnconfirmed, - // Why: same — the sequencer that issued it dies with the process (see PersistedAgentHookEventPayload). - observation: _observation, - // Replay provenance is runtime-only and must not survive another restart. - isReplay: _isReplay, - launchToken, - ...persistedPayload - } = enrichedPayload - const launchTokenHash = launchToken?.trim() - ? createHash('sha256').update(launchToken.trim()).digest('hex') - : this.hydratedLaunchTokenHashByPaneKey.get(paneKey) - entries[paneKey] = { - ...persistedPayload, - ...(childOnlyBoundary ? { claudeLeadBoundaryChildOnly: true } : {}), - ...(launchTokenHash ? { launchTokenHash } : {}) - } - const commitment = this.toAuthorityEvidence(payload, launchTokenHash) - if (commitment && !conflictedCommitments.has(paneKey)) { - const existing = authorityCommitments[paneKey] - if (existing && !authorityCommitmentsMatch(existing, commitment)) { - delete authorityCommitments[paneKey] - conflictedCommitments.add(paneKey) - } else { - authorityCommitments[paneKey] = { ...commitment } - } - } - } - const file: LastStatusFile = { - version: LAST_STATUS_FILE_VERSION, - entries, - authorityCommitments - } - return JSON.stringify(file) - } - - private scheduleStatusPersist(): void { - if (!this.lastStatusFilePath) { - return - } - // Why: reset the timer each call so the write fires only after the last event in a burst. - if (this.statusPersistTimer) { - clearTimeout(this.statusPersistTimer) - } - this.statusPersistTimer = setTimeout(() => { - this.statusPersistTimer = null - this.runStatusPersist() - }, STATUS_PERSIST_DEBOUNCE_MS) - // Why: don't keep the event loop alive just for a status flush — quit already flushes sync. - if (typeof this.statusPersistTimer.unref === 'function') { - this.statusPersistTimer.unref() - } - } - - flushStatusPersistSync(): void { - if (this.statusPersistTimer) { - clearTimeout(this.statusPersistTimer) - this.statusPersistTimer = null - } - if (!this.lastStatusFilePath) { - return - } - this.runStatusPersist() - } - - private runStatusPersist(): void { - if (!this.lastStatusFilePath || !this.endpointDir) { - return - } - const json = this.serializeStatusFile() - if (json === this.lastWrittenJson) { - return - } - const tmpPath = join(this.endpointDir, `.last-status-${process.pid}-${randomUUID()}.tmp`) - let tmpWritten = false - try { - mkdirSync(this.endpointDir, { recursive: true, mode: 0o700 }) - if (process.platform !== 'win32') { - try { - chmodSync(this.endpointDir, 0o700) - } catch { - // best-effort - } - } - writeFileSync(tmpPath, json, { mode: 0o600 }) - tmpWritten = true - renameSync(tmpPath, this.lastStatusFilePath) - this.lastWrittenJson = json - } catch (err) { - console.warn('[agent-hooks] failed to write last-status file:', err) - if (tmpWritten) { - try { - unlinkSync(tmpPath) - } catch { - // tmp already gone - } - } - } - } - - /** Test-only accessor for the per-instance listener state (narrow getter avoids an `as unknown` cast). */ - _getStateForTests(): HookListenerState { - return this.state - } - - _resetPromptSentDedupeForTests(): void { - this.promptSentDedupeByPaneKey.clear() - } - - _resetConnectionTimestampWatermarksForTests(): void { - this.connectionTimestampWatermarkById.clear() - } -} +export { + CLOSED_AGENT_STATUS_TAB_IDS_MAX, + CLOSED_AGENT_STATUS_PANE_KEYS_MAX, + PANE_KEY_ALIASES_MAX, + RETIRED_PANE_FENCES_MAX +} from './server/server-constants' +export { isValidPaneKey } + +/** Public composition seam for the loopback hook listener and relay status adapter. */ +export class AgentHookServer extends AgentHookServerLifecycle {} export const agentHookServer = new AgentHookServer() @@ -3540,3 +44,5 @@ export const _internals = { agentHookServer._resetConnectionTimestampWatermarksForTests() } } + +export type { HookListenerState } from '../../shared/agent-hook-listener/listener-state' diff --git a/src/main/agent-hooks/server/server-authority-aliases.ts b/src/main/agent-hooks/server/server-authority-aliases.ts new file mode 100644 index 00000000000..18756cb459c --- /dev/null +++ b/src/main/agent-hooks/server/server-authority-aliases.ts @@ -0,0 +1,222 @@ +import { movePaneCacheState } from '../../../shared/agent-hook-listener/listener-state' +import { canRegisterPaneKeyAlias, isOpaqueRemintedPaneKey } from '../../../shared/pane-key-alias' +import { parsePaneKey } from '../../../shared/stable-pane-id' +import { PANE_KEY_ALIASES_MAX } from './server-constants' +import type { EnrichedAgentHookEventPayload, PaneKeyAliasPersistenceListener } from './server-types' +import type { LegacyPaneKeyAliasEntry } from '../../../shared/persisted-state-types' +import { isValidPaneKey } from './server-status-identity' +import { AgentHookServerAuthorityEvidence } from './server-authority-evidence' + +export abstract class AgentHookServerAuthorityAliases extends AgentHookServerAuthorityEvidence { + setPaneKeyAliasPersistenceListener(listener: PaneKeyAliasPersistenceListener | null): void { + this.paneKeyAliasPersistenceListener = listener + } + + protected getPersistedPaneKeyAliases(): LegacyPaneKeyAliasEntry[] { + return Array.from(this.legacyPaneKeyAliases.entries()).flatMap(([legacyPaneKey, entry]) => + entry.ptyId + ? [ + { + ptyId: entry.ptyId, + legacyPaneKey, + stablePaneKey: entry.stablePaneKey, + updatedAt: entry.updatedAt + } + ] + : [] + ) + } + + protected notifyPaneKeyAliasPersistenceListener(): void { + this.paneKeyAliasPersistenceListener?.(this.getPersistedPaneKeyAliases()) + } + + protected boundPaneKeyAliases(): void { + while (this.legacyPaneKeyAliases.size > PANE_KEY_ALIASES_MAX) { + // Why: renderer-originated aliases are untrusted; insertion-order eviction bounds memory and per-message cleanup. + const oldestKey = this.legacyPaneKeyAliases.keys().next().value + if (!oldestKey) { + break + } + this.legacyPaneKeyAliases.delete(oldestKey) + } + } + + protected getPhysicalPaneKeyForAuthority(paneKey: string, ptyId?: string): string { + const ownerPaneKey = this.resolvePaneKeyAlias(paneKey) + let fallbackPaneKey = paneKey + for (const [physicalPaneKey, entry] of this.legacyPaneKeyAliases) { + if ( + entry.stablePaneKey === ownerPaneKey && + (!ptyId || !entry.ptyId || entry.ptyId === ptyId) + ) { + if (entry.authorityVerified) { + return physicalPaneKey + } + fallbackPaneKey = physicalPaneKey + } + } + return fallbackPaneKey + } + + canTransferPaneAuthority( + fromPaneKey: string, + ptyId: string | undefined, + ownsPty: (physicalPaneKey: string, ptyId: string) => boolean + ): boolean { + if (!isValidPaneKey(fromPaneKey)) { + return false + } + const ownerPaneKey = this.resolvePaneKeyAlias(fromPaneKey) + const physicalPaneKey = this.getPhysicalPaneKeyForAuthority(fromPaneKey, ptyId) + const alias = this.legacyPaneKeyAliases.get(physicalPaneKey) + if (ptyId) { + return Boolean( + (alias?.authorityVerified && alias.ptyId === ptyId) || + ownsPty(physicalPaneKey, ptyId) || + (ownerPaneKey !== physicalPaneKey && ownsPty(ownerPaneKey, ptyId)) + ) + } + // Why: hook status is renderer evidence, not PTY ownership; ID-less moves are safe only after a verified transfer minted an alias. + return alias?.authorityVerified === true + } + + registerPaneKeyAlias( + legacyPaneKey: string, + stablePaneKey: string, + ptyId?: string, + updatedAt = Date.now(), + options?: { overwriteExisting?: boolean; authorityVerified?: boolean } + ): void { + const fromPaneKey = legacyPaneKey.trim() + const toPaneKey = stablePaneKey.trim() + if (!canRegisterPaneKeyAlias(fromPaneKey, toPaneKey)) { + return + } + const existing = this.legacyPaneKeyAliases.get(fromPaneKey) + if (existing && options?.overwriteExisting === false) { + return + } + // Why: remint tokens have no embedded tab id; first pane wins so a later spawn + // cannot steal leftover $$…:L$$ posts onto a different tab:leaf. + if (existing && existing.stablePaneKey !== toPaneKey && isOpaqueRemintedPaneKey(fromPaneKey)) { + return + } + const normalizedPtyId = + typeof ptyId === 'string' && ptyId.trim().length > 0 ? ptyId.trim() : existing?.ptyId + const normalizedUpdatedAt = + Number.isFinite(updatedAt) && updatedAt > 0 ? updatedAt : (existing?.updatedAt ?? Date.now()) + const authorityVerified = options?.authorityVerified ?? false + if ( + existing && + existing.stablePaneKey === toPaneKey && + existing.ptyId === (normalizedPtyId ?? null) && + existing.updatedAt === normalizedUpdatedAt && + existing.authorityVerified === authorityVerified + ) { + return + } + this.legacyPaneKeyAliases.set(fromPaneKey, { + stablePaneKey: toPaneKey, + ptyId: normalizedPtyId ?? null, + updatedAt: normalizedUpdatedAt, + authorityVerified + }) + this.boundPaneKeyAliases() + if (normalizedPtyId) { + this.notifyPaneKeyAliasPersistenceListener() + } + } + + transferPaneAuthority( + fromPaneKey: string, + toPaneKey: string, + ptyId?: string, + updatedAt = Date.now(), + options?: { authorityVerified?: boolean } + ): void { + if (!isValidPaneKey(fromPaneKey) || !isValidPaneKey(toPaneKey)) { + return + } + const previousOwnerPaneKey = this.resolvePaneKeyAlias(fromPaneKey) + const physicalPaneKey = this.getPhysicalPaneKeyForAuthority(fromPaneKey, ptyId) + const existing = this.legacyPaneKeyAliases.get(physicalPaneKey) + const normalizedPtyId = ptyId?.trim() || existing?.ptyId || null + const hadStatus = this.state.lastStatusByPaneKey.has(previousOwnerPaneKey) + movePaneCacheState(this.state, previousOwnerPaneKey, toPaneKey) + const movedStatus = this.state.lastStatusByPaneKey.get(toPaneKey) as + | EnrichedAgentHookEventPayload + | undefined + if (movedStatus) { + const owner = parsePaneKey(toPaneKey) + this.state.lastStatusByPaneKey.set(toPaneKey, { + ...movedStatus, + paneKey: toPaneKey, + tabId: owner?.tabId + }) + } + const hydratedLaunchTokenHash = this.hydratedLaunchTokenHashByPaneKey.get(previousOwnerPaneKey) + if (hydratedLaunchTokenHash) { + this.hydratedLaunchTokenHashByPaneKey.delete(previousOwnerPaneKey) + this.hydratedLaunchTokenHashByPaneKey.set(toPaneKey, hydratedLaunchTokenHash) + } + const persistedAuthority = this.persistedAuthorityCommitmentsByPaneKey.get(previousOwnerPaneKey) + if (persistedAuthority) { + const owner = parsePaneKey(toPaneKey) + this.persistedAuthorityCommitmentsByPaneKey.delete(previousOwnerPaneKey) + this.persistedAuthorityCommitmentsByPaneKey.set( + toPaneKey, + Object.freeze({ + ...persistedAuthority, + paneKey: toPaneKey, + ...(owner?.tabId ? { tabId: owner.tabId } : {}) + }) + ) + } + if (this.runtimeObservedStatusPaneKeys.delete(previousOwnerPaneKey)) { + this.runtimeObservedStatusPaneKeys.add(toPaneKey) + } + const restartedTokenHash = + this.restartedStatusLaunchTokenHashByPaneKey.get(previousOwnerPaneKey) + this.restartedStatusLaunchTokenHashByPaneKey.delete(previousOwnerPaneKey) + this.restartedStatusLaunchTokenHashByPaneKey.delete(toPaneKey) + if (restartedTokenHash) { + this.restartedStatusLaunchTokenHashByPaneKey.set(toPaneKey, restartedTokenHash) + } + const activeTurnCompletedAt = this.activeHookTurnCompletedAtByPaneKey.get(previousOwnerPaneKey) + if (activeTurnCompletedAt !== undefined) { + this.activeHookTurnCompletedAtByPaneKey.delete(previousOwnerPaneKey) + this.activeHookTurnCompletedAtByPaneKey.set(toPaneKey, activeTurnCompletedAt) + } + const authorityObservation = this.currentAuthorityObservations.get(previousOwnerPaneKey) + if (authorityObservation) { + const owner = parsePaneKey(toPaneKey) + this.currentAuthorityObservations.delete(previousOwnerPaneKey) + this.currentAuthorityObservations.set( + toPaneKey, + Object.freeze({ ...authorityObservation, paneKey: toPaneKey, tabId: owner?.tabId }) + ) + } + const promptDedupe = this.promptSentDedupeByPaneKey.get(previousOwnerPaneKey) + if (promptDedupe !== undefined) { + this.promptSentDedupeByPaneKey.delete(previousOwnerPaneKey) + this.promptSentDedupeByPaneKey.set(toPaneKey, promptDedupe) + } + this.clearAssistantMessageRetry(previousOwnerPaneKey) + this.clearCodexSubagentPoll(previousOwnerPaneKey) + // Why: the live process keeps posting the physical source key after detach; persist a chain-safe mapping to the current owner. + this.legacyPaneKeyAliases.set(physicalPaneKey, { + stablePaneKey: toPaneKey, + ptyId: normalizedPtyId, + updatedAt, + authorityVerified: options?.authorityVerified ?? true + }) + this.boundPaneKeyAliases() + this.closedAgentStatusPaneKeys.delete(toPaneKey) + this.notifyPaneKeyAliasPersistenceListener() + if (hadStatus || persistedAuthority) { + this.scheduleStatusPersist() + this.notifyStatusChangeListeners() + } + } +} diff --git a/src/main/agent-hooks/server/server-authority-evidence.ts b/src/main/agent-hooks/server/server-authority-evidence.ts new file mode 100644 index 00000000000..8cda3426629 --- /dev/null +++ b/src/main/agent-hooks/server/server-authority-evidence.ts @@ -0,0 +1,94 @@ +import { createHash } from 'node:crypto' + +import type { AgentHookEventPayload } from '../../../shared/agent-hook-listener/listener-event' +import type { + AgentHookAuthorityAttestation, + AgentHookAuthorityEvidence, + EnrichedAgentHookEventPayload +} from './server-types' +import { AgentHookServerStatusRetries } from './server-status-retries' + +export abstract class AgentHookServerAuthorityEvidence extends AgentHookServerStatusRetries { + attestCompatibilityAuthority(candidate: { + paneKey: string + launchTokenHash: string + connectionId: string | null + terminalProvenance: 'current_runtime' | 'restored' + }): AgentHookAuthorityAttestation | null { + const paneKey = this.resolvePaneKeyAlias(candidate.paneKey) + const matchesCandidate = (entry: AgentHookAuthorityEvidence): boolean => + entry.launchTokenHash === candidate.launchTokenHash && + entry.connectionId === candidate.connectionId + const commitments = this.hydratedAuthorityCommitments.filter( + (entry) => matchesCandidate(entry) && !this.revokedHydratedAuthorityCommitments.has(entry) + ) + const current = Array.from(this.currentAuthorityObservations.values()) + const observations = current.filter(matchesCandidate) + const paneObservations = current.filter( + (entry) => this.resolvePaneKeyAlias(entry.paneKey) === paneKey + ) + const hasUniqueCurrentObservation = + observations.length === 1 && + paneObservations.length === 1 && + this.resolvePaneKeyAlias(observations[0]!.paneKey) === paneKey + if (candidate.terminalProvenance === 'current_runtime') { + return hasUniqueCurrentObservation ? Object.freeze({ paneKey, source: 'current_hook' }) : null + } + if (commitments.length !== 1 || this.resolvePaneKeyAlias(commitments[0]!.paneKey) !== paneKey) { + return null + } + if (observations.length === 0 && paneObservations.length === 0) { + return Object.freeze({ paneKey, source: 'hydrated_commitment' }) + } + if (!hasUniqueCurrentObservation) { + return null + } + return Object.freeze({ paneKey, source: 'current_hook' }) + } + + protected captureHydratedAuthorityCommitments(): void { + this.revokedHydratedAuthorityCommitments = new WeakSet() + for (const entry of this.state.lastStatusByPaneKey.values()) { + const evidence = this.toAuthorityEvidence( + entry as EnrichedAgentHookEventPayload, + this.hydratedLaunchTokenHashByPaneKey.get(entry.paneKey) + ) + if (evidence && !this.persistedAuthorityCommitmentsByPaneKey.has(entry.paneKey)) { + this.persistedAuthorityCommitmentsByPaneKey.set(entry.paneKey, evidence) + } + } + this.hydratedAuthorityCommitments = Object.freeze( + Array.from(this.persistedAuthorityCommitmentsByPaneKey.values()) + ) + } + + protected recordCurrentAuthorityObservation(payload: AgentHookEventPayload): void { + const evidence = this.toAuthorityEvidence(payload) + if (evidence) { + this.currentAuthorityObservations.set(evidence.paneKey, evidence) + this.persistedAuthorityCommitmentsByPaneKey.set(evidence.paneKey, evidence) + this.hydratedLaunchTokenHashByPaneKey.set(evidence.paneKey, evidence.launchTokenHash) + } + } + + protected toAuthorityEvidence( + payload: AgentHookEventPayload | EnrichedAgentHookEventPayload, + launchTokenHashOverride?: string + ): AgentHookAuthorityEvidence | null { + const launchToken = payload.launchToken?.trim() + const launchTokenHash = + launchTokenHashOverride ?? + (launchToken ? createHash('sha256').update(launchToken).digest('hex') : null) + if (!launchTokenHash) { + return null + } + return Object.freeze({ + paneKey: payload.paneKey, + launchTokenHash, + connectionId: payload.connectionId, + ...(payload.tabId ? { tabId: payload.tabId } : {}), + ...(payload.worktreeId ? { worktreeId: payload.worktreeId } : {}), + observedAt: 'receivedAt' in payload ? payload.receivedAt : Date.now() + }) + } +} diff --git a/src/main/agent-hooks/server/server-authority-fences.ts b/src/main/agent-hooks/server/server-authority-fences.ts new file mode 100644 index 00000000000..0ad1bdeba62 --- /dev/null +++ b/src/main/agent-hooks/server/server-authority-fences.ts @@ -0,0 +1,193 @@ +import { clearPaneCacheState } from '../../../shared/agent-hook-listener/listener-state' +import { parsePaneKey } from '../../../shared/stable-pane-id' +import { AgentHookServerAuthorityAliases } from './server-authority-aliases' +import type { RetiredPaneAlias, RetiredPaneFence } from './server-types' + +export abstract class AgentHookServerAuthorityFences extends AgentHookServerAuthorityAliases { + // Why: retirement fences a pane and every alias of it, then deletes those aliases. + retirePaneAuthority(paneKey: string): void { + const ownerPaneKey = this.resolvePaneKeyAlias(paneKey) + const paneKeys = new Set([paneKey, ownerPaneKey]) + const retiredAliases: RetiredPaneAlias[] = [] + let aliasChanged = false + for (const [physicalPaneKey, entry] of this.legacyPaneKeyAliases) { + if (physicalPaneKey === paneKey || entry.stablePaneKey === ownerPaneKey) { + this.legacyPaneKeyAliases.delete(physicalPaneKey) + retiredAliases.push({ physicalPaneKey, entry }) + paneKeys.add(physicalPaneKey) + paneKeys.add(entry.stablePaneKey) + aliasChanged = true + } + } + this.recordRetiredPaneFence(paneKeys, retiredAliases) + const authorityChanged = this.revokeHydratedAuthorityForPaneKeys(paneKeys) + const hadStatus = [...paneKeys].some((key) => this.state.lastStatusByPaneKey.has(key)) + for (const key of paneKeys) { + this.markPaneClosedForAgentStatus(key) + this.restartedStatusLaunchTokenHashByPaneKey.delete(key) + this.clearAssistantMessageRetry(key) + this.clearCodexSubagentPoll(key) + clearPaneCacheState(this.state, key) + this.activeHookTurnCompletedAtByPaneKey.delete(key) + this.runtimeObservedStatusPaneKeys.delete(key) + this.currentAuthorityObservations.delete(key) + this.promptSentDedupeByPaneKey.delete(key) + this.observations.forget(key) + } + if (aliasChanged) { + this.notifyPaneKeyAliasPersistenceListener() + } + if (hadStatus || authorityChanged) { + this.scheduleStatusPersist() + this.notifyStatusChangeListeners() + } + } + + // Why: retirement fences a pane and every alias of it, then deletes those aliases. + // Lifting only the key we are handed strands the rest — a detached pane's process + // keeps posting the key it launched under, so it would stay suppressed forever with + // the fence apparently lifted. Replay the recorded fence instead: same key set, same + // aliases. Keys and aliases belonging to a closed tab are skipped, so the stronger + // claim survives and a live process is never routed back into a closed tab. + protected restoreRetiredPaneFence(fence: RetiredPaneFence): void { + let aliasChanged = false + for (const { physicalPaneKey, entry } of fence.aliases) { + if ( + this.isClosedAgentStatusTabForPaneKey(physicalPaneKey) || + this.isClosedAgentStatusTabForPaneKey(entry.stablePaneKey) || + // Why: the pane was rebound in the meantime; the newer alias is the truth. + this.legacyPaneKeyAliases.has(physicalPaneKey) + ) { + continue + } + this.legacyPaneKeyAliases.set(physicalPaneKey, entry) + aliasChanged = true + } + for (const key of fence.paneKeys) { + if (this.retiredPaneFencesByKey.get(key) === fence) { + this.retiredPaneFencesByKey.delete(key) + } + } + if (aliasChanged) { + this.boundPaneKeyAliases() + this.notifyPaneKeyAliasPersistenceListener() + } + } + + restorePaneAuthority(paneKey: string): boolean { + const ownerPaneKey = this.resolvePaneKeyAlias(paneKey) + if (this.isClosedAgentStatusTabForPaneKey(ownerPaneKey)) { + return false + } + // Why: retirement is a claim that a pane is gone. Re-attaching a live PTY to that + // exact pane disproves the claim at the moment it stops being true, so the fence + // lifts here instead of waiting for the agent to speak again — an agent re-attached + // mid-turn or left idle would otherwise stay suppressed for the rest of its life + // (STA-4114). A closed *tab* is a separate, stronger claim and is left standing. + const fence = + this.retiredPaneFencesByKey.get(paneKey) ?? this.retiredPaneFencesByKey.get(ownerPaneKey) + let restored = false + for (const key of new Set([paneKey, ownerPaneKey, ...(fence?.paneKeys ?? [])])) { + if (this.isClosedAgentStatusTabForPaneKey(key)) { + continue + } + if (this.closedAgentStatusPaneKeys.delete(key)) { + restored = true + } + } + if (fence) { + this.restoreRetiredPaneFence(fence) + } + return restored + } + + clearPaneKeyAliasesForPty( + ptyId: string, + options?: { shouldClearStablePaneKey?: (paneKey: string) => boolean } + ): void { + let aliasChanged = false + let statusChanged = false + const clearedStatusPaneKeys = new Set() + for (const [legacyPaneKey, entry] of this.legacyPaneKeyAliases) { + if (entry.ptyId !== ptyId) { + continue + } + const shouldClearStablePaneKey = + options?.shouldClearStablePaneKey?.(entry.stablePaneKey) ?? true + const revokedPaneKeys = new Set([legacyPaneKey]) + if (shouldClearStablePaneKey) { + revokedPaneKeys.add(entry.stablePaneKey) + } + if (this.revokeHydratedAuthorityForPaneKeys(revokedPaneKeys)) { + statusChanged = true + } + this.legacyPaneKeyAliases.delete(legacyPaneKey) + clearPaneCacheState(this.state, legacyPaneKey) + this.activeHookTurnCompletedAtByPaneKey.delete(legacyPaneKey) + this.currentAuthorityObservations.delete(legacyPaneKey) + this.promptSentDedupeByPaneKey.delete(legacyPaneKey) + if (shouldClearStablePaneKey && this.state.lastStatusByPaneKey.has(entry.stablePaneKey)) { + statusChanged = true + clearedStatusPaneKeys.add(entry.stablePaneKey) + } + if (shouldClearStablePaneKey) { + // Why: hydrated rows live under the stable key; if this PTY dies before ptyPaneKey rebuilds, alias cleanup is the only evictor. + clearPaneCacheState(this.state, entry.stablePaneKey) + this.activeHookTurnCompletedAtByPaneKey.delete(entry.stablePaneKey) + this.runtimeObservedStatusPaneKeys.delete(entry.stablePaneKey) + this.currentAuthorityObservations.delete(entry.stablePaneKey) + this.promptSentDedupeByPaneKey.delete(entry.stablePaneKey) + } + aliasChanged = true + } + if (aliasChanged) { + this.notifyPaneKeyAliasPersistenceListener() + } + if (statusChanged) { + this.scheduleStatusPersist() + this.notifyStatusChangeListeners() + for (const paneKey of clearedStatusPaneKeys) { + this.emitPaneStatusCleared({ paneKey }) + } + } + } + + protected resolvePaneKeyAlias(paneKey: string): string { + return this.legacyPaneKeyAliases.get(paneKey)?.stablePaneKey ?? paneKey + } + + protected revokeHydratedAuthorityForPaneKeys(paneKeys: ReadonlySet): boolean { + let changed = false + for (const commitment of this.hydratedAuthorityCommitments) { + if ( + paneKeys.has(commitment.paneKey) || + paneKeys.has(this.resolvePaneKeyAlias(commitment.paneKey)) + ) { + this.revokedHydratedAuthorityCommitments.add(commitment) + changed = true + } + } + for (const paneKey of paneKeys) { + const resolvedPaneKey = this.resolvePaneKeyAlias(paneKey) + changed = this.hydratedLaunchTokenHashByPaneKey.delete(paneKey) || changed + changed = this.hydratedLaunchTokenHashByPaneKey.delete(resolvedPaneKey) || changed + changed = this.persistedAuthorityCommitmentsByPaneKey.delete(paneKey) || changed + changed = this.persistedAuthorityCommitmentsByPaneKey.delete(resolvedPaneKey) || changed + } + return changed + } + + protected normalizeHookBodyPaneKeyAlias(body: unknown): unknown { + if (typeof body !== 'object' || body === null) { + return body + } + const record = body as Record + const rawPaneKey = typeof record.paneKey === 'string' ? record.paneKey.trim() : '' + const stablePaneKey = this.legacyPaneKeyAliases.get(rawPaneKey)?.stablePaneKey + if (!stablePaneKey) { + return body + } + // Why: detached shells keep posting the immutable physical pane key; normalize pane and tab identity to the current owner. + return { ...record, paneKey: stablePaneKey, tabId: parsePaneKey(stablePaneKey)?.tabId } + } +} diff --git a/src/main/agent-hooks/server/server-claude-status-rules.ts b/src/main/agent-hooks/server/server-claude-status-rules.ts new file mode 100644 index 00000000000..9607e4a9dc8 --- /dev/null +++ b/src/main/agent-hooks/server/server-claude-status-rules.ts @@ -0,0 +1,191 @@ +import { claudeTeammateIdMatchesName } from '../../../shared/claude-subagent-roster' +import { isAskUserQuestionTool } from '../../../shared/agent-question-answered-intent' +import type { AgentHookEventPayload } from '../../../shared/agent-hook-listener/listener-event' +import type { EnrichedAgentHookEventPayload } from './server-types' + +export function attachClaudeChildOnlyBoundary( + previous: EnrichedAgentHookEventPayload | undefined, + next: AgentHookEventPayload +): AgentHookEventPayload & { claudeLeadBoundaryChildOnly?: true } { + const establishesBoundary = + next.payload.agentType === 'claude' && + (next.hookEventName === 'Stop' || next.hookEventName === 'StopFailure') && + !next.toolAgentId && + next.payload.state === 'working' && + next.payload.subagents?.some((subagent) => subagent.state === 'working') === true && + next.claudeRunningNonAgentTask === false + const carriesBoundary = + previous?.claudeLeadBoundaryChildOnly === true && + next.payload.agentType === 'claude' && + next.claudeRunningNonAgentTask === false && + (next.toolAgentId !== undefined || + next.hookEventName === 'SubagentStart' || + next.hookEventName === 'SubagentStop' || + next.hookEventName === 'TeammateIdle') + return establishesBoundary || carriesBoundary + ? { ...next, claudeLeadBoundaryChildOnly: true } + : next +} + +export function invalidateClaudeChildOnlyBoundary( + previous: EnrichedAgentHookEventPayload | undefined, + next: AgentHookEventPayload +): EnrichedAgentHookEventPayload | undefined { + if ( + previous?.claudeLeadBoundaryChildOnly !== true || + attachClaudeChildOnlyBoundary(previous, next).claudeLeadBoundaryChildOnly === true + ) { + return previous + } + const { claudeLeadBoundaryChildOnly: _boundary, ...withoutBoundary } = previous + return withoutBoundary +} + +export function shouldKeepClaudePermissionVisible( + previous: EnrichedAgentHookEventPayload | undefined, + next: AgentHookEventPayload +): boolean { + if (previous?.restoredUnconfirmed) { + return false + } + if ( + previous?.payload.agentType !== 'claude' || + previous.payload.state !== 'waiting' || + previous.hookEventName !== 'PermissionRequest' || + next.payload.agentType !== 'claude' || + next.payload.state !== 'working' + ) { + return false + } + if (next.hasExplicitPrompt === true) { + return false + } + if (isClaudePermissionOwningChildEnding(previous, next)) { + return false + } + if (isClaudePermissionResumingApprovedTool(previous, next)) { + return false + } + // Why: only real permission requests stay sticky; newer Claude reports AskUserQuestion as a PermissionRequest, so tool name (not event) decides. + if (isAskUserQuestionTool(previous.payload.toolName)) { + return false + } + return true +} + +function isClaudePermissionOwningChildEnding( + previous: EnrichedAgentHookEventPayload, + next: AgentHookEventPayload +): boolean { + const ownerId = previous.toolAgentId?.trim() + if (!ownerId) { + return false + } + if (next.hookEventName === 'SubagentStop') { + return ownerId === next.toolAgentId?.trim() + } + return ( + next.hookEventName === 'TeammateIdle' && + next.teammateName !== undefined && + claudeTeammateIdMatchesName(ownerId, next.teammateName) + ) +} + +function isClaudePermissionResumingApprovedTool( + previous: EnrichedAgentHookEventPayload, + next: AgentHookEventPayload +): boolean { + const previousToolUseId = previous.toolUseId?.trim() || undefined + const nextToolUseId = next.toolUseId?.trim() || undefined + const previousAgentId = previous.toolAgentId?.trim() || undefined + const nextAgentId = next.toolAgentId?.trim() || undefined + const hasAgentId = previousAgentId !== undefined || nextAgentId !== undefined + const previousAgentType = previous.toolAgentType?.trim() || undefined + const nextAgentType = next.toolAgentType?.trim() || undefined + const hasMatchingConcreteAgentId = + previousAgentId !== undefined && previousAgentId === nextAgentId + const hasSameExplicitAgentType = + !hasAgentId && previousAgentType !== undefined && previousAgentType === nextAgentType + const sameToolName = + previous.payload.toolName !== undefined && previous.payload.toolName === next.payload.toolName + const sameKnownToolInput = + previous.payload.toolInput !== undefined && + previous.payload.toolInput === next.payload.toolInput + const sameUnknownInputFromConcreteAgent = + hasMatchingConcreteAgentId && + previous.payload.toolInput === undefined && + next.payload.toolInput === undefined + const hasMatchingToolUseId = + previousToolUseId !== undefined && previousToolUseId === nextToolUseId + const hasConflictingToolUseId = + previousToolUseId !== undefined && + nextToolUseId !== undefined && + previousToolUseId !== nextToolUseId + const sameUnknownInputFromToolUseId = + hasMatchingToolUseId && + previous.payload.toolInput === undefined && + next.payload.toolInput === undefined + + return ( + (next.hookEventName === 'PreToolUse' || next.hookEventName === 'PostToolUse') && + nextToolUseId !== undefined && + !hasConflictingToolUseId && + // Why: subagents share agent_type, so a concrete agent id (or the preserved PostToolUse tool_use_id) is the safest resume signal. + (hasMatchingConcreteAgentId || hasSameExplicitAgentType || hasMatchingToolUseId) && + sameToolName && + (sameKnownToolInput || sameUnknownInputFromConcreteAgent || sameUnknownInputFromToolUseId) + ) +} + +export function shouldInheritClaudeToolUseIdForPermission( + previous: EnrichedAgentHookEventPayload | undefined, + next: AgentHookEventPayload +): boolean { + if ( + previous?.restoredUnconfirmed || + previous?.payload.agentType !== 'claude' || + previous.payload.state !== 'working' || + previous.hookEventName !== 'PreToolUse' || + typeof previous.toolUseId !== 'string' || + previous.toolUseId.trim().length === 0 || + next.payload.agentType !== 'claude' || + next.payload.state !== 'waiting' || + next.hookEventName !== 'PermissionRequest' || + next.toolUseId !== undefined + ) { + return false + } + const sameKnownToolInput = + previous.payload.toolInput !== undefined && + previous.payload.toolInput === next.payload.toolInput + const sameUnknownToolInput = + previous.payload.toolInput === undefined && next.payload.toolInput === undefined + if ( + previous.toolAgentId !== next.toolAgentId || + previous.toolAgentType !== next.toolAgentType || + previous.payload.toolName === undefined || + previous.payload.toolName !== next.payload.toolName || + (!sameKnownToolInput && !sameUnknownToolInput) + ) { + return false + } + return true +} + +export function attachClaudePermissionToolUseId( + previous: EnrichedAgentHookEventPayload | undefined, + next: AgentHookEventPayload +): AgentHookEventPayload { + const inheritedToolUseId = previous?.toolUseId + if ( + !shouldInheritClaudeToolUseIdForPermission(previous, next) || + typeof inheritedToolUseId !== 'string' + ) { + return next + } + return { + ...next, + // Why: Claude emits PermissionRequest without tool_use_id, then PostToolUse carries the original PreToolUse id. + toolUseId: inheritedToolUseId + } +} diff --git a/src/main/agent-hooks/server/server-cleanup.ts b/src/main/agent-hooks/server/server-cleanup.ts new file mode 100644 index 00000000000..04acc058dea --- /dev/null +++ b/src/main/agent-hooks/server/server-cleanup.ts @@ -0,0 +1,167 @@ +import { paneHasStateClaims } from '../../../shared/agent-hook-listener/listener-state' +import type { EnrichedAgentHookEventPayload } from './server-types' +import { AgentHookServerAuthorityFences } from './server-authority-fences' + +export abstract class AgentHookServerCleanup extends AgentHookServerAuthorityFences { + /** The resume-identity remnant of a dropped row: a `providerSessionOnly` entry carries no state + * claim — it cannot gate a pane `working` — so it survives teardowns that end the pane's live + * claims. Returns null when the row has no resumable session to keep. */ + protected toRetainedProviderSessionRow( + entry: EnrichedAgentHookEventPayload | null | undefined + ): EnrichedAgentHookEventPayload | null { + if ( + !entry?.providerSession || + !entry.payload.agentType || + entry.payload.agentType === 'unknown' + ) { + return null + } + const { launchToken: _launchToken, ...resumeIdentity } = entry + return { ...resumeIdentity, providerSessionOnly: true, retainedForLiveness: true } + } + + /** Drop only the status row (user dismissal); do NOT wipe prompt/tool caches since the pane's agent may still be alive. Use clearPaneState for PTY-teardown. */ + dropStatusEntry(paneKey: string): void { + const deleted = this.deleteStatusEntry(paneKey, { preserveAuthority: true }) + if (!deleted) { + return + } + const retained = this.toRetainedProviderSessionRow(deleted) + if (retained) { + this.state.lastStatusByPaneKey.set(deleted.paneKey, retained) + } + this.scheduleStatusPersist() + this.notifyStatusChangeListeners() + this.emitStatusDropped(deleted.paneKey) + } + + /** Retire panes whose owning process is certifiably dead. + * + * The ordinary teardown already does this: every attributable PTY exit reaches + * `clearProviderPtyState`, which resolves the pane key and calls `clearPaneState`. But that + * resolution depends on the spawn-time `ptyPaneKey` mapping, which a restored/reattached PTY may + * never rebuild — so those panes keep a `working` row and its latches for good, with no hook left + * to retire them. This is the same operation reached from the runtime's own pane-key knowledge, + * so a dead pane is cleaned up identically however its keys were resolved. */ + reconcileEndedProcessForPaneKeys( + paneKeys: Iterable, + options?: { + /** The pane's PTY outlived its agent (a confirmed shell foreground), so the session can still + * be resumed in place — keep the `providerSessionOnly` remnant the paired `agentStatus:drop` + * minted for exactly this case. A certified PTY exit passes nothing: there is no pane left to + * resume into, and dropping it matches what `clearProviderPtyState` already does. */ + preserveResumeIdentity?: boolean + } + ): number { + // A certified PTY exit passes no resume identity; a surviving shell may opt into the remnant. + let cleared = 0 + for (const paneKey of paneKeys) { + const resolvedPaneKey = this.resolvePaneKeyAlias(paneKey) + if (!this.hasLiveClaimsForPaneKey(resolvedPaneKey)) { + continue + } + const retained = options?.preserveResumeIdentity + ? this.toRetainedProviderSessionRow( + this.state.lastStatusByPaneKey.get(resolvedPaneKey) as + | EnrichedAgentHookEventPayload + | undefined + ) + : null + this.clearPaneState(resolvedPaneKey) + if (retained) { + this.state.lastStatusByPaneKey.set(resolvedPaneKey, retained) + this.scheduleStatusPersist() + this.notifyStatusChangeListeners() + } + cleared += 1 + } + return cleared + } + + /** Anything a dead pane could still be asserting: a row, or a latch that would re-gate one through + * `resolveClaudePaneState` on the pane's next event even after the row reads `done`. The list + * itself lives beside `clearPaneCacheState`, so adding a latch cannot leave this behind in a + * different file. */ + protected hasLiveClaimsForPaneKey(paneKey: string): boolean { + return paneHasStateClaims(this.state, paneKey) + } + + /** Clear statuses proven to belong to one lost SSH transport. */ + clearStatusEntriesForConnection(connectionId: string): void { + const normalizedConnectionId = connectionId.trim() + if (normalizedConnectionId.length === 0) { + return + } + const clearedAt = Math.max( + Date.now(), + (this.connectionTimestampWatermarkById.get(normalizedConnectionId) ?? -1) + 1 + ) + this.connectionTimestampWatermarkById.set(normalizedConnectionId, clearedAt) + let statusChanged = false + for (const [paneKey, rawEntry] of this.state.lastStatusByPaneKey) { + const entry = rawEntry as EnrichedAgentHookEventPayload + // Why: unstamped rows can't be attributed to one host; leave them for normal pane teardown. + if (entry.connectionId !== normalizedConnectionId) { + continue + } + const deleted = this.deleteStatusEntry(paneKey, { preserveAuthority: true }) + if (deleted) { + statusChanged = true + if (deleted.payload.agentType === 'codex') { + // Why: a replacement remote process may reuse the pane; don't merge it with the lost connection's children. + this.state.codexSubagentRosterByPaneKey.delete(paneKey) + this.state.codexLeadStateByPaneKey.delete(paneKey) + } else if (deleted.payload.agentType === 'claude') { + this.state.claudeSubagentRosterByPaneKey.delete(paneKey) + this.state.claudeLeadStateByPaneKey.delete(paneKey) + this.state.claudeRunningNonAgentTaskPaneKeys.delete(paneKey) + this.state.claudeActiveSessionCronPaneKeys.delete(paneKey) + this.state.claudeSessionOwnerByPaneKey.delete(paneKey) + } + } + } + for (const [paneKey, evidence] of this.currentAuthorityObservations) { + if (evidence.connectionId === normalizedConnectionId) { + this.currentAuthorityObservations.delete(paneKey) + } + } + if (statusChanged) { + // Why: persist/notify once — one disconnect can own many panes. + this.scheduleStatusPersist() + this.notifyStatusChangeListeners() + } + // Why: always send the cutoff even with no matched entry — another host may have overwritten this pane's row. + this.emitPaneStatusCleared({ + transient: true, + connectionId: normalizedConnectionId, + clearedAt + }) + } + + protected deleteStatusEntry( + paneKey: string, + options?: { preserveAuthority?: boolean } + ): EnrichedAgentHookEventPayload | null { + const resolvedPaneKey = this.resolvePaneKeyAlias(paneKey) + const existing = this.state.lastStatusByPaneKey.get(resolvedPaneKey) as + | EnrichedAgentHookEventPayload + | undefined + if (!existing) { + return null + } + this.state.lastStatusByPaneKey.delete(resolvedPaneKey) + this.activeHookTurnCompletedAtByPaneKey.delete(resolvedPaneKey) + if (!options?.preserveAuthority) { + this.hydratedLaunchTokenHashByPaneKey.delete(resolvedPaneKey) + this.persistedAuthorityCommitmentsByPaneKey.delete(resolvedPaneKey) + } + this.clearAssistantMessageRetry(resolvedPaneKey) + this.clearCodexSubagentPoll(resolvedPaneKey) + this.runtimeObservedStatusPaneKeys.delete(resolvedPaneKey) + this.currentAuthorityObservations.delete(resolvedPaneKey) + if (existing.payload.state === 'done') { + this.promptSentDedupeByPaneKey.delete(resolvedPaneKey) + } + return existing + } +} diff --git a/src/main/agent-hooks/server/server-constants.ts b/src/main/agent-hooks/server/server-constants.ts new file mode 100644 index 00000000000..7fa7901ae73 --- /dev/null +++ b/src/main/agent-hooks/server/server-constants.ts @@ -0,0 +1,29 @@ +import { AGENT_KIND_VALUES, type AgentKind } from '../../../shared/telemetry-events' + +// Why: co-located with the endpoint file in userData/agent-hooks/ so hook-server cross-restart artifacts stay together. +export const LAST_STATUS_FILE_NAME = 'last-status.json' +export const ASSISTANT_MESSAGE_RETRY_ATTEMPTS = 5 +export const ASSISTANT_MESSAGE_RETRY_MS = 50 +export const CODEX_SUBAGENT_POLL_MS = 1_000 +export const INTERRUPTED_DONE_LATE_WORKING_SUPPRESSION_MS = 15_000 + +// Why: starts at 2 — pre-merge v1 lacked receivedAt/stateStartedAt (never shipped); a mismatched version hydrates empty (treated as corrupt). +export const LAST_STATUS_FILE_VERSION = 2 + +// Why: trailing-edge debounce so a burst of hook events yields one disk write, not N; quit-time flushStatusPersistSync() guarantees the final flush. +export const STATUS_PERSIST_DEBOUNCE_MS = 250 +export const TOOL_PROGRESS_HOOK_EVENTS = new Set([ + 'PreToolUse', + 'PostToolUse', + 'PostToolUseFailure' +]) +export const AGENT_PROMPT_SENT_AGENT_KINDS = new Set(AGENT_KIND_VALUES) + +// Why: bound file growth from PTYs that never re-attach; 7 days is the "still relevant?" horizon beyond which entries shouldn't resurrect on hydrate. +export const HYDRATE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000 + +// Why: a long-closed tab can't receive status events; bound the set so it can't grow one entry per close for the whole session. +export const CLOSED_AGENT_STATUS_TAB_IDS_MAX = 1024 +export const CLOSED_AGENT_STATUS_PANE_KEYS_MAX = 1024 +export const PANE_KEY_ALIASES_MAX = 1024 +export const RETIRED_PANE_FENCES_MAX = 1024 diff --git a/src/main/agent-hooks/server/server-hydration.ts b/src/main/agent-hooks/server/server-hydration.ts new file mode 100644 index 00000000000..70da93b7c3b --- /dev/null +++ b/src/main/agent-hooks/server/server-hydration.ts @@ -0,0 +1,162 @@ +import { readFileSync } from 'node:fs' + +import { + seedClaudeLeadTurnFromPersistedStatus, + seedClaudeSubagentRosterFromSnapshots +} from '../../../shared/agent-hook-listener/providers/claude-roster-state' +import { seedCodexStateFromSnapshot } from '../../../shared/agent-hook-listener/providers/codex-state' +import { HYDRATE_MAX_AGE_MS, LAST_STATUS_FILE_VERSION } from './server-constants' +import type { LastStatusFile } from './server-types' +import { + authorityCommitmentsMatch, + dropHydratedIdleClaudeSubagents, + readPersistedLaunchTokenHash, + sanitizeHydratedEntry, + sanitizePersistedAuthorityCommitment +} from './server-persistence-validation' +import { AgentHookServerReaping } from './server-reaping' + +export abstract class AgentHookServerHydration extends AgentHookServerReaping { + /** Hydrate the durable cache, validating every row before it reaches the live listener state. */ + protected hydrateLastStatusFromDisk(): void { + if (!this.lastStatusFilePath) { + return + } + // Why: keep hydrate idempotent so a future re-start path can't merge prior-session state. + this.state.lastStatusByPaneKey.clear() + this.hydratedLaunchTokenHashByPaneKey.clear() + this.persistedAuthorityCommitmentsByPaneKey.clear() + let raw: string + try { + raw = readFileSync(this.lastStatusFilePath, 'utf8') + } catch (err) { + // Why: missing file is normal (first launch); other errors degrade to empty hydration + one warn. + if ((err as NodeJS.ErrnoException).code !== 'ENOENT') { + console.warn('[agent-hooks] failed to read last-status file:', err) + } + return + } + let parsed: unknown + try { + parsed = JSON.parse(raw) + } catch { + console.warn('[agent-hooks] last-status file is not valid JSON; ignoring') + return + } + if (typeof parsed !== 'object' || parsed === null) { + console.warn('[agent-hooks] last-status file is not an object; ignoring') + return + } + const file = parsed as Partial + if (file.version !== LAST_STATUS_FILE_VERSION) { + console.warn( + `[agent-hooks] last-status file version mismatch (${String( + file.version + )} != ${LAST_STATUS_FILE_VERSION}); ignoring` + ) + return + } + const entries = file.entries + if (typeof entries !== 'object' || entries === null) { + console.warn('[agent-hooks] last-status file entries missing or wrong shape; ignoring') + return + } + let hydrated = 0 + let dropped = 0 + let prunedLegacyClaudeSubagents = 0 + let scrubbedLegacyLaunchTokens = 0 + // Why: drop entries older than HYDRATE_MAX_AGE_MS to bound disk growth (one Date.now() for a consistent cutoff). + const ttlCutoff = Date.now() - HYDRATE_MAX_AGE_MS + for (const [paneKey, rawEntry] of Object.entries(entries)) { + const resolvedPaneKey = this.resolvePaneKeyAlias(paneKey) + const rawResolvedEntry = + resolvedPaneKey === paneKey || typeof rawEntry !== 'object' || rawEntry === null + ? rawEntry + : { ...(rawEntry as Record), paneKey: resolvedPaneKey } + const entry = sanitizeHydratedEntry(resolvedPaneKey, rawResolvedEntry) + if (entry && entry.receivedAt >= ttlCutoff) { + const launchTokenHash = readPersistedLaunchTokenHash(rawResolvedEntry) + if (launchTokenHash) { + this.hydratedLaunchTokenHashByPaneKey.set(resolvedPaneKey, launchTokenHash) + const evidence = this.toAuthorityEvidence(entry, launchTokenHash) + if (evidence) { + this.persistedAuthorityCommitmentsByPaneKey.set(resolvedPaneKey, evidence) + } + } + if ( + typeof rawResolvedEntry === 'object' && + rawResolvedEntry !== null && + typeof (rawResolvedEntry as Record).launchToken === 'string' + ) { + scrubbedLegacyLaunchTokens += 1 + } + const hydratedPayload = dropHydratedIdleClaudeSubagents(entry.payload) + if (hydratedPayload !== entry.payload) { + prunedLegacyClaudeSubagents += + (entry.payload.subagents?.length ?? 0) - (hydratedPayload.subagents?.length ?? 0) + entry.payload = hydratedPayload + } + if (entry.payload.state !== 'done') { + // Why: the terminal transition may have fired while no receiver was up; restore as unconfirmed, never as live truth. + entry.restoredUnconfirmed = true + } + this.state.lastStatusByPaneKey.set(resolvedPaneKey, entry) + if (entry.connectionId) { + // Why: a restart can see an earlier wall clock; seed ordering so new events stay after disk state. + const previousWatermark = this.connectionTimestampWatermarkById.get(entry.connectionId) + this.connectionTimestampWatermarkById.set( + entry.connectionId, + Math.max(previousWatermark ?? -1, entry.receivedAt) + ) + } + // Why: restore live child hierarchy immediately; provider-specific reconciliation reaps stale seeds. + if (entry.payload.agentType === 'codex') { + seedCodexStateFromSnapshot(this.state, resolvedPaneKey, entry.payload) + } else if (entry.payload.agentType === 'claude') { + seedClaudeLeadTurnFromPersistedStatus(this.state, resolvedPaneKey, entry, { + childOnlyBoundary: entry.claudeLeadBoundaryChildOnly === true + }) + if (entry.payload.subagents) { + seedClaudeSubagentRosterFromSnapshots( + this.state, + resolvedPaneKey, + entry.payload.subagents + ) + } + } + hydrated += 1 + } else { + dropped += 1 + } + } + for (const [paneKey, rawCommitment] of Object.entries(file.authorityCommitments ?? {})) { + const resolvedPaneKey = this.resolvePaneKeyAlias(paneKey) + const commitment = sanitizePersistedAuthorityCommitment(resolvedPaneKey, rawCommitment) + if (!commitment || commitment.observedAt < ttlCutoff) { + dropped += 1 + continue + } + const existing = this.persistedAuthorityCommitmentsByPaneKey.get(resolvedPaneKey) + if (existing && !authorityCommitmentsMatch(existing, commitment)) { + this.persistedAuthorityCommitmentsByPaneKey.delete(resolvedPaneKey) + this.hydratedLaunchTokenHashByPaneKey.delete(resolvedPaneKey) + dropped += 1 + continue + } + this.persistedAuthorityCommitmentsByPaneKey.set(resolvedPaneKey, commitment) + this.hydratedLaunchTokenHashByPaneKey.set(resolvedPaneKey, commitment.launchTokenHash) + } + if (dropped > 0) { + console.warn( + `[agent-hooks] last-status hydrate dropped ${dropped} entries (kept ${hydrated})` + ) + } + if (dropped > 0 || prunedLegacyClaudeSubagents > 0 || scrubbedLegacyLaunchTokens > 0) { + // Why: persist load-time pruning and bearer scrubbing once. + this.runStatusPersist() + } else if (hydrated > 0) { + // Why: prime dedup from raw bytes (not re-serialized) only when hydration was lossless. + this.lastWrittenJson = raw + } + } +} diff --git a/src/main/agent-hooks/server/server-ingest-normalization.ts b/src/main/agent-hooks/server/server-ingest-normalization.ts new file mode 100644 index 00000000000..0a1e8d761d6 --- /dev/null +++ b/src/main/agent-hooks/server/server-ingest-normalization.ts @@ -0,0 +1,81 @@ +import { buildSpoolHookBody, type SpoolRecord } from '../../../shared/agent-hook-spool' +import { normalizeHookPayload } from '../../../shared/agent-hook-listener' +import { isAgentHookSource, type AgentHookSource } from '../../../shared/agent-hook-relay' +import type { NormalizedLocalHook } from './server-types' +import { AgentHookServerPersistence } from './server-persistence' + +export abstract class AgentHookServerIngestNormalization extends AgentHookServerPersistence { + protected setClaudeBackgroundEvidence( + paneKey: string, + hasRunningTask: boolean, + hasActiveCron: boolean + ): void { + if (hasRunningTask) { + this.state.claudeRunningNonAgentTaskPaneKeys.add(paneKey) + } else { + this.state.claudeRunningNonAgentTaskPaneKeys.delete(paneKey) + } + if (hasActiveCron) { + this.state.claudeActiveSessionCronPaneKeys.add(paneKey) + } else { + this.state.claudeActiveSessionCronPaneKeys.delete(paneKey) + } + } + + protected normalizeLocalHookPayload(source: AgentHookSource, body: unknown): NormalizedLocalHook { + if (source !== 'claude' || typeof body !== 'object' || body === null) { + return { event: normalizeHookPayload(this.state, source, body, this.env) } + } + const rawPaneKey = (body as Record).paneKey + const paneKey = typeof rawPaneKey === 'string' ? rawPaneKey.trim() : '' + if (!paneKey) { + return { event: normalizeHookPayload(this.state, source, body, this.env) } + } + const previousRunningTask = this.state.claudeRunningNonAgentTaskPaneKeys.has(paneKey) + const previousActiveCron = this.state.claudeActiveSessionCronPaneKeys.has(paneKey) + const event = normalizeHookPayload(this.state, source, body, this.env) + const nextRunningTask = this.state.claudeRunningNonAgentTaskPaneKeys.has(paneKey) + const nextActiveCron = this.state.claudeActiveSessionCronPaneKeys.has(paneKey) + this.setClaudeBackgroundEvidence(paneKey, previousRunningTask, previousActiveCron) + if (!event || event.paneKey !== paneKey) { + return { event } + } + // Why: nested CLIs may inherit the pane key; only accepted statuses may mutate its background-work gate. + return { + event, + onAccepted: () => this.setClaudeBackgroundEvidence(paneKey, nextRunningTask, nextActiveCron) + } + } + + // Spool records are durable replay evidence, not a live observation. + protected ingestSpoolRecord(record: SpoolRecord): void { + if (!isAgentHookSource(record.source)) { + return + } + const body = this.normalizeHookBodyPaneKeyAlias(buildSpoolHookBody(record)) + const normalized = this.normalizeLocalHookPayload(record.source, body) + if (!normalized.event) { + return + } + const replay = { ...normalized.event, isReplay: true as const } + const statusDisposition = this.getAgentStatusDisposition(replay.paneKey, { + source: record.source, + hookEventName: replay.hookEventName, + isReplay: true, + hasExplicitPrompt: replay.hasExplicitPrompt, + launchToken: replay.launchToken + }) + if (statusDisposition === 'suppress') { + return + } + const event = statusDisposition === 'restart' ? { ...replay, launchToken: undefined } : replay + if (statusDisposition === 'restart') { + this.observations.rebind(event.paneKey) + } + this.recordCurrentAuthorityObservation(event) + this.applyNormalizedStatus(event, normalized.onAccepted) + if (event.payload.state !== 'done') { + this.withdrawReplayObservation(this.resolvePaneKeyAlias(event.paneKey)) + } + } +} diff --git a/src/main/agent-hooks/server/server-ingest-remote.ts b/src/main/agent-hooks/server/server-ingest-remote.ts new file mode 100644 index 00000000000..18b0a837c32 --- /dev/null +++ b/src/main/agent-hooks/server/server-ingest-remote.ts @@ -0,0 +1,282 @@ +import { track } from '../../telemetry/client' +import { normalizeAgentStatusPayload } from '../../../shared/agent-status-types' +import { normalizeAgentProviderSession } from '../../../shared/agent-session-resume' +import { isAgentHookSource, restoreShedStatusFields } from '../../../shared/agent-hook-relay' +import { + MAX_PANE_KEY_LEN, + normalizeClaudePromptId, + warnOnHookEnvOrVersionMismatch +} from '../../../shared/agent-hook-listener/listener-limits' +import { + canAcceptClaudeCompactCompletion, + isClaudeCompactCompletionConsumed, + markClaudeCompactCompletionConsumed, + resolveLegacyCompactTrigger +} from '../../../shared/claude-compact-completion' +import { launchTokenHash } from '../../../shared/agent-hook-spool' +import { parsePaneKey } from '../../../shared/stable-pane-id' +import type { AgentHookEventPayload } from '../../../shared/agent-hook-listener/listener-event' +import { isValidPiProviderSessionOnly } from './server-status-identity' +import { AgentHookServerIngestTerminal } from './server-ingest-terminal' + +export abstract class AgentHookServerIngestRemote extends AgentHookServerIngestTerminal { + /** Ingest a payload from the relay JSON-RPC channel (not the local HTTP server); connectionId is stamped here. Main is still the SSH trust boundary, so re-run the canonical normalizer before caching. */ + ingestRemote( + envelope: { + paneKey: string + tabId?: string + worktreeId?: string + env?: string + version?: string + launchToken?: string + hasExplicitPrompt?: boolean + promptInteractionKey?: string + hookEventName?: string + source?: unknown + providerPromptId?: unknown + compactTrigger?: unknown + toolUseId?: string + toolAgentId?: string + teammateName?: string + toolAgentType?: string + providerSession?: unknown + providerSessionOnly?: unknown + isReplay?: boolean + /** Payload fields the relay dropped to fit an oversized frame; validated below. */ + shedFields?: unknown + claudeRunningNonAgentTask?: unknown + payload: unknown + }, + connectionId: string | null + ): void { + // Why: wire crosses a trust boundary — re-check/trim so an empty connectionId can't poison caches. + if (connectionId !== null && typeof connectionId !== 'string') { + return + } + const trimmedConnectionId = connectionId?.trim() ?? null + if (trimmedConnectionId !== null && trimmedConnectionId.length === 0) { + return + } + if (!envelope || typeof envelope.paneKey !== 'string') { + return + } + // Why: trim paneKey to match the HTTP path, else remote-vs-local events for one pane diverge. + const physicalPaneKey = envelope.paneKey.trim() + const paneKey = this.resolvePaneKeyAlias(physicalPaneKey) + const parsedPaneKey = parsePaneKey(paneKey) + if (paneKey.length === 0) { + track('agent_hook_unattributed', { reason: 'empty_pane_key' }) + return + } + if (paneKey.length > MAX_PANE_KEY_LEN || !parsedPaneKey) { + return + } + // Why: fence relay spool replay at main so stale generations cannot overwrite hydrated state. + if (envelope.isReplay === true) { + const expectedLaunchTokenHash = this.hydratedLaunchTokenHashByPaneKey.get(paneKey) + const actualLaunchTokenHash = launchTokenHash(envelope.launchToken) + if (expectedLaunchTokenHash && actualLaunchTokenHash !== expectedLaunchTokenHash) { + return + } + } + if (envelope.tabId !== undefined && typeof envelope.tabId !== 'string') { + return + } + if (envelope.worktreeId !== undefined && typeof envelope.worktreeId !== 'string') { + return + } + // Why: mirror the HTTP path's readStringField — trim and treat empty-after-trim as undefined. + const reportedTabId = + envelope.tabId !== undefined && envelope.tabId.trim().length > 0 + ? envelope.tabId.trim() + : undefined + if ( + paneKey === physicalPaneKey && + reportedTabId !== undefined && + reportedTabId !== parsedPaneKey.tabId + ) { + return + } + const tabId = paneKey !== physicalPaneKey ? parsedPaneKey.tabId : reportedTabId + const hookEventName = + typeof envelope.hookEventName === 'string' && envelope.hookEventName.trim().length > 0 + ? envelope.hookEventName.trim() + : undefined + const source = isAgentHookSource(envelope.source) ? envelope.source : undefined + const providerPromptId = + source === 'claude' ? normalizeClaudePromptId(envelope.providerPromptId) : undefined + const compactTrigger = + source === 'claude' && + (envelope.compactTrigger === 'manual' || envelope.compactTrigger === 'auto') + ? envelope.compactTrigger + : undefined + const statusDisposition = this.getAgentStatusDisposition(paneKey, { + source, + rawSource: envelope.source, + hookEventName, + isReplay: envelope.isReplay === true, + hasExplicitPrompt: envelope.hasExplicitPrompt === true, + launchToken: envelope.launchToken + }) + if (statusDisposition === 'suppress') { + return + } + if (statusDisposition === 'restart') { + // Why: same rebind as the HTTP path — a retired pane taking a new turn is a new session. + // Why paneKey, not envelope.paneKey: alias resolution already mapped it to the + // stable pane, so the rebind cannot land on a legacy key. + this.observations.rebind(paneKey) + } + const worktreeId = + envelope.worktreeId !== undefined && envelope.worktreeId.trim().length > 0 + ? envelope.worktreeId.trim() + : undefined + const promptInteractionKey = + typeof envelope.promptInteractionKey === 'string' && + envelope.promptInteractionKey.trim().length > 0 + ? envelope.promptInteractionKey.trim() + : undefined + const toolUseId = + typeof envelope.toolUseId === 'string' && envelope.toolUseId.trim().length > 0 + ? envelope.toolUseId.trim() + : undefined + const toolAgentId = + typeof envelope.toolAgentId === 'string' && envelope.toolAgentId.trim().length > 0 + ? envelope.toolAgentId.trim() + : undefined + const teammateName = + typeof envelope.teammateName === 'string' && envelope.teammateName.trim().length > 0 + ? envelope.teammateName.trim() + : undefined + const toolAgentType = + typeof envelope.toolAgentType === 'string' && envelope.toolAgentType.trim().length > 0 + ? envelope.toolAgentType.trim() + : undefined + const providerSession = normalizeAgentProviderSession(envelope.providerSession) ?? undefined + // Why: relay crosses a trust boundary — re-run the canonical normalizer to enforce caps/invariants (returns null on malformed). + const validatedPayload = normalizeAgentStatusPayload(envelope.payload) + if (!validatedPayload) { + return + } + // Why: restore a shed roster only when its digest and turn identity still match the cache. + let normalizedPayload = restoreShedStatusFields( + validatedPayload, + envelope.shedFields, + this.state.lastStatusByPaneKey.get(paneKey)?.payload + ) + const previousStatus = this.state.lastStatusByPaneKey.get(paneKey) + let acceptedCompactCompletion = false + if (hookEventName === 'PreCompact' || hookEventName === 'PostCompact') { + // Why: PreCompact is never registered and proves nothing (an aborted compact emits it alone); + // reject it here too so a host on any version cannot drive pane state from it. + if (hookEventName === 'PreCompact' || source !== 'claude') { + return + } + // Why: a relay predating this change strips `compactTrigger` from its cached PostCompact + // before replaying it, so the replay has no manual/auto discriminator. That relay's mapping is + // fixed and known — manual produced `done`, auto produced `working` — so the payload state + // stands in for the missing trigger. Trigger substitution only; ownership is still checked. + const effectiveTrigger = resolveLegacyCompactTrigger(compactTrigger, normalizedPayload.state) + // Why: an auto compact happens inside a turn that resumes and emits its own Stop. An older + // relay maps it to `working`, and this ingest applies the relay's payload verbatim — so + // without this drop, every auto compact on such a host mints exactly the stuck `working` this + // change removes. + if (effectiveTrigger !== 'manual' || normalizedPayload.agentType !== source) { + return + } + if ( + isClaudeCompactCompletionConsumed( + this.state.claudeConsumedCompactPromptIdByPaneKey, + paneKey, + providerPromptId + ) || + !canAcceptClaudeCompactCompletion(previousStatus, { + source, + connectionId: trimmedConnectionId, + providerPromptId, + providerSession + }) + ) { + return + } + markClaudeCompactCompletionConsumed( + this.state.claudeConsumedCompactPromptIdByPaneKey, + paneKey, + providerPromptId + ) + // Why: an older relay built this payload before the boundary flag existed, so it arrives as a + // plain `done` — which every completion-reactive consumer reads as a finished turn. Stamp the + // boundary here so a compact stays silent regardless of which relay normalized it. + if (normalizedPayload.sessionBoundary !== true) { + normalizedPayload = { ...normalizedPayload, sessionBoundary: true } + } + acceptedCompactCompletion = true + } + // Why: keyed on "did we accept a completion", not on the trigger surviving the wire — the + // trigger-stripped replay is exactly the shape that arrives without one, and it is still the + // compact's own promptless event, so it still needs the summarized turn's label. + if ( + source === 'claude' && + (compactTrigger !== undefined || acceptedCompactCompletion) && + normalizedPayload.prompt.length === 0 && + previousStatus?.payload.prompt + ) { + normalizedPayload = { ...normalizedPayload, prompt: previousStatus.payload.prompt } + } + if ( + envelope.providerSessionOnly === true && + !isValidPiProviderSessionOnly(providerSession, normalizedPayload.agentType) + ) { + return + } + const applyClaudeBackgroundWork = + normalizedPayload.agentType === 'claude' && + typeof envelope.claudeRunningNonAgentTask === 'boolean' && + // Why: reconnect replay may seed a restarted listener, but cannot override any observation made by this runtime. + (envelope.isReplay !== true || !this.runtimeObservedStatusPaneKeys.has(paneKey)) + // Why: run the HTTP path's warn-once version/env-mismatch diagnostics with this.env as expected. + warnOnHookEnvOrVersionMismatch(this.state, { + version: envelope.version, + env: envelope.env, + expectedEnv: this.env + }) + const event = { + paneKey, + source, + launchToken: statusDisposition === 'restart' ? undefined : envelope.launchToken, + tabId, + worktreeId, + connectionId: trimmedConnectionId, + hasExplicitPrompt: envelope.hasExplicitPrompt === true ? true : undefined, + promptInteractionKey, + hookEventName, + providerPromptId, + compactTrigger, + toolUseId, + toolAgentId, + teammateName, + toolAgentType, + providerSession, + providerSessionOnly: envelope.providerSessionOnly === true ? true : undefined, + isReplay: envelope.isReplay === true ? true : undefined, + claudeRunningNonAgentTask: + typeof envelope.claudeRunningNonAgentTask === 'boolean' + ? envelope.claudeRunningNonAgentTask + : undefined, + payload: normalizedPayload + } as AgentHookEventPayload + this.recordCurrentAuthorityObservation(event) + this.applyNormalizedStatus( + event, + applyClaudeBackgroundWork + ? () => { + if (envelope.claudeRunningNonAgentTask) { + this.state.claudeRunningNonAgentTaskPaneKeys.add(paneKey) + } else { + this.state.claudeRunningNonAgentTaskPaneKeys.delete(paneKey) + } + } + : undefined + ) + } +} diff --git a/src/main/agent-hooks/server/server-ingest-terminal.ts b/src/main/agent-hooks/server/server-ingest-terminal.ts new file mode 100644 index 00000000000..822c7e76f02 --- /dev/null +++ b/src/main/agent-hooks/server/server-ingest-terminal.ts @@ -0,0 +1,104 @@ +import { track } from '../../telemetry/client' +import { MAX_PANE_KEY_LEN } from '../../../shared/agent-hook-listener/listener-limits' +import { parsePaneKey } from '../../../shared/stable-pane-id' +import { terminalStatusPayloadMatchesHook } from '../../../shared/agent-terminal-status-equivalence' +import type { ParsedAgentStatusPayload } from '../../../shared/agent-status-types' +import type { EnrichedAgentHookEventPayload } from './server-types' +import { AgentHookServerIngestNormalization } from './server-ingest-normalization' + +export abstract class AgentHookServerIngestTerminal extends AgentHookServerIngestNormalization { + ingestTerminalStatus(event: { + paneKey: string + tabId?: string + worktreeId?: string + connectionId?: string | null + payload: ParsedAgentStatusPayload + }): void { + const physicalPaneKey = event.paneKey.trim() + const paneKey = this.resolvePaneKeyAlias(physicalPaneKey) + const parsedPaneKey = parsePaneKey(paneKey) + if (paneKey.length === 0) { + track('agent_hook_unattributed', { reason: 'empty_pane_key' }) + return + } + if (paneKey.length > MAX_PANE_KEY_LEN || !parsedPaneKey) { + return + } + const reportedTabId = + event.tabId !== undefined && event.tabId.trim().length > 0 ? event.tabId.trim() : undefined + if ( + paneKey === physicalPaneKey && + reportedTabId !== undefined && + reportedTabId !== parsedPaneKey.tabId + ) { + return + } + const tabId = paneKey !== physicalPaneKey ? parsedPaneKey.tabId : reportedTabId + if (this.getAgentStatusDisposition(paneKey) !== 'accept') { + return + } + const worktreeId = + event.worktreeId !== undefined && event.worktreeId.trim().length > 0 + ? event.worktreeId.trim() + : undefined + const connectionId = + typeof event.connectionId === 'string' && event.connectionId.trim().length > 0 + ? event.connectionId.trim() + : null + const previous = this.state.lastStatusByPaneKey.get(paneKey) as + | EnrichedAgentHookEventPayload + | undefined + if ( + previous?.claudeLeadBoundaryChildOnly === true && + previous.payload.agentType === 'claude' && + event.payload.agentType === 'claude' + ) { + // Why: OSC has no child identity or lead boundary, so it cannot replace a persisted child-only proof before the lifecycle hook arrives. + return + } + // Why: preserve the hook-completed turn stamp while OSC repaints the current state. + const preserveActiveTurnStamp = + previous?.payload.turnCompletedAt !== undefined && + previous.payload.turnCompletedAt === this.activeHookTurnCompletedAtByPaneKey.get(paneKey) + if ( + !previous?.restoredUnconfirmed && + previous?.connectionId === connectionId && + previous.tabId === tabId && + previous.worktreeId === worktreeId && + terminalStatusPayloadMatchesHook(previous.payload, event.payload, preserveActiveTurnStamp) + ) { + return + } + // Why: the OSC 9999 wire payload has no providerSession field at all, so an OSC observation is + // never evidence that the session ended — yet overwriting the row dropped the cached identity. + // That erased it from persisted rows (lost across restart) and from headless `orca serve`, which + // serves these rows to mobile directly instead of the renderer store, blanking Chat UI (#10630). + // A new turn after `done` still starts clean so a reused pane cannot inherit a finished session. + // Why: mirror resolveAgentStatusIdentity, which treats a literal 'unknown' exactly like an + // omitted type — an OSC ping that names no agent makes no claim about the pane's identity, so + // it must not be read as a mismatch and strip the session the renderer would have kept. + const claimedAgentType = + event.payload.agentType && event.payload.agentType !== 'unknown' + ? event.payload.agentType + : undefined + const preservedProviderSession = + previous?.providerSession && + (claimedAgentType === undefined || claimedAgentType === previous.payload.agentType) && + (previous.payload.state !== 'done' || event.payload.state === 'done') + ? previous.providerSession + : undefined + // Why: OSC status is a runtime observation, not a prompt boundary; keep prompt-sent telemetry tied to native hooks. + this.applyNormalizedStatus( + { + paneKey, + tabId, + worktreeId, + connectionId, + ...(preservedProviderSession ? { providerSession: preservedProviderSession } : {}), + payload: event.payload + }, + undefined, + 'osc' + ) + } +} diff --git a/src/main/agent-hooks/server/server-lifecycle.ts b/src/main/agent-hooks/server/server-lifecycle.ts new file mode 100644 index 00000000000..9beb0ad0bbb --- /dev/null +++ b/src/main/agent-hooks/server/server-lifecycle.ts @@ -0,0 +1,197 @@ +import { createServer, type IncomingMessage, type ServerResponse } from 'node:http' +import { randomUUID } from 'node:crypto' + +import { + CLAUDE_STATUSLINE_PATHNAME, + parseClaudeStatusLineBody +} from '../../../shared/claude-statusline-rate-limits' +import { mergeAgentHookRequestHeaders } from '../../../shared/agent-hook-listener/hook-envelope' +import { readRequestBody } from '../../../shared/agent-hook-listener/request-body' +import { resolveHookSource } from '../../../shared/agent-hook-listener/source-routing' +import { HOOK_REQUEST_SLOWLORIS_MS } from '../../../shared/agent-hook-listener/listener-limits' +import { isHookRequestTruncatedError } from '../../../shared/agent-hook-transport-interference' +import { drainAgentHookSpool, type SpoolRecord } from '../../../shared/agent-hook-spool' +import { clearAllListenerCaches } from '../../../shared/agent-hook-listener/listener-state' +import { trackEmptyPaneKeyHook } from './server-transport-rules' +import { AgentHookServerRuntimeEnv } from './server-runtime-env' + +export abstract class AgentHookServerLifecycle extends AgentHookServerRuntimeEnv { + /** Start the loopback listener after hydration and spool replay have settled. */ + async start(options?: { + env?: string + userDataPath?: string + endpointNamespace?: string + }): Promise { + if (this.server) { + return + } + + if (options?.env) { + this.env = options.env + } + if (options?.userDataPath) { + // Why: dev builds share one userData path; namespace per instance while packaged keeps the stable path for PTY reconnect. + this.configureEndpointPaths(options.userDataPath, options.endpointNamespace) + } + this.token = randomUUID() + this.endpointFileWritten = false + this.lastWrittenJson = null + // Why: hydrate before binding the listener so an early hook POST runs against a populated map. + if (this.lastStatusFilePath) { + this.hydrateLastStatusFromDisk() + } + this.captureHydratedAuthorityCommitments() + // Drain before binding the listener so replay cannot race a live hook during startup. + if (this.endpointDir) { + drainAgentHookSpool({ + endpointDir: this.endpointDir, + getPersistedLaunchTokenHash: (paneKey) => + this.hydratedLaunchTokenHashByPaneKey.get(this.resolvePaneKeyAlias(paneKey)), + ingest: (record: SpoolRecord) => this.ingestSpoolRecord(record) + }) + } + const handleRequest = async (req: IncomingMessage, res: ServerResponse): Promise => { + if (req.method !== 'POST') { + res.writeHead(404) + res.end() + return + } + // Why: authenticate before spending work reading an untrusted body. + if (req.headers['x-orca-agent-hook-token'] !== this.token) { + res.writeHead(403) + res.end() + return + } + // Why: bound request time so a stalled client can't hold a socket open (slowloris). + // Why: track our own destroy so the slowloris cap can't be misread as outside interference. + let destroyedBySlowlorisCap = false + req.setTimeout(HOOK_REQUEST_SLOWLORIS_MS, () => { + destroyedBySlowlorisCap = true + req.destroy() + }) + const pathname = new URL(req.url ?? '/', 'http://127.0.0.1').pathname + try { + const body = await readRequestBody(req) + if (pathname === CLAUDE_STATUSLINE_PATHNAME) { + const statusLineEvent = parseClaudeStatusLineBody(body) + if (statusLineEvent) { + this.onClaudeStatusLine?.(statusLineEvent) + } + res.writeHead(204) + res.end() + return + } + const source = resolveHookSource(pathname) + if (!source) { + res.writeHead(404) + res.end() + return + } + // Why: merge transport headers before normalization so relay-compatible fields have one canonical path. + const hookBody = mergeAgentHookRequestHeaders(body, req.headers) + trackEmptyPaneKeyHook(hookBody) + const aliasedBody = this.normalizeHookBodyPaneKeyAlias(hookBody) + const normalized = this.normalizeLocalHookPayload(source, aliasedBody) + const statusDisposition = normalized.event + ? this.getAgentStatusDisposition(normalized.event.paneKey, { + source, + hookEventName: normalized.event.hookEventName, + isReplay: normalized.event.isReplay, + hasExplicitPrompt: normalized.event.hasExplicitPrompt, + launchToken: normalized.event.launchToken + }) + : 'suppress' + if (normalized.event && statusDisposition !== 'suppress') { + const event = + statusDisposition === 'restart' + ? { ...normalized.event, launchToken: undefined } + : normalized.event + if (statusDisposition === 'restart') { + // Why: a retired pane accepting a new turn is a different agent session behind the + // same key — later observations must not be ordered against the retired one. + this.observations.rebind(event.paneKey) + } + this.recordCurrentAuthorityObservation(event) + const enriched = this.applyNormalizedStatus(event, normalized.onAccepted) + this.scheduleAssistantMessageRetry(source, aliasedBody, enriched) + this.scheduleCodexSubagentPoll(source, aliasedBody, enriched) + } + res.writeHead(204) + res.end() + } catch (error) { + // Why (#11217): an authenticated POST whose body dies short of its own Content-Length was cut + // by something on the loopback path, not by a bad payload. Fail open as before, but count it — + // this is the one failure mode that silently stops status for every runtime at once. + if (isHookRequestTruncatedError(error) && !destroyedBySlowlorisCap) { + this.transportInterference.record({ source: resolveHookSource(pathname) ?? null, error }) + } + // Why: fail open — return success on malformed payloads so a broken hook never blocks the agent. + res.writeHead(204) + res.end() + } + } + // Why: node ignores a returned promise, so the handler must settle it itself; handleRequest never rejects. + this.server = createServer((req, res) => { + void handleRequest(req, res) + }) + await new Promise((resolve, reject) => { + const onStartupError = (err: Error): void => { + // Why: swap the startup reject-handler for a logging one so a later runtime 'error' can't crash main as an unhandled event. + this.server?.off('listening', onListening) + reject(err) + } + const onListening = (): void => { + this.server?.off('error', onStartupError) + this.server?.on('error', (err) => { + console.error('[agent-hooks] server error', err) + }) + const address = this.server!.address() + if (address && typeof address === 'object') { + this.port = address.port + } + this.maybeWriteEndpointFile() + resolve() + } + this.server!.once('error', onStartupError) + this.server!.listen(0, '127.0.0.1', onListening) + }) + } + + stop(): void { + // Why: flush the pending debounced write before clearing the map, else a hook <250ms before quit is lost on relaunch. + this.flushStatusPersistSync() + this.server?.close() + this.server = null + this.port = 0 + this.token = '' + this.env = 'production' + this.onAgentStatus = null + this.onPaneStatusCleared = null + for (const timer of this.assistantMessageRetryTimers.values()) { + clearTimeout(timer) + } + this.assistantMessageRetryTimers.clear() + this.clearAllCodexSubagentPolls() + this.endpointDir = null + this.endpointFilePathCache = null + this.endpointFileWritten = false + this.lastStatusFilePath = null + this.lastWrittenJson = null + this.runtimeObservedStatusPaneKeys.clear() + this.hydratedAuthorityCommitments = Object.freeze([]) + this.hydratedLaunchTokenHashByPaneKey.clear() + this.persistedAuthorityCommitmentsByPaneKey.clear() + this.revokedHydratedAuthorityCommitments = new WeakSet() + this.currentAuthorityObservations.clear() + this.promptSentDedupeByPaneKey.clear() + this.closedAgentStatusTabIds.clear() + this.closedAgentStatusPaneKeys.clear() + this.restartedStatusLaunchTokenHashByPaneKey.clear() + this.retiredPaneFencesByKey.clear() + this.connectionTimestampWatermarkById.clear() + this.legacyPaneKeyAliases.clear() + // Why: don't unlink the endpoint file — a stale file matches fail-open and avoids a TOCTOU race with a concurrent Orca. + clearAllListenerCaches(this.state) + this.notifyStatusChangeListeners() + } +} diff --git a/src/main/agent-hooks/server/server-listeners.ts b/src/main/agent-hooks/server/server-listeners.ts new file mode 100644 index 00000000000..08d2ef21a70 --- /dev/null +++ b/src/main/agent-hooks/server/server-listeners.ts @@ -0,0 +1,218 @@ +import type { + AgentStatusClearIpcPayload, + AgentStatusIpcPayload +} from '../../../shared/agent-status-types' +import type { ClaudeStatusLineRateLimits } from '../../../shared/claude-statusline-rate-limits' +import type { HookTransportInterferenceReport } from '../../../shared/agent-hook-transport-interference' +import type { HookListenerState } from '../../../shared/agent-hook-listener/listener-state' +import type { + AgentHookAuthorityEvidence, + AgentHookProviderSessionIdentity, + AgentHookStatusChangeEntry, + EnrichedAgentHookEventPayload, + StatusDropListener +} from './server-types' +import { toAgentStatusIpcPayload } from './server-status-identity' +import { AgentHookServerState } from './server-state' + +export abstract class AgentHookServerListeners extends AgentHookServerState { + /** + * Notified once per process when repeated hook POSTs are cut off mid-body (#11217). + * Why: the listener fails open on every request error, so without this the only symptom is + * agent status quietly going stale — for every runtime at once, since they share this transport. + */ + setTransportInterferenceListener( + listener: ((report: HookTransportInterferenceReport) => void) | null + ): void { + this.onTransportInterference = listener + } + + setListener(listener: ((payload: EnrichedAgentHookEventPayload) => void) | null): void { + this.onAgentStatus = listener + if (!listener) { + return + } + // Why: replay is best-effort per pane so one throwing listener can't starve the rest. + for (const payload of this.state.lastStatusByPaneKey.values()) { + try { + // Why: cache always holds enriched payloads; the map's declared type is the bare shape only because the shared module never reads it. + listener({ ...(payload as EnrichedAgentHookEventPayload), isReplay: true }) + } catch (err) { + console.error('[agent-hooks] replay listener threw', err) + } + } + } + + // Why: statusline posts carry live Claude usage windows, not agent status; they feed RateLimitService directly. + setClaudeStatusLineListener( + listener: ((event: ClaudeStatusLineRateLimits) => void) | null + ): void { + this.onClaudeStatusLine = listener + } + + subscribeStatusChanges(listener: (statuses: AgentHookStatusChangeEntry[]) => void): () => void { + this.statusChangeListeners.add(listener) + return () => { + this.statusChangeListeners.delete(listener) + } + } + + subscribeProviderSessionChanges( + listener: (providerSessions: AgentHookProviderSessionIdentity[]) => void + ): () => void { + this.providerSessionChangeListeners.add(listener) + return () => { + this.providerSessionChangeListeners.delete(listener) + } + } + + /** Multi-subscriber tap on definitive live-row deletions. `dropStatusEntry` is a user + * dismissal, so it never routes through the pane-status-clear fan-out — pane-owned + * cleanup (synthetic spinners) still has to retire with the row it was driving. */ + subscribeStatusDrop(listener: StatusDropListener): () => void { + this.statusDropListeners.add(listener) + return () => { + this.statusDropListeners.delete(listener) + } + } + + protected emitStatusDropped(paneKey: string): void { + for (const listener of this.statusDropListeners) { + // Why: matches every other fan-out here — one throwing subscriber must not strand the rest. + try { + listener(paneKey) + } catch (err) { + console.error('[agent-hooks] status-drop listener threw', err) + } + } + } + + /** Multi-subscriber tap on every enriched status change (no replay). */ + subscribeEnrichedStatus(listener: (payload: EnrichedAgentHookEventPayload) => void): () => void { + this.enrichedStatusListeners.add(listener) + return () => { + this.enrichedStatusListeners.delete(listener) + } + } + + /** Replay is durable evidence from a prior runtime, not a live observation. */ + protected withdrawReplayObservation(paneKey: string): void { + if (this.runtimeObservedStatusPaneKeys.delete(paneKey)) { + this.notifyStatusChangeListeners() + } + } + + setPaneStatusClearListener(listener: ((clear: AgentStatusClearIpcPayload) => void) | null): void { + this.onPaneStatusCleared = listener + } + + /** Multi-subscriber tap on pane status clears. Unlike `setPaneStatusClearListener` + * (a single slot the main window owns and drops on close) this survives window + * teardown and exists at all under headless serve, which never opens one. */ + subscribePaneStatusClear(listener: (clear: AgentStatusClearIpcPayload) => void): () => void { + this.paneStatusClearListeners.add(listener) + return () => { + this.paneStatusClearListeners.delete(listener) + } + } + + protected emitPaneStatusCleared(clear: AgentStatusClearIpcPayload): void { + this.onPaneStatusCleared?.(clear) + for (const listener of this.paneStatusClearListeners) { + // Why: callers are pane/connection teardown paths; one throwing subscriber must + // not strand the rest, matching every other fan-out here. + try { + listener(clear) + } catch (err) { + console.error('[agent-hooks] pane-status-clear listener threw', err) + } + } + } + + /** Snapshot of cached statuses in IPC shape. Used by `agentStatus:getSnapshot` after tabs hydrate so the + * dashboard catches up on hook events that fired during startup. */ + getStatusSnapshot(): AgentStatusIpcPayload[] { + return Array.from(this.state.lastStatusByPaneKey.values(), (entry) => + toAgentStatusIpcPayload(entry as EnrichedAgentHookEventPayload) + ) + } + + /** Provider-session identities, including Pi's metadata-only rows. */ + getProviderSessionIdentities(): AgentHookProviderSessionIdentity[] { + return this.buildStatusChangeNotification().providerSessions + } + + getStatusSnapshotForPane(paneKey: string): AgentStatusIpcPayload[] { + const entry = this.state.lastStatusByPaneKey.get(paneKey) + return entry ? [toAgentStatusIpcPayload(entry as EnrichedAgentHookEventPayload)] : [] + } + + getHydratedAuthorityCommitments(): readonly AgentHookAuthorityEvidence[] { + return this.hydratedAuthorityCommitments + } + + getCurrentAuthorityObservations(): readonly AgentHookAuthorityEvidence[] { + return Object.freeze( + Array.from(this.currentAuthorityObservations.values(), (entry) => Object.freeze({ ...entry })) + ) + } + + protected buildStatusChangeNotification(): { + statuses: AgentHookStatusChangeEntry[] + providerSessions: AgentHookProviderSessionIdentity[] + } { + const statuses: AgentHookStatusChangeEntry[] = [] + const providerSessions: AgentHookProviderSessionIdentity[] = [] + for (const [paneKey, entry] of this.state.lastStatusByPaneKey) { + const enriched = entry as EnrichedAgentHookEventPayload + if (enriched.providerSession) { + providerSessions.push({ + paneKey, + sessionId: enriched.providerSession.id, + ...(enriched.providerSession.transcriptPath + ? { transcriptPath: enriched.providerSession.transcriptPath } + : {}), + ...(enriched.worktreeId ? { worktreeId: enriched.worktreeId } : {}) + }) + } + if (!enriched.providerSessionOnly) { + statuses.push({ + state: enriched.payload.state, + receivedAt: enriched.receivedAt, + observedInCurrentRuntime: this.runtimeObservedStatusPaneKeys.has(paneKey) + }) + } + } + return { statuses, providerSessions } + } + + protected notifyStatusChangeListeners(): void { + if (this.statusChangeListeners.size === 0 && this.providerSessionChangeListeners.size === 0) { + return + } + const { statuses, providerSessions } = this.buildStatusChangeNotification() + for (const listener of this.statusChangeListeners) { + try { + listener(statuses) + } catch (err) { + console.error('[agent-hooks] status-change listener threw', err) + } + } + for (const listener of this.providerSessionChangeListeners) { + try { + listener(providerSessions) + } catch (err) { + console.error('[agent-hooks] provider-session listener threw', err) + } + } + } + + getStatusChangeSnapshot(): AgentHookStatusChangeEntry[] { + return this.buildStatusChangeNotification().statuses + } + + /** Test-only accessor for the per-instance listener state (narrow getter avoids an `as unknown` cast). */ + _getStateForTests(): HookListenerState { + return this.state + } +} diff --git a/src/main/agent-hooks/server/server-persistence-validation.ts b/src/main/agent-hooks/server/server-persistence-validation.ts new file mode 100644 index 00000000000..061646731ee --- /dev/null +++ b/src/main/agent-hooks/server/server-persistence-validation.ts @@ -0,0 +1,183 @@ +import { createHash } from 'node:crypto' + +import { normalizeAgentProviderSession } from '../../../shared/agent-session-resume' +import { + normalizeAgentStatusPayload, + type ParsedAgentStatusPayload +} from '../../../shared/agent-status-types' +import { isAgentHookSource } from '../../../shared/agent-hook-relay' +import { normalizeClaudePromptId } from '../../../shared/agent-hook-listener/listener-limits' +import { parsePaneKey } from '../../../shared/stable-pane-id' +import type { AgentHookAuthorityEvidence, EnrichedAgentHookEventPayload } from './server-types' +import { isValidPaneKey, isValidPiProviderSessionOnly } from './server-status-identity' + +export function dropHydratedIdleClaudeSubagents( + payload: ParsedAgentStatusPayload +): ParsedAgentStatusPayload { + if ( + payload.agentType !== 'claude' || + !payload.subagents?.some((subagent) => subagent.state === 'idle') + ) { + return payload + } + const activeSubagents = payload.subagents.filter((subagent) => subagent.state !== 'idle') + // Why: an idle teammate's liveness can't be proven across a restart (its TeammateIdle confirmation is in-memory); prune so a dead pile can't resurrect — a live teammate re-earns its row via SubagentStart. + return { + ...payload, + subagents: activeSubagents.length > 0 ? activeSubagents : undefined + } +} + +export function sanitizeHydratedEntry( + paneKey: string, + rawEntry: unknown +): EnrichedAgentHookEventPayload | null { + const parsedPaneKey = parsePaneKey(paneKey) + if (!parsedPaneKey) { + return null + } + if (typeof rawEntry !== 'object' || rawEntry === null) { + return null + } + const record = rawEntry as Record + if (record.paneKey !== paneKey) { + return null + } + const tabId = record.tabId + if (tabId !== undefined && (typeof tabId !== 'string' || tabId.length === 0)) { + return null + } + // Why: a stored tabId that diverges from the paneKey's tab segment is corruption; drop instead of hydrating an inconsistent row. + if (typeof tabId === 'string' && tabId !== parsedPaneKey.tabId) { + return null + } + const worktreeId = record.worktreeId + if (worktreeId !== undefined && (typeof worktreeId !== 'string' || worktreeId.length === 0)) { + return null + } + const receivedAt = record.receivedAt + if (typeof receivedAt !== 'number' || !Number.isFinite(receivedAt) || receivedAt <= 0) { + return null + } + const stateStartedAt = record.stateStartedAt + if ( + typeof stateStartedAt !== 'number' || + !Number.isFinite(stateStartedAt) || + stateStartedAt <= 0 + ) { + return null + } + // Why: connectionId is null (local) or string (relay); any other shape is rejected to keep the typed surface honest. + const connectionIdRaw = record.connectionId + let connectionId: string | null + if (connectionIdRaw === null || connectionIdRaw === undefined) { + connectionId = null + } else if (typeof connectionIdRaw === 'string') { + connectionId = connectionIdRaw + } else { + return null + } + const payload = normalizeAgentStatusPayload(record.payload) + if (!payload) { + return null + } + const providerSession = normalizeAgentProviderSession(record.providerSession) ?? undefined + const providerSessionOnly = record.providerSessionOnly === true + const retainedForLiveness = record.retainedForLiveness === true + const validRetainedIdentity = Boolean( + retainedForLiveness && providerSession && payload.agentType && payload.agentType !== 'unknown' + ) + if ( + providerSessionOnly && + !isValidPiProviderSessionOnly(providerSession, payload.agentType) && + !validRetainedIdentity + ) { + return null + } + const source = isAgentHookSource(record.source) ? record.source : undefined + const providerPromptId = + source === 'claude' ? normalizeClaudePromptId(record.providerPromptId) : undefined + const compactTrigger = + source === 'claude' && (record.compactTrigger === 'manual' || record.compactTrigger === 'auto') + ? record.compactTrigger + : undefined + return { + paneKey, + source, + tabId: typeof tabId === 'string' ? tabId : undefined, + worktreeId: typeof worktreeId === 'string' ? worktreeId : undefined, + connectionId, + hasExplicitPrompt: record.hasExplicitPrompt === true ? true : undefined, + hookEventName: typeof record.hookEventName === 'string' ? record.hookEventName : undefined, + providerPromptId, + compactTrigger, + toolUseId: typeof record.toolUseId === 'string' ? record.toolUseId : undefined, + toolAgentId: typeof record.toolAgentId === 'string' ? record.toolAgentId : undefined, + teammateName: typeof record.teammateName === 'string' ? record.teammateName : undefined, + toolAgentType: typeof record.toolAgentType === 'string' ? record.toolAgentType : undefined, + claudeLeadBoundaryChildOnly: record.claudeLeadBoundaryChildOnly === true ? true : undefined, + providerSession, + providerSessionOnly: providerSessionOnly ? true : undefined, + retainedForLiveness: retainedForLiveness ? true : undefined, + payload, + receivedAt, + stateStartedAt + } +} + +export function readPersistedLaunchTokenHash(rawEntry: unknown): string | null { + if (typeof rawEntry !== 'object' || rawEntry === null) { + return null + } + const record = rawEntry as Record + const launchTokenHash = + typeof record.launchTokenHash === 'string' ? record.launchTokenHash.trim() : '' + if (/^[a-f0-9]{64}$/.test(launchTokenHash)) { + return launchTokenHash + } + const legacyLaunchToken = typeof record.launchToken === 'string' ? record.launchToken.trim() : '' + return legacyLaunchToken ? createHash('sha256').update(legacyLaunchToken).digest('hex') : null +} + +export function sanitizePersistedAuthorityCommitment( + paneKey: string, + value: unknown +): AgentHookAuthorityEvidence | null { + if (!isValidPaneKey(paneKey) || typeof value !== 'object' || value === null) { + return null + } + const record = value as Record + const launchTokenHash = + typeof record.launchTokenHash === 'string' ? record.launchTokenHash.trim() : '' + const connectionId = record.connectionId + const observedAt = record.observedAt + if ( + !/^[a-f0-9]{64}$/.test(launchTokenHash) || + (connectionId !== null && typeof connectionId !== 'string') || + typeof observedAt !== 'number' || + !Number.isFinite(observedAt) + ) { + return null + } + return Object.freeze({ + paneKey, + launchTokenHash, + connectionId: connectionId as string | null, + ...(typeof record.tabId === 'string' ? { tabId: record.tabId } : {}), + ...(typeof record.worktreeId === 'string' ? { worktreeId: record.worktreeId } : {}), + observedAt + }) +} + +export function authorityCommitmentsMatch( + left: AgentHookAuthorityEvidence, + right: AgentHookAuthorityEvidence +): boolean { + return ( + left.paneKey === right.paneKey && + left.launchTokenHash === right.launchTokenHash && + left.connectionId === right.connectionId && + left.tabId === right.tabId && + left.worktreeId === right.worktreeId + ) +} diff --git a/src/main/agent-hooks/server/server-persistence.ts b/src/main/agent-hooks/server/server-persistence.ts new file mode 100644 index 00000000000..ecb33c44d37 --- /dev/null +++ b/src/main/agent-hooks/server/server-persistence.ts @@ -0,0 +1,141 @@ +import { chmodSync, mkdirSync, renameSync, unlinkSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { createHash, randomUUID } from 'node:crypto' + +import { isValidPaneKey } from './server-status-identity' +import { LAST_STATUS_FILE_VERSION, STATUS_PERSIST_DEBOUNCE_MS } from './server-constants' +import type { + EnrichedAgentHookEventPayload, + LastStatusFile, + PersistedAgentHookAuthorityCommitment, + PersistedAgentHookEventPayload +} from './server-types' +import { authorityCommitmentsMatch } from './server-persistence-validation' +import { AgentHookServerHydration } from './server-hydration' + +export abstract class AgentHookServerPersistence extends AgentHookServerHydration { + protected serializeStatusFile(): string { + const entries: Record = {} + const authorityCommitments: Record = {} + const conflictedCommitments = new Set() + for (const [paneKey, commitment] of this.persistedAuthorityCommitmentsByPaneKey) { + authorityCommitments[paneKey] = { ...commitment } + } + for (const [paneKey, payload] of this.state.lastStatusByPaneKey) { + // Why: never persist invalid keys (matches the hydrate-path invariant). + if (!isValidPaneKey(paneKey)) { + continue + } + const enrichedPayload = payload as EnrichedAgentHookEventPayload + const childOnlyBoundary = enrichedPayload.claudeLeadBoundaryChildOnly === true + const { + claudeRunningNonAgentTask: _claudeRunningNonAgentTask, + promptInteractionKey: _promptInteractionKey, + // Why: never persisted — hydrate re-stamps it, so a stored copy could only drift. + restoredUnconfirmed: _restoredUnconfirmed, + // Why: same — the sequencer that issued it dies with the process (see PersistedAgentHookEventPayload). + observation: _observation, + // Replay provenance is runtime-only and must not survive another restart. + isReplay: _isReplay, + launchToken, + ...persistedPayload + } = enrichedPayload + const launchTokenHash = launchToken?.trim() + ? createHash('sha256').update(launchToken.trim()).digest('hex') + : this.hydratedLaunchTokenHashByPaneKey.get(paneKey) + entries[paneKey] = { + ...persistedPayload, + ...(childOnlyBoundary ? { claudeLeadBoundaryChildOnly: true } : {}), + ...(launchTokenHash ? { launchTokenHash } : {}) + } + const commitment = this.toAuthorityEvidence(payload, launchTokenHash) + if (commitment && !conflictedCommitments.has(paneKey)) { + const existing = authorityCommitments[paneKey] + if (existing && !authorityCommitmentsMatch(existing, commitment)) { + delete authorityCommitments[paneKey] + conflictedCommitments.add(paneKey) + } else { + authorityCommitments[paneKey] = { ...commitment } + } + } + } + const file: LastStatusFile = { + version: LAST_STATUS_FILE_VERSION, + entries, + authorityCommitments + } + return JSON.stringify(file) + } + + protected scheduleStatusPersist(): void { + if (!this.lastStatusFilePath) { + return + } + // Why: reset the timer each call so the write fires only after the last event in a burst. + if (this.statusPersistTimer) { + clearTimeout(this.statusPersistTimer) + } + this.statusPersistTimer = setTimeout(() => { + this.statusPersistTimer = null + this.runStatusPersist() + }, STATUS_PERSIST_DEBOUNCE_MS) + // Why: don't keep the event loop alive just for a status flush — quit already flushes sync. + if (typeof this.statusPersistTimer.unref === 'function') { + this.statusPersistTimer.unref() + } + } + + flushStatusPersistSync(): void { + if (this.statusPersistTimer) { + clearTimeout(this.statusPersistTimer) + this.statusPersistTimer = null + } + if (!this.lastStatusFilePath) { + return + } + this.runStatusPersist() + } + + protected runStatusPersist(): void { + if (!this.lastStatusFilePath || !this.endpointDir) { + return + } + const json = this.serializeStatusFile() + if (json === this.lastWrittenJson) { + return + } + const tmpPath = join(this.endpointDir, `.last-status-${process.pid}-${randomUUID()}.tmp`) + let tmpWritten = false + try { + mkdirSync(this.endpointDir, { recursive: true, mode: 0o700 }) + if (process.platform !== 'win32') { + try { + chmodSync(this.endpointDir, 0o700) + } catch { + // best-effort + } + } + writeFileSync(tmpPath, json, { mode: 0o600 }) + tmpWritten = true + renameSync(tmpPath, this.lastStatusFilePath) + this.lastWrittenJson = json + } catch (err) { + console.warn('[agent-hooks] failed to write last-status file:', err) + if (tmpWritten) { + try { + unlinkSync(tmpPath) + } catch { + // tmp already gone + } + } + } + } + + _resetPromptSentDedupeForTests(): void { + this.promptSentDedupeByPaneKey.clear() + } + + _resetConnectionTimestampWatermarksForTests(): void { + this.connectionTimestampWatermarkById.clear() + } +} diff --git a/src/main/agent-hooks/server/server-reaping.ts b/src/main/agent-hooks/server/server-reaping.ts new file mode 100644 index 00000000000..7805303ced1 --- /dev/null +++ b/src/main/agent-hooks/server/server-reaping.ts @@ -0,0 +1,124 @@ +import { + claudeRosterHasRestoredSnapshotSubagent, + claudeRosterHasWorkingSubagent, + claudeRosterToSnapshots +} from '../../../shared/claude-subagent-roster' +import { reapRestoredClaudeSubagentsForDeadPane } from '../../../shared/agent-hook-listener/providers/claude-roster-state' +import { AgentHookServerTabCleanup } from './server-tab-cleanup' +import type { EnrichedAgentHookEventPayload } from './server-types' + +export abstract class AgentHookServerReaping extends AgentHookServerTabCleanup { + /** Second reap path for restored Claude subagent rows: drop the ones whose pane + * has no live local agent process behind it any more. A PTY that dies while Orca + * is down never runs the teardown that clears pane state, so hydrate rebuilds a + * roster nothing can ever retire — the inventory reap needs the parent to emit a + * complete `background_tasks` list and an idle parent never does. The row then + * gates the pane 'working' for the rest of its life and hibernation, which + * requires 'done', can never reclaim the agent's heap. + * + * Both the execution host and relay binding must prove local ownership before + * targeted PTY liveness is consulted. Panes that reported in this runtime are + * also skipped. Returns the number of panes changed. */ + async reapRestoredClaudeSubagentsWithoutLiveAgent( + isLocalExecutionHost: (worktreeId: string | undefined) => boolean, + isLocalPaneAgentLive: (paneKey: string) => Promise, + isLocalPaneLivenessEvidenceCurrent: (paneKey: string) => boolean + ): Promise { + const candidates: { paneKey: string; entry: EnrichedAgentHookEventPayload }[] = [] + for (const [paneKey, entry] of this.state.lastStatusByPaneKey) { + const enriched = entry as EnrichedAgentHookEventPayload + if ( + enriched.payload.agentType === 'claude' && + enriched.connectionId === null && + isLocalExecutionHost(enriched.worktreeId) && + // Why: a restored roster is only one shape of stranded claim. A lead row left non-terminal, + // or a background-task/cron latch nothing will refresh, strands the pane just as + // permanently — and unlike the roster case there is no child event left to reap it. + (claudeRosterHasRestoredSnapshotSubagent( + this.state.claudeSubagentRosterByPaneKey.get(paneKey) + ) || + enriched.payload.state !== 'done' || + this.state.claudeRunningNonAgentTaskPaneKeys.has(paneKey) || + this.state.claudeActiveSessionCronPaneKeys.has(paneKey)) && + !this.runtimeObservedStatusPaneKeys.has(paneKey) + ) { + candidates.push({ paneKey, entry: enriched }) + } + } + const liveness = await Promise.all( + candidates.map(async (candidate) => { + try { + return await isLocalPaneAgentLive(candidate.paneKey) + } catch { + return true + } + }) + ) + let changedPanes = 0 + for (const [index, candidate] of candidates.entries()) { + const { paneKey, entry: enriched } = candidate + if ( + liveness[index] || + !isLocalPaneLivenessEvidenceCurrent(paneKey) || + this.state.lastStatusByPaneKey.get(paneKey) !== enriched || + this.runtimeObservedStatusPaneKeys.has(paneKey) || + !isLocalExecutionHost(enriched.worktreeId) + ) { + continue + } + if (!reapRestoredClaudeSubagentsForDeadPane(this.state, paneKey)) { + // Why: the roster reap only speaks for restored child rows. A pane whose PTY is provably + // gone and whose claim is a lead row or a latch has nothing for it to reap, so retire the + // pane the same way an observed exit would — otherwise the widened candidate set is inert. + // + // Why delete rather than downgrade to `done` like the reap branch below: that branch has a + // real turn to describe — a parent whose children it just reaped — while these panes' only + // claim IS the stale non-terminal row. Rewriting a `waiting`/`blocked` row to `done` would + // invent a completion that never happened, and leaving it non-terminal keeps the bug. This + // sweep stands in for the exit Orca never observed, so it does what that exit does: + // `clearProviderPtyState` -> `clearPaneState`. + if (this.hasLiveClaimsForPaneKey(paneKey)) { + this.clearPaneState(paneKey) + changedPanes += 1 + } + continue + } + changedPanes += 1 + const roster = this.state.claudeSubagentRosterByPaneKey.get(paneKey) + const subagents = claudeRosterToSnapshots(roster) + // Why: the pane's persisted 'working' was the child gate holding a finished + // lead open (subagent events never set lead state). With the last working row + // gone and no process left to report, 'done' is the only truthful state — and + // the one hibernation needs once this pane's agent is restored. + const state = + enriched.payload.state === 'working' && !claudeRosterHasWorkingSubagent(roster) + ? 'done' + : enriched.payload.state + const stateChanged = state !== enriched.payload.state + const reconciledAt = stateChanged + ? Math.max(Date.now(), enriched.receivedAt + 1) + : enriched.receivedAt + // Why: a reconciled `done` is process-probe-verified, not hydrated guesswork — carrying + // restoredUnconfirmed onto it would make freshness gates suppress a legitimate completion. + const { restoredUnconfirmed, ...reconciledBase } = enriched + const reconciled: EnrichedAgentHookEventPayload = { + ...reconciledBase, + ...(state !== 'done' && restoredUnconfirmed ? { restoredUnconfirmed: true } : {}), + receivedAt: reconciledAt, + stateStartedAt: stateChanged ? reconciledAt : enriched.stateStartedAt, + payload: { + ...enriched.payload, + state, + workingMode: state === 'working' ? enriched.payload.workingMode : undefined, + subagents + } + } + this.state.lastStatusByPaneKey.set(paneKey, reconciled) + } + if (changedPanes > 0) { + this.scheduleStatusPersist() + this.notifyStatusChangeListeners() + } + return changedPanes + } +} diff --git a/src/main/agent-hooks/server/server-runtime-env.ts b/src/main/agent-hooks/server/server-runtime-env.ts new file mode 100644 index 00000000000..e5b9e4c1363 --- /dev/null +++ b/src/main/agent-hooks/server/server-runtime-env.ts @@ -0,0 +1,63 @@ +import { join } from 'node:path' +import { + getEndpointFileName, + writeEndpointFile +} from '../../../shared/agent-hook-listener/endpoint-publication' +import { + ORCA_HOOK_PROTOCOL_VERSION, + ORCA_HOOK_RAW_JSON_TRANSPORT +} from '../../../shared/agent-hook-types' +import { AgentHookServerIngestRemote } from './server-ingest-remote' + +export abstract class AgentHookServerRuntimeEnv extends AgentHookServerIngestRemote { + buildPtyEnv(): Record { + if (this.port <= 0 || !this.token) { + return {} + } + const env: Record = { + ORCA_AGENT_HOOK_PORT: String(this.port), + ORCA_AGENT_HOOK_TOKEN: this.token, + ORCA_AGENT_HOOK_ENV: this.env, + ORCA_AGENT_HOOK_VERSION: ORCA_HOOK_PROTOCOL_VERSION, + ORCA_AGENT_HOOK_TRANSPORT: ORCA_HOOK_RAW_JSON_TRANSPORT + } + // Why: hooks source this file at invocation; dev namespaces it so parallel `pnpm dev` runs don't steal each other's hooks. + if (this.endpointFileWritten && this.endpointFilePathCache) { + env.ORCA_AGENT_HOOK_ENDPOINT = this.endpointFilePathCache + } + return env + } + + get endpointFilePath(): string | null { + return this.endpointFilePathCache + } + + /** Test/diagnostic accessor for the on-disk last-status file path. */ + get lastStatusPath(): string | null { + return this.lastStatusFilePath + } + + protected maybeWriteEndpointFile(): void { + if (!this.endpointDir || !this.endpointFilePathCache) { + return + } + this.endpointFileWritten = false + const ok = writeEndpointFile(this.endpointDir, this.endpointFilePathCache, { + port: this.port, + token: this.token, + env: this.env, + version: ORCA_HOOK_PROTOCOL_VERSION, + transport: ORCA_HOOK_RAW_JSON_TRANSPORT + }) + this.endpointFileWritten = ok + } + + protected configureEndpointPaths(userDataPath: string, endpointNamespace?: string): void { + // Why: dev builds share one userData path; namespace per instance while packaged keeps the stable path for PTY reconnect. + this.endpointDir = endpointNamespace + ? join(userDataPath, 'agent-hooks', endpointNamespace) + : join(userDataPath, 'agent-hooks') + this.endpointFilePathCache = join(this.endpointDir, getEndpointFileName()) + this.lastStatusFilePath = join(this.endpointDir, 'last-status.json') + } +} diff --git a/src/main/agent-hooks/server/server-state.ts b/src/main/agent-hooks/server/server-state.ts new file mode 100644 index 00000000000..956be136ca6 --- /dev/null +++ b/src/main/agent-hooks/server/server-state.ts @@ -0,0 +1,217 @@ +import type { createServer } from 'node:http' +import { randomBytes } from 'node:crypto' + +import { + createHookListenerState, + type HookListenerState +} from '../../../shared/agent-hook-listener/listener-state' +import { + createHookTransportInterferenceTracker, + describeHookTransportInterference, + type HookTransportInterferenceReport +} from '../../../shared/agent-hook-transport-interference' +import { + AgentStatusObservationSequencer, + createAgentStatusAuthorityId, + type AgentStatusObservation, + type AgentStatusObservationOrigin +} from '../../../shared/agent-status-observation' +import type { AgentHookEventPayload } from '../../../shared/agent-hook-listener/listener-event' +import type { AgentHookSource } from '../../../shared/agent-hook-relay' +import type { AgentStatusClearIpcPayload } from '../../../shared/agent-status-types' +import type { LegacyPaneKeyAliasEntry } from '../../../shared/persisted-state-types' +import type { SpoolRecord } from '../../../shared/agent-hook-spool' +import type { + AgentHookAuthorityEvidence, + AgentHookProviderSessionIdentity, + AgentHookStatusChangeEntry, + AgentPromptSentDedupeEntry, + EnrichedAgentHookEventPayload, + NormalizedLocalHook, + PaneKeyAliasEntry, + PaneKeyAliasPersistenceListener, + PaneStatusClearListener, + ProviderSessionChangeListener, + RetiredPaneAlias, + RetiredPaneFence, + ServerAgentStatusListener, + ServerStatusLineListener, + StatusChangeListener, + StatusDropListener +} from './server-types' + +/** Shared mutable state for the layered hook-server implementation. */ +export abstract class AgentHookServerState { + protected server: ReturnType | null = null + protected port = 0 + protected token = '' + // Why: identifies this Orca instance so the server can detect dev vs. prod cross-talk; set at start() from packaged-build knowledge. + protected env = 'production' + protected onAgentStatus: ServerAgentStatusListener = null + protected onClaudeStatusLine: ServerStatusLineListener = null + protected onPaneStatusCleared: PaneStatusClearListener | null = null + protected paneStatusClearListeners = new Set() + protected statusDropListeners = new Set() + protected statusChangeListeners = new Set() + protected providerSessionChangeListeners = new Set() + // Why: setListener is a single slot owned by the main-window fanout; the + // plugin event bus (and future consumers) need an additive subscription + // that also works in headless serve, where no window listener exists. + protected enrichedStatusListeners = new Set<(payload: EnrichedAgentHookEventPayload) => void>() + // Why: set via start()'s userDataPath so the class has no direct Electron dependency (mockable in vitest node env). + protected endpointDir: string | null = null + protected endpointFilePathCache: string | null = null + protected endpointFileWritten = false + // Why: per-instance (not module-level) so tests can spin up multiple servers without state cross-contamination. + protected state: HookListenerState = createHookListenerState() + protected onTransportInterference: ((report: HookTransportInterferenceReport) => void) | null = + null + protected transportInterference = createHookTransportInterferenceTracker( + (report: HookTransportInterferenceReport) => { + console.warn(describeHookTransportInterference(report)) + this.onTransportInterference?.(report) + } + ) + // Why: hydrated rows give UI continuity but aren't evidence of live agent work in this runtime. + protected runtimeObservedStatusPaneKeys = new Set() + protected hydratedAuthorityCommitments: readonly AgentHookAuthorityEvidence[] = Object.freeze([]) + protected hydratedLaunchTokenHashByPaneKey = new Map() + protected persistedAuthorityCommitmentsByPaneKey = new Map() + protected revokedHydratedAuthorityCommitments = new WeakSet() + protected currentAuthorityObservations = new Map() + protected legacyPaneKeyAliases = new Map() + // Why: indexed by every key the retirement fenced, so a re-attach on any of them + // (owner, physical, or a deleted alias) finds the same record. Bounded like the maps + // it mirrors; an evicted record simply degrades to lifting the key it was handed. + protected retiredPaneFencesByKey = new Map() + protected paneKeyAliasPersistenceListener: PaneKeyAliasPersistenceListener | null = null + // Why: on-disk last-status cache path; null without a userDataPath (tests), where persistence is a no-op and only in-memory replay applies. + protected lastStatusFilePath: string | null = null + // Why: trailing-edge debounce timer, per-instance so test servers in one process don't share state. + protected statusPersistTimer: ReturnType | null = null + protected assistantMessageRetryTimers = new Map>() + protected promptSentDedupeByPaneKey = new Map() + protected activeHookTurnCompletedAtByPaneKey = new Map() + protected promptSentHashSalt = randomBytes(16).toString('hex') + protected closedAgentStatusTabIds = new Set() + protected closedAgentStatusPaneKeys = new Set() + protected restartedStatusLaunchTokenHashByPaneKey = new Map() + protected connectionTimestampWatermarkById = new Map() + // Why: skip disk writes when the JSON exactly matches the last write; guards against re-firing trailing timers when nothing changed. + protected lastWrittenJson: string | null = null + // Why: main is the pane authority for local/WSL/SSH panes — hook HTTP, relay, and its own + // OSC parse all converge on applyNormalizedStatus, so one sequencer covers every ingress here. + protected readonly observations = new AgentStatusObservationSequencer( + createAgentStatusAuthorityId('main-agent-hooks') + ) + + protected abstract withdrawReplayObservation(paneKey: string): void + protected abstract ingestSpoolRecord(record: SpoolRecord): void + protected abstract emitPaneStatusCleared(clear: AgentStatusClearIpcPayload): void + protected abstract buildStatusChangeNotification(): { + statuses: AgentHookStatusChangeEntry[] + providerSessions: AgentHookProviderSessionIdentity[] + } + protected abstract notifyStatusChangeListeners(): void + protected abstract markTabClosedForAgentStatus(tabId: string): void + protected abstract getAgentStatusDisposition( + paneKey: string, + event?: { + source?: AgentHookSource + rawSource?: unknown + hookEventName?: string + isReplay?: boolean + hasExplicitPrompt?: boolean + launchToken?: string + } + ): 'accept' | 'restart' | 'suppress' + protected abstract isClosedAgentStatusTabForPaneKey(paneKey: string): boolean + protected abstract recordRetiredPaneFence( + paneKeys: ReadonlySet, + aliases: readonly RetiredPaneAlias[] + ): void + protected abstract markPaneClosedForAgentStatus(paneKey: string): void + protected abstract attachStatusTiming( + payload: AgentHookEventPayload, + now?: number + ): EnrichedAgentHookEventPayload + protected abstract hashPromptForTelemetryDedupe(prompt: string): string + protected abstract maybeTrackAgentPromptSent( + payload: AgentHookEventPayload, + previousStatus: EnrichedAgentHookEventPayload | undefined + ): void + protected abstract stampObservation( + payload: AgentHookEventPayload, + origin: AgentStatusObservationOrigin, + observedAt: number + ): AgentStatusObservation + protected abstract applyNormalizedStatus( + payload: AgentHookEventPayload, + onAccepted?: () => void, + origin?: AgentStatusObservationOrigin + ): EnrichedAgentHookEventPayload + protected abstract emitEnrichedStatus(enriched: EnrichedAgentHookEventPayload): void + protected abstract clearAssistantMessageRetry(paneKey: string): void + protected abstract clearCodexSubagentPoll(paneKey: string): void + protected abstract clearAllCodexSubagentPolls(): void + protected abstract scheduleCodexSubagentPoll( + source: AgentHookSource, + body: unknown, + original: EnrichedAgentHookEventPayload + ): void + protected abstract scheduleAssistantMessageRetry( + source: AgentHookSource, + body: unknown, + original: EnrichedAgentHookEventPayload, + attempt?: number, + discoveryReady?: boolean + ): void + protected abstract applyAssistantMessageRetry( + source: AgentHookSource, + body: unknown, + original: EnrichedAgentHookEventPayload, + nextAttempt: number, + requireExactOriginal: boolean + ): void + protected abstract getPersistedPaneKeyAliases(): LegacyPaneKeyAliasEntry[] + protected abstract notifyPaneKeyAliasPersistenceListener(): void + protected abstract boundPaneKeyAliases(): void + protected abstract getPhysicalPaneKeyForAuthority(paneKey: string, ptyId?: string): string + protected abstract restoreRetiredPaneFence(fence: RetiredPaneFence): void + protected abstract revokeHydratedAuthorityForPaneKeys(paneKeys: ReadonlySet): boolean + protected abstract resolvePaneKeyAlias(paneKey: string): string + protected abstract normalizeHookBodyPaneKeyAlias(body: unknown): unknown + protected abstract normalizeLocalHookPayload( + source: AgentHookSource, + body: unknown + ): NormalizedLocalHook + protected abstract setClaudeBackgroundEvidence( + paneKey: string, + hasRunningTask: boolean, + hasActiveCron: boolean + ): void + protected abstract toRetainedProviderSessionRow( + entry: EnrichedAgentHookEventPayload | null | undefined + ): EnrichedAgentHookEventPayload | null + protected abstract hasLiveClaimsForPaneKey(paneKey: string): boolean + protected abstract clearPaneState(paneKey: string): void + protected abstract deleteStatusEntry( + paneKey: string, + options?: { preserveAuthority?: boolean } + ): EnrichedAgentHookEventPayload | null + protected abstract maybeWriteEndpointFile(): void + protected abstract hydrateLastStatusFromDisk(): void + protected abstract captureHydratedAuthorityCommitments(): void + protected abstract recordCurrentAuthorityObservation(payload: AgentHookEventPayload): void + protected abstract toAuthorityEvidence( + payload: AgentHookEventPayload | EnrichedAgentHookEventPayload, + launchTokenHashOverride?: string + ): AgentHookAuthorityEvidence | null + protected abstract serializeStatusFile(): string + protected abstract scheduleStatusPersist(): void + protected abstract runStatusPersist(): void + + abstract _getStateForTests(): HookListenerState + abstract _resetPromptSentDedupeForTests(): void + abstract _resetConnectionTimestampWatermarksForTests(): void +} diff --git a/src/main/agent-hooks/server/server-status-application.ts b/src/main/agent-hooks/server/server-status-application.ts new file mode 100644 index 00000000000..7fbb6a96bc1 --- /dev/null +++ b/src/main/agent-hooks/server/server-status-application.ts @@ -0,0 +1,141 @@ +import { createHash } from 'node:crypto' + +import { getCohortAtEmit } from '../../telemetry/cohort-classifier' +import { track } from '../../telemetry/client' +import { isCommandCodeNewTurnWhileWorking } from '../../../shared/command-code-turn-boundary' +import { isNewTurnEvent } from '../../../shared/agent-hook-listener/provider-event-routing' +import type { AgentHookEventPayload } from '../../../shared/agent-hook-listener/listener-event' +import type { + AgentStatusObservation, + AgentStatusObservationOrigin +} from '../../../shared/agent-status-observation' +import type { EnrichedAgentHookEventPayload } from './server-types' +import { agentTypeToPromptSentAgentKind } from './server-status-identity' +import { AgentHookServerStatusDisposition } from './server-status-disposition' + +export abstract class AgentHookServerStatusApplication extends AgentHookServerStatusDisposition { + protected attachStatusTiming( + payload: AgentHookEventPayload, + now = Date.now() + ): EnrichedAgentHookEventPayload { + const previous = this.state.lastStatusByPaneKey.get(payload.paneKey) as + | EnrichedAgentHookEventPayload + | undefined + const commandCodeNewTurn = + previous !== undefined && + isCommandCodeNewTurnWhileWorking({ + agentType: payload.payload.agentType, + previousState: previous.payload.state, + incomingState: payload.payload.state, + previousPrompt: previous.payload.prompt, + incomingPrompt: payload.payload.prompt, + hasExplicitPrompt: payload.hasExplicitPrompt, + previousPromptInteractionKey: previous.promptInteractionKey, + incomingPromptInteractionKey: payload.promptInteractionKey + }) + const stateStartedAt = + previous && previous.payload.state === payload.payload.state && !commandCodeNewTurn + ? previous.stateStartedAt + : now + // Why: `stateStartedAt` tracks the current state, while `receivedAt` tracks every arrival. + return { + ...payload, + receivedAt: now, + stateStartedAt + } + } + + protected hashPromptForTelemetryDedupe(prompt: string): string { + return createHash('sha256') + .update(this.promptSentHashSalt) + .update('\0') + .update(prompt) + .digest('hex') + } + + protected maybeTrackAgentPromptSent( + payload: AgentHookEventPayload, + previousStatus: EnrichedAgentHookEventPayload | undefined + ): void { + if (payload.isReplay === true || payload.hasExplicitPrompt !== true) { + return + } + const prompt = payload.payload.prompt?.trim() ?? '' + if (prompt.length === 0) { + return + } + const agentKind = agentTypeToPromptSentAgentKind(payload.payload.agentType) + const promptHash = this.hashPromptForTelemetryDedupe(prompt) + const promptInteractionKey = + typeof payload.promptInteractionKey === 'string' && + payload.promptInteractionKey.trim().length > 0 + ? payload.promptInteractionKey.trim() + : undefined + const previousDedupe = this.promptSentDedupeByPaneKey.get(payload.paneKey) + const isCompletedTurnBoundary = + previousStatus?.payload.state === 'done' && payload.payload.state === 'working' + if ( + previousDedupe?.agentKind === agentKind && + previousDedupe.promptInteractionKey !== undefined && + previousDedupe.promptInteractionKey === promptInteractionKey && + (agentKind === 'opencode' || previousDedupe.promptHash === promptHash) + ) { + return + } + if ( + previousDedupe?.agentKind === agentKind && + previousDedupe.promptHash === promptHash && + !( + previousStatus?.payload.state === 'done' && + payload.payload.state === 'done' && + previousDedupe.promptInteractionKey !== undefined && + promptInteractionKey !== undefined && + previousDedupe.promptInteractionKey !== promptInteractionKey + ) && + !isCompletedTurnBoundary + ) { + return + } + this.promptSentDedupeByPaneKey.set(payload.paneKey, { + agentKind, + promptHash, + promptInteractionKey + }) + try { + // Why: hooks prove a turn was submitted but not which UI launched the terminal; keep attribution low-cardinality. + track('agent_prompt_sent', { + agent_kind: agentKind, + launch_source: 'unknown', + request_kind: 'followup', + ...getCohortAtEmit() + }) + } catch (err) { + console.error('[agent-hooks] prompt-sent telemetry failed', err) + } + } + + /** Stamp who observed this event, in what order, on main's clock. Nothing reads it yet + * (STA-4293) — it is stamped here because every main-side ingress funnels through + * applyNormalizedStatus, so no origin can silently arrive untagged. */ + protected stampObservation( + payload: AgentHookEventPayload, + origin: AgentStatusObservationOrigin, + observedAt: number + ): AgentStatusObservation { + return this.observations.observe(payload.paneKey, { + origin, + observedAt, + // Why: reuse the listener's own per-provider classifier; a second list of raw event-name + // literals here would strand the providers whose boundary event is named anything else. + boundary: + payload.source !== undefined && isNewTurnEvent(payload.source, payload.hookEventName), + kind: payload.providerSessionOnly + ? 'identity-only' + : // Why: a replay restates a turn that already happened, and OSC 9999 repaints the + // current state rather than announcing a change — neither is a fresh transition. + payload.isReplay === true || origin === 'osc' + ? 'snapshot' + : 'transition' + }) + } +} diff --git a/src/main/agent-hooks/server/server-status-disposition.ts b/src/main/agent-hooks/server/server-status-disposition.ts new file mode 100644 index 00000000000..b6c69967280 --- /dev/null +++ b/src/main/agent-hooks/server/server-status-disposition.ts @@ -0,0 +1,161 @@ +import { createHash } from 'node:crypto' + +import { isNewTurnEvent } from '../../../shared/agent-hook-listener/provider-event-routing' +import { parseLegacyNumericPaneKey, parsePaneKey } from '../../../shared/stable-pane-id' +import type { AgentHookSource } from '../../../shared/agent-hook-relay' +import { + CLOSED_AGENT_STATUS_PANE_KEYS_MAX, + CLOSED_AGENT_STATUS_TAB_IDS_MAX, + RETIRED_PANE_FENCES_MAX +} from './server-constants' +import type { RetiredPaneAlias, RetiredPaneFence } from './server-types' +import { AgentHookServerStatusInference } from './server-status-inference' + +export abstract class AgentHookServerStatusDisposition extends AgentHookServerStatusInference { + protected markTabClosedForAgentStatus(tabId: string): void { + // Delete-then-add keeps recently closed tabs most-recent so eviction sheds only the oldest ids. + this.closedAgentStatusTabIds.delete(tabId) + this.closedAgentStatusTabIds.add(tabId) + while (this.closedAgentStatusTabIds.size > CLOSED_AGENT_STATUS_TAB_IDS_MAX) { + const oldest = this.closedAgentStatusTabIds.keys().next().value + if (oldest === undefined) { + break + } + this.closedAgentStatusTabIds.delete(oldest) + } + } + + protected getAgentStatusDisposition( + paneKey: string, + event?: { + source?: AgentHookSource + /** Raw wire value, so the gate can tell "field absent" from "field present but unknown". */ + rawSource?: unknown + hookEventName?: string + isReplay?: boolean + hasExplicitPrompt?: boolean + launchToken?: string + } + ): 'accept' | 'restart' | 'suppress' { + const ownerPaneKey = this.resolvePaneKeyAlias(paneKey) + const paneRetired = + this.closedAgentStatusPaneKeys.has(paneKey) || + this.closedAgentStatusPaneKeys.has(ownerPaneKey) + const tabId = parsePaneKey(ownerPaneKey)?.tabId + if (tabId && this.closedAgentStatusTabIds.has(tabId)) { + return 'suppress' + } + if (!paneRetired) { + const tokenFence = this.restartedStatusLaunchTokenHashByPaneKey.get(ownerPaneKey) + // Why: deferred retirement lets a new process start in a still-authorized pane, so + // its tokened SessionStart re-fences; prompts recur, so a stale process would win. + if ( + event?.hookEventName === 'SessionStart' && + event.isReplay !== true && + tokenFence !== undefined + ) { + const startedLaunchToken = event.launchToken?.trim() + if (startedLaunchToken) { + this.restartedStatusLaunchTokenHashByPaneKey.set( + ownerPaneKey, + createHash('sha256').update(startedLaunchToken).digest('hex') + ) + return 'accept' + } + } + if (event && tokenFence) { + const launchToken = event.launchToken?.trim() + if (!launchToken || createHash('sha256').update(launchToken).digest('hex') !== tokenFence) { + return 'suppress' + } + } + return 'accept' + } + // Why: command completion retires launch authority but leaves its shell pane reusable. + // A live new-turn event proves a new agent process owns the retired pane just like a + // fresh prompt does — without it, a session resumed in a reused pane stays rowless (STA-3386). + // Why the classifier, not literals: only 5 of 18 sources name their boundary + // `UserPromptSubmit`/`SessionStart`; the rest stayed retired forever. + // Why four branches: `source` collapses to undefined when an older relay omits the field, + // when a newer host sends an unknown string, and when the wire value is malformed. Only an + // unknown string is valid future-provider evidence. Unreachable from the local path, which + // 404s an unresolvable source. + const isNewTurn = + event?.source !== undefined + ? isNewTurnEvent(event.source, event.hookEventName) + : typeof event?.rawSource === 'string' && event.rawSource.trim().length > 0 + ? // Why fail OPEN for an unknown provider: its boundary event is unknowable here, and + // the costs are asymmetric — a stranded pane is invisible and permanent with no user + // recovery, while a spurious revive decays after AGENT_STATUS_STALE_AFTER_MS. + true + : event?.rawSource === undefined + ? // Why literals here: an older relay omits `source` entirely. Legacy shim only — it + // cannot revive a provider whose boundary event is named anything else. + event?.hookEventName === 'UserPromptSubmit' || event?.hookEventName === 'SessionStart' + : false + // Why in addition to the classifier: the OpenCode family carries its mid-session boundary in + // an explicit-prompt MessagePart, which isNewTurnEvent cannot name — and mimo-code has no + // SessionStart at all, so without this its retired panes never come back. + const freshOpenCodeFamilyPrompt = + (event?.source === 'opencode' || event?.source === 'mimo-code') && + event.hookEventName === 'MessagePart' && + event.hasExplicitPrompt === true + // Why the token is minted here: a revive proves a live lifecycle, and fencing follow-up + // status on that launch token stops a stale process reclaiming the pane's row without + // restoring retired orchestration authority. + if ((isNewTurn || freshOpenCodeFamilyPrompt) && event?.isReplay !== true) { + this.closedAgentStatusPaneKeys.delete(paneKey) + this.closedAgentStatusPaneKeys.delete(ownerPaneKey) + const launchToken = event?.launchToken?.trim() + if (launchToken) { + this.restartedStatusLaunchTokenHashByPaneKey.set( + ownerPaneKey, + createHash('sha256').update(launchToken).digest('hex') + ) + } else { + this.restartedStatusLaunchTokenHashByPaneKey.delete(ownerPaneKey) + } + return 'restart' + } + return 'suppress' + } + + // Why: a fence can span tabs (a pane detached into another tab), and legacy numeric + // keys never parse as stable ones — resolve both forms so neither slips the tab check. + protected isClosedAgentStatusTabForPaneKey(paneKey: string): boolean { + const tabId = + parsePaneKey(paneKey)?.tabId ?? parseLegacyNumericPaneKey(paneKey)?.tabId ?? undefined + return tabId !== undefined && this.closedAgentStatusTabIds.has(tabId) + } + + protected recordRetiredPaneFence( + paneKeys: ReadonlySet, + aliases: readonly RetiredPaneAlias[] + ): void { + const fence: RetiredPaneFence = { paneKeys: [...paneKeys], aliases } + for (const key of paneKeys) { + // Delete-then-set keeps the newest fence most-recent so eviction sheds only the oldest. + this.retiredPaneFencesByKey.delete(key) + this.retiredPaneFencesByKey.set(key, fence) + } + while (this.retiredPaneFencesByKey.size > RETIRED_PANE_FENCES_MAX) { + const oldest = this.retiredPaneFencesByKey.keys().next().value + if (oldest === undefined) { + break + } + this.retiredPaneFencesByKey.delete(oldest) + } + } + + protected markPaneClosedForAgentStatus(paneKey: string): void { + this.closedAgentStatusPaneKeys.delete(paneKey) + this.closedAgentStatusPaneKeys.add(paneKey) + while (this.closedAgentStatusPaneKeys.size > CLOSED_AGENT_STATUS_PANE_KEYS_MAX) { + const oldest = this.closedAgentStatusPaneKeys.keys().next().value + if (oldest === undefined) { + break + } + this.closedAgentStatusPaneKeys.delete(oldest) + } + } +} diff --git a/src/main/agent-hooks/server/server-status-identity.ts b/src/main/agent-hooks/server/server-status-identity.ts new file mode 100644 index 00000000000..41a87b4d7de --- /dev/null +++ b/src/main/agent-hooks/server/server-status-identity.ts @@ -0,0 +1,94 @@ +import { createHash } from 'node:crypto' + +import type { AgentKind } from '../../../shared/telemetry-events' +import type { AgentHookEventPayload } from '../../../shared/agent-hook-listener/listener-event' +import { + getAgentResumeArgv, + type AgentProviderSessionMetadata +} from '../../../shared/agent-session-resume' +import { parseLegacyNumericPaneKey, parsePaneKey } from '../../../shared/stable-pane-id' +import type { AgentStatusIpcPayload, AgentType } from '../../../shared/agent-status-types' +import type { EnrichedAgentHookEventPayload } from './server-types' +import { AGENT_PROMPT_SENT_AGENT_KINDS, TOOL_PROGRESS_HOOK_EVENTS } from './server-constants' +import { MAX_PANE_KEY_LEN } from '../../../shared/agent-hook-listener/listener-limits' + +export function agentTypeToPromptSentAgentKind(agentType: AgentType | undefined): AgentKind { + const normalized = agentType?.trim().toLowerCase() + if (!normalized || normalized === 'unknown') { + return 'other' + } + if (normalized === 'claude') { + return 'claude-code' + } + return AGENT_PROMPT_SENT_AGENT_KINDS.has(normalized as AgentKind) + ? (normalized as AgentKind) + : 'other' +} + +export function equivalentInterruptAgentType( + actual: AgentType | undefined, + baseline: AgentType | undefined +): boolean { + const normalizedActual = actual === 'unknown' ? undefined : actual + const normalizedBaseline = baseline === 'unknown' ? undefined : baseline + return normalizedActual === normalizedBaseline +} + +// Why: validate the durable `${tabId}:${leafUuid}` leaf suffix at write/hydrate so legacy numeric rows fail closed. +export function isValidPaneKey(value: unknown): value is string { + return ( + typeof value === 'string' && value.length <= MAX_PANE_KEY_LEN && parsePaneKey(value) !== null + ) +} + +// Why: remote metadata-only rows are currently a Pi contract; user-dismissed rows use an internal persisted marker instead. +export function isValidPiProviderSessionOnly( + providerSession: AgentProviderSessionMetadata | undefined, + agentType: AgentType | undefined +): boolean { + return Boolean(providerSession && agentType === 'pi' && getAgentResumeArgv('pi', providerSession)) +} + +export function toAgentStatusIpcPayload( + entry: EnrichedAgentHookEventPayload +): AgentStatusIpcPayload { + return { + paneKey: entry.paneKey, + ...(entry.launchToken ? { launchToken: entry.launchToken } : {}), + tabId: entry.tabId, + worktreeId: entry.worktreeId, + connectionId: entry.connectionId, + receivedAt: entry.receivedAt, + stateStartedAt: entry.stateStartedAt, + ...(entry.providerSession ? { providerSession: entry.providerSession } : {}), + ...(entry.providerSessionOnly ? { providerSessionOnly: true } : {}), + ...(entry.promptInteractionKey ? { promptInteractionKey: entry.promptInteractionKey } : {}), + ...(entry.restoredUnconfirmed ? { restoredUnconfirmed: true } : {}), + ...(entry.observation ? { observation: entry.observation } : {}), + ...entry.payload + } +} + +export function isToolProgressWorkingAfterInterrupt(next: AgentHookEventPayload): boolean { + if (next.payload.state !== 'working') { + return false + } + if (next.payload.agentType !== 'claude' && next.payload.agentType !== 'codex') { + return false + } + // Why: a same-prompt retry is another UserPromptSubmit, while late post-Ctrl+C progress arrives as tool lifecycle work. + return next.hookEventName !== undefined && TOOL_PROGRESS_HOOK_EVENTS.has(next.hookEventName) +} + +export function paneCacheKeyTabId(key: string): string | null { + const paneKey = key.split('\0', 1)[0] ?? key + return parsePaneKey(paneKey)?.tabId ?? parseLegacyNumericPaneKey(paneKey)?.tabId ?? null +} + +export function paneCacheKeyMatchesTab(key: string, tabId: string): boolean { + return paneCacheKeyTabId(key) === tabId +} + +export function hashLaunchToken(value: string): string { + return createHash('sha256').update(value).digest('hex') +} diff --git a/src/main/agent-hooks/server/server-status-inference.ts b/src/main/agent-hooks/server/server-status-inference.ts new file mode 100644 index 00000000000..ec651691982 --- /dev/null +++ b/src/main/agent-hooks/server/server-status-inference.ts @@ -0,0 +1,175 @@ +import { + markClaudeLeadTurnInterrupted, + clearClaudeAnsweredQuestionWait +} from '../../../shared/agent-hook-listener/providers/claude-roster-state' +import { markCodexLeadTurnInterrupted } from '../../../shared/agent-hook-listener/providers/codex-state' +import { + isAgentInterruptInputIntent, + type AgentInterruptInferenceRequest +} from '../../../shared/agent-interrupt-intent' +import { + isAskUserQuestionTool, + type AgentQuestionAnsweredInferenceRequest +} from '../../../shared/agent-question-answered-intent' +import { AGENT_STATUS_STALE_AFTER_MS, type AgentType } from '../../../shared/agent-status-types' +import type { EnrichedAgentHookEventPayload } from './server-types' +import { equivalentInterruptAgentType, isValidPaneKey } from './server-status-identity' +import { AgentHookServerListeners } from './server-listeners' + +export abstract class AgentHookServerStatusInference extends AgentHookServerListeners { + inferInterrupt(request: AgentInterruptInferenceRequest): boolean { + if (!isValidPaneKey(request.paneKey)) { + return false + } + if (!isAgentInterruptInputIntent(request.intent)) { + return false + } + const existing = this.state.lastStatusByPaneKey.get(request.paneKey) as + | EnrichedAgentHookEventPayload + | undefined + if (!existing) { + return false + } + if (existing.providerSessionOnly) { + return false + } + // Why: inference must not fabricate a `done` onto a row whose `working` was never confirmed this runtime. + if (existing.restoredUnconfirmed) { + return false + } + const payload = existing.payload + const agentType: AgentType | undefined = payload.agentType + // Why: Droid's Ctrl+C exits the CLI (handled by PTY lifecycle) rather than interrupting the current turn. + if (agentType === 'droid' && request.intent === 'ctrl-c') { + return false + } + // Why: these agents use the first Escape as a TUI cancel that can leave the turn running; only a double Escape infers an interrupt. + if ( + (agentType === 'opencode' || agentType === 'copilot') && + request.intent === 'plain-escape' && + request.inputCount !== 2 + ) { + return false + } + const dismissesClaudeQuestion = + agentType === 'claude' && + request.intent === 'plain-escape' && + payload.state === 'waiting' && + isAskUserQuestionTool(payload.toolName) + if (dismissesClaudeQuestion) { + return this.inferQuestionAnswered(request) + } + // Why: inference is a fallback for a missing final hook; a strict baseline match keeps a delayed timer from clobbering any newer hook. + if ( + payload.state !== 'working' || + !equivalentInterruptAgentType(agentType, request.baselineAgentType) || + payload.prompt !== request.baselinePrompt || + existing.receivedAt !== request.baselineUpdatedAt || + existing.stateStartedAt !== request.baselineStateStartedAt || + Date.now() - existing.receivedAt > AGENT_STATUS_STALE_AFTER_MS + ) { + return false + } + // Why: a 'working' pane can be child-driven; Ctrl+C doesn't stop background children, so inferring done would retire live child rows. + if (payload.subagents?.some((subagent) => subagent.state !== 'idle')) { + return false + } + // Why: Escape/Ctrl+C at Claude's idle prompt does not stop provider-owned shells or session crons. + if ( + agentType === 'claude' && + (this.state.claudeRunningNonAgentTaskPaneKeys.has(existing.paneKey) || + this.state.claudeActiveSessionCronPaneKeys.has(existing.paneKey)) + ) { + return false + } + // Why: keep the Claude lead-turn record in sync, or a later child event re-emits the stale 'working' state and resurrects the cancelled pane. + if (agentType === 'claude') { + markClaudeLeadTurnInterrupted(this.state, existing.paneKey) + } + if (agentType === 'codex') { + markCodexLeadTurnInterrupted(this.state, existing.paneKey) + } + const inferred = this.applyNormalizedStatus({ + paneKey: existing.paneKey, + tabId: existing.tabId, + worktreeId: existing.worktreeId, + connectionId: existing.connectionId, + providerSession: existing.providerSession, + payload: { + state: 'done', + prompt: payload.prompt, + agentType, + ...(payload.model ? { model: payload.model } : {}), + interrupted: true, + // Why: idle children are display state; dropping them on an inferred interrupt blanks rows a later hook would restore. + ...(payload.subagents ? { subagents: payload.subagents } : {}) + } + }) + console.debug('[agent-hooks] inferred interrupted agent status', { + paneKey: inferred.paneKey, + agentType, + intent: request.intent + }) + return true + } + + /** Guarded fallback for the hook Claude omits after answering or dismissing AskUserQuestion. */ + inferQuestionAnswered(request: AgentQuestionAnsweredInferenceRequest): boolean { + if (!isValidPaneKey(request.paneKey)) { + return false + } + const existing = this.state.lastStatusByPaneKey.get(request.paneKey) as + | EnrichedAgentHookEventPayload + | undefined + if (!existing) { + return false + } + // Why: inference must not fabricate a transition onto a row whose state was never confirmed this runtime. + if (existing.restoredUnconfirmed) { + return false + } + const payload = existing.payload + // Why: only Claude's interactive question clears on typed input — tool name (not hook event) discriminates; real permission waits stay sticky. + if ( + payload.agentType !== 'claude' || + payload.state !== 'waiting' || + !isAskUserQuestionTool(payload.toolName) + ) { + return false + } + if ( + payload.agentType !== request.baselineAgentType || + payload.prompt !== request.baselinePrompt || + existing.receivedAt !== request.baselineUpdatedAt || + existing.stateStartedAt !== request.baselineStateStartedAt || + Date.now() - existing.receivedAt > AGENT_STATUS_STALE_AFTER_MS + ) { + return false + } + // Why: sync the listener's lead-turn record too, or a later child event re-emits the stale waiting state and resurrects the card. + const restored = clearClaudeAnsweredQuestionWait(this.state, existing.paneKey) + const inferred = this.applyNormalizedStatus({ + paneKey: existing.paneKey, + tabId: existing.tabId, + worktreeId: existing.worktreeId, + connectionId: existing.connectionId, + providerSession: existing.providerSession, + payload: { + state: restored.state, + ...(restored.workingMode ? { workingMode: restored.workingMode } : {}), + prompt: payload.prompt, + agentType: payload.agentType, + ...(restored.state === 'done' && restored.interrupted ? { interrupted: true } : {}), + ...(restored.turnCompletedAt !== undefined + ? { turnCompletedAt: restored.turnCompletedAt } + : {}), + ...(payload.subagents ? { subagents: payload.subagents } : {}) + } + }) + console.debug('[agent-hooks] inferred resolved question status', { + paneKey: inferred.paneKey, + state: inferred.payload.state + }) + return true + } +} diff --git a/src/main/agent-hooks/server/server-status-retries.ts b/src/main/agent-hooks/server/server-status-retries.ts new file mode 100644 index 00000000000..2757806b293 --- /dev/null +++ b/src/main/agent-hooks/server/server-status-retries.ts @@ -0,0 +1,155 @@ +import { hasCodexTranscriptSubagents } from '../../../shared/agent-hook-listener/providers/codex-state' +import { normalizeHookPayload } from '../../../shared/agent-hook-listener' +import { + hasPendingAgentResultText, + preparePendingGrokResultDiscovery +} from '../../../shared/agent-hook-listener/grok-result-discovery' +import type { AgentHookSource } from '../../../shared/agent-hook-relay' +import { CodexSubagentPollScheduler } from '../../../shared/codex-subagent-poll-scheduler' +import type { EnrichedAgentHookEventPayload } from './server-types' +import { + ASSISTANT_MESSAGE_RETRY_ATTEMPTS, + ASSISTANT_MESSAGE_RETRY_MS, + CODEX_SUBAGENT_POLL_MS +} from './server-constants' +import { AgentHookServerStatusUpdate } from './server-status-update' + +type CodexSubagentPoll = { + source: AgentHookSource + body: unknown + original: EnrichedAgentHookEventPayload +} + +export abstract class AgentHookServerStatusRetries extends AgentHookServerStatusUpdate { + private readonly codexSubagentPollScheduler = new CodexSubagentPollScheduler( + CODEX_SUBAGENT_POLL_MS, + (paneKey, poll) => this.runCodexSubagentPoll(paneKey, poll) + ) + + protected clearAllCodexSubagentPolls(): void { + this.codexSubagentPollScheduler.clearAll() + } + + protected clearAssistantMessageRetry(paneKey: string): void { + const timer = this.assistantMessageRetryTimers.get(paneKey) + if (!timer) { + return + } + clearTimeout(timer) + this.assistantMessageRetryTimers.delete(paneKey) + } + + protected clearCodexSubagentPoll(paneKey: string): void { + this.codexSubagentPollScheduler.clear(paneKey) + } + + protected scheduleCodexSubagentPoll( + source: AgentHookSource, + body: unknown, + original: EnrichedAgentHookEventPayload + ): void { + // Why: a nested non-codex CLI inherits ORCA_PANE_KEY, so clearing here would silently end a live codex poll. + if (source !== 'codex') { + return + } + this.codexSubagentPollScheduler.clear(original.paneKey) + if (!hasCodexTranscriptSubagents(this.state, original.paneKey)) { + return + } + this.codexSubagentPollScheduler.schedule(original.paneKey, { source, body, original }) + } + + private runCodexSubagentPoll(paneKey: string, poll: CodexSubagentPoll): void { + const { source, body, original } = poll + // Keep the identity check at callback time: a newer event supersedes this + // payload even when its pane still has transcript children. + if ( + paneKey !== original.paneKey || + !this.server || + this.state.lastStatusByPaneKey.get(original.paneKey) !== original + ) { + return + } + const normalized = normalizeHookPayload(this.state, source, body, this.env) + if (!normalized) { + return + } + const subagentsChanged = + JSON.stringify(normalized.payload.subagents) !== JSON.stringify(original.payload.subagents) + const next = subagentsChanged ? this.applyNormalizedStatus(normalized) : original + this.scheduleCodexSubagentPoll(source, body, next) + } + + protected scheduleAssistantMessageRetry( + source: AgentHookSource, + body: unknown, + original: EnrichedAgentHookEventPayload, + attempt = 1, + discoveryReady = false + ): void { + if ( + original.payload.lastAssistantMessage || + !hasPendingAgentResultText(source, body) || + attempt > ASSISTANT_MESSAGE_RETRY_ATTEMPTS + ) { + return + } + this.clearAssistantMessageRetry(original.paneKey) + if (!discoveryReady) { + const discovery = preparePendingGrokResultDiscovery(source, body) + if (discovery) { + // Why: slug-group discovery can outlive the bounded flush timers; its completion must drive the first retry deterministically. + void discovery + .then(() => { + if (this.server) { + this.applyAssistantMessageRetry(source, body, original, 1, true) + } + }) + .catch((err) => { + console.error('[agent-hooks] Grok result discovery failed:', err) + }) + return + } + } + const timer = setTimeout(() => { + try { + this.assistantMessageRetryTimers.delete(original.paneKey) + this.applyAssistantMessageRetry(source, body, original, attempt + 1, discoveryReady) + } catch (err) { + console.error('[agent-hooks] assistant message retry failed:', err) + } + }, ASSISTANT_MESSAGE_RETRY_MS) + this.assistantMessageRetryTimers.set(original.paneKey, timer) + if (typeof timer.unref === 'function') { + timer.unref() + } + } + + protected applyAssistantMessageRetry( + source: AgentHookSource, + body: unknown, + original: EnrichedAgentHookEventPayload, + nextAttempt: number, + requireExactOriginal: boolean + ): void { + const current = this.state.lastStatusByPaneKey.get(original.paneKey) as + | EnrichedAgentHookEventPayload + | undefined + if ( + !current || + (requireExactOriginal && current !== original) || + current.payload.agentType !== original.payload.agentType || + current.payload.prompt !== original.payload.prompt || + current.payload.lastAssistantMessage + ) { + return + } + const normalized = this.normalizeLocalHookPayload(source, body) + if (!normalized.event?.payload.lastAssistantMessage) { + this.scheduleAssistantMessageRetry(source, body, original, nextAttempt, requireExactOriginal) + return + } + // Why: some agents POST Stop before their transcript line is flushed; discovery is event-driven, later content retries stay timed. + this.applyNormalizedStatus(normalized.event, normalized.onAccepted) + } +} diff --git a/src/main/agent-hooks/server/server-status-update.ts b/src/main/agent-hooks/server/server-status-update.ts new file mode 100644 index 00000000000..981da873e3d --- /dev/null +++ b/src/main/agent-hooks/server/server-status-update.ts @@ -0,0 +1,219 @@ +import { + reconcileRemoteCodexState, + markCodexLeadTurnInterrupted +} from '../../../shared/agent-hook-listener/providers/codex-state' +import { + resolveAgentStatusIdentity, + shouldSuppressInheritedTerminalStatus +} from '../../../shared/agent-status-identity' +import { INTERRUPTED_DONE_LATE_WORKING_SUPPRESSION_MS } from './server-constants' +import type { EnrichedAgentHookEventPayload } from './server-types' +import type { AgentHookEventPayload } from '../../../shared/agent-hook-listener/listener-event' +import type { AgentStatusObservationOrigin } from '../../../shared/agent-status-observation' +import { + attachClaudeChildOnlyBoundary, + attachClaudePermissionToolUseId, + invalidateClaudeChildOnlyBoundary, + shouldKeepClaudePermissionVisible +} from './server-claude-status-rules' +import { isToolProgressWorkingAfterInterrupt } from './server-status-identity' +import { AgentHookServerStatusApplication } from './server-status-application' + +export abstract class AgentHookServerStatusUpdate extends AgentHookServerStatusApplication { + protected applyNormalizedStatus( + payload: AgentHookEventPayload, + onAccepted?: () => void, + origin: AgentStatusObservationOrigin = 'hook' + ): EnrichedAgentHookEventPayload { + if (payload.hookEventName === 'UserPromptSubmit') { + // Why: the prompt boundary is authoritative even when text is unchanged; its next OSC working row must not inherit the prior cron/background turn stamp. + this.activeHookTurnCompletedAtByPaneKey.delete(payload.paneKey) + } + let previous = this.state.lastStatusByPaneKey.get(payload.paneKey) as + | EnrichedAgentHookEventPayload + | undefined + const connectionClearWatermark = payload.connectionId + ? this.connectionTimestampWatermarkById.get(payload.connectionId) + : undefined + // Why: renderer ordering rejects older rows; live evidence must sort after reconnect clears and restored rows across clock rollback. + const restoredStatusWatermark = previous?.restoredUnconfirmed ? previous.receivedAt : undefined + const now = Math.max( + Date.now(), + (connectionClearWatermark ?? -1) + 1, + (restoredStatusWatermark ?? -1) + 1 + ) + if (payload.connectionId) { + this.connectionTimestampWatermarkById.set(payload.connectionId, now) + } + if (payload.providerSessionOnly) { + // Why: identity-only rows survive replay but must not emit prompt telemetry or a fabricated status. + onAccepted?.() + const enriched = { + ...this.attachStatusTiming(payload, now), + observation: this.stampObservation(payload, origin, now) + } + this.clearAssistantMessageRetry(enriched.paneKey) + this.runtimeObservedStatusPaneKeys.delete(enriched.paneKey) + this.state.lastStatusByPaneKey.set(enriched.paneKey, enriched) + this.scheduleStatusPersist() + this.notifyStatusChangeListeners() + this.emitEnrichedStatus(enriched) + return enriched + } + const stateReconciledPayload = + payload.connectionId && payload.payload.agentType === 'codex' && payload.hookEventName + ? { + ...payload, + payload: reconcileRemoteCodexState( + this.state, + payload.paneKey, + payload.hookEventName, + payload.toolAgentId, + payload.payload, + previous?.payload + ) + } + : payload + const previousCodexRoot = + stateReconciledPayload.payload.agentType === 'codex' && + stateReconciledPayload.toolAgentId && + previous?.payload.agentType === 'codex' + ? previous + : undefined + const preservedProviderSession = !stateReconciledPayload.providerSession + ? previousCodexRoot?.providerSession + : undefined + const preservedRootModel = !stateReconciledPayload.payload.model + ? previousCodexRoot?.payload.model + : undefined + // Why: an SSH relay restart forgets root-only fields; child hooks must not erase durable resume/model identity. + const rootContextPreservingPayload = + preservedProviderSession || preservedRootModel + ? { + ...stateReconciledPayload, + ...(preservedProviderSession ? { providerSession: preservedProviderSession } : {}), + payload: preservedRootModel + ? { ...stateReconciledPayload.payload, model: preservedRootModel } + : stateReconciledPayload.payload + } + : stateReconciledPayload + const boundaryReconciledPrevious = invalidateClaudeChildOnlyBoundary( + previous, + rootContextPreservingPayload + ) + if (boundaryReconciledPrevious !== previous) { + previous = boundaryReconciledPrevious + if (previous) { + this.state.lastStatusByPaneKey.set(previous.paneKey, previous) + this.scheduleStatusPersist() + } + } + const identity = resolveAgentStatusIdentity({ + existing: previous + ? { + agentType: previous.payload.agentType, + state: previous.payload.state, + updatedAt: previous.receivedAt, + restoredUnconfirmed: previous.restoredUnconfirmed + } + : undefined, + incoming: rootContextPreservingPayload.payload.agentType, + now + }) + if ( + previous && + shouldSuppressInheritedTerminalStatus({ + inheritedFromActivePane: identity.inheritedFromActivePane, + incomingState: rootContextPreservingPayload.payload.state + }) + ) { + return previous + } + const identityResolvedPayload = + identity.agentType === rootContextPreservingPayload.payload.agentType + ? rootContextPreservingPayload + : { + ...rootContextPreservingPayload, + payload: { ...rootContextPreservingPayload.payload, agentType: identity.agentType } + } + const effectivePayload = attachClaudePermissionToolUseId(previous, identityResolvedPayload) + const boundaryAwarePayload = attachClaudeChildOnlyBoundary(previous, effectivePayload) + if (previous && shouldKeepClaudePermissionVisible(previous, effectivePayload)) { + return previous + } + // Why: some TUIs emit a delayed tool/working hook after Ctrl+C stopped the turn; don't let it resurrect the row. + if ( + previous?.payload.state === 'done' && + previous.payload.interrupted === true && + effectivePayload.payload.state === 'done' && + previous.payload.agentType === effectivePayload.payload.agentType && + previous.payload.prompt === effectivePayload.payload.prompt && + Date.now() - previous.receivedAt <= INTERRUPTED_DONE_LATE_WORKING_SUPPRESSION_MS + ) { + return previous + } + if ( + previous?.payload.state === 'done' && + previous.payload.interrupted === true && + effectivePayload.payload.state === 'working' && + previous.payload.agentType === effectivePayload.payload.agentType && + previous.payload.prompt === effectivePayload.payload.prompt && + (effectivePayload.isReplay === true || + isToolProgressWorkingAfterInterrupt(effectivePayload) || + (effectivePayload.hasExplicitPrompt !== true && + Date.now() - previous.receivedAt <= INTERRUPTED_DONE_LATE_WORKING_SUPPRESSION_MS)) + ) { + if (effectivePayload.payload.agentType === 'codex') { + markCodexLeadTurnInterrupted(this.state, effectivePayload.paneKey) + } + return previous + } + if ( + effectivePayload.payload.state !== 'done' || + effectivePayload.payload.lastAssistantMessage + ) { + this.clearAssistantMessageRetry(effectivePayload.paneKey) + } + onAccepted?.() + if (!identity.inheritedFromActivePane) { + this.maybeTrackAgentPromptSent(effectivePayload, previous) + } + const enriched = { + ...this.attachStatusTiming(boundaryAwarePayload, now), + observation: this.stampObservation(boundaryAwarePayload, origin, now) + } + if ( + typeof enriched.payload.turnCompletedAt === 'number' && + Number.isFinite(enriched.payload.turnCompletedAt) + ) { + this.activeHookTurnCompletedAtByPaneKey.set( + enriched.paneKey, + enriched.payload.turnCompletedAt + ) + } + // Why: an identity-matched event can still leave the aggregate backed only by another restored child; keep liveness reconciliation eligible. + if (enriched.restoredUnconfirmed) { + this.runtimeObservedStatusPaneKeys.delete(enriched.paneKey) + } else { + this.runtimeObservedStatusPaneKeys.add(enriched.paneKey) + } + this.state.lastStatusByPaneKey.set(enriched.paneKey, enriched) + this.scheduleStatusPersist() + this.notifyStatusChangeListeners() + this.emitEnrichedStatus(enriched) + return enriched + } + + // Why: every status emit must reach plugins too, so a new early-return path + // upstream cannot silently leave the plugin tap behind the main-window fanout. + protected emitEnrichedStatus(enriched: EnrichedAgentHookEventPayload): void { + this.onAgentStatus?.(enriched) + for (const listener of this.enrichedStatusListeners) { + try { + listener(enriched) + } catch (err) { + console.error('[agent-hooks] enriched status listener threw', err) + } + } + } +} diff --git a/src/main/agent-hooks/server/server-tab-cleanup.ts b/src/main/agent-hooks/server/server-tab-cleanup.ts new file mode 100644 index 00000000000..4abacfc81d0 --- /dev/null +++ b/src/main/agent-hooks/server/server-tab-cleanup.ts @@ -0,0 +1,122 @@ +import { clearPaneCacheState } from '../../../shared/agent-hook-listener/listener-state' +import { paneCacheKeyMatchesTab } from './server-status-identity' +import { AgentHookServerCleanup } from './server-cleanup' + +export abstract class AgentHookServerTabCleanup extends AgentHookServerCleanup { + /** Drop every status/cache claim attributable to a closed tab prefix. */ + dropStatusEntriesByTabPrefix(tabId: string): void { + this.markTabClosedForAgentStatus(tabId) + const paneKeysToClear = new Set() + for (const key of this.state.lastStatusByPaneKey.keys()) { + if (paneCacheKeyMatchesTab(key, tabId)) { + paneKeysToClear.add(key) + } + } + for (const key of this.state.lastPromptByPaneKey.keys()) { + if (paneCacheKeyMatchesTab(key, tabId)) { + paneKeysToClear.add(key.split('\0', 1)[0] ?? key) + } + } + for (const key of this.state.lastToolByPaneKey.keys()) { + if (paneCacheKeyMatchesTab(key, tabId)) { + paneKeysToClear.add(key.split('\0', 1)[0] ?? key) + } + } + for (const key of this.state.antigravityCompletedTranscriptByPaneKey.keys()) { + if (paneCacheKeyMatchesTab(key, tabId)) { + paneKeysToClear.add(key.split('\0', 1)[0] ?? key) + } + } + for (const key of this.state.ampCompletedCacheKeys) { + if (paneCacheKeyMatchesTab(key, tabId)) { + paneKeysToClear.add(key.split('\0', 1)[0] ?? key) + } + } + for (const paneKey of this.runtimeObservedStatusPaneKeys) { + if (paneCacheKeyMatchesTab(paneKey, tabId)) { + paneKeysToClear.add(paneKey) + } + } + for (const paneKey of this.promptSentDedupeByPaneKey.keys()) { + if (paneCacheKeyMatchesTab(paneKey, tabId)) { + paneKeysToClear.add(paneKey) + } + } + for (const commitment of this.hydratedAuthorityCommitments) { + if (paneCacheKeyMatchesTab(commitment.paneKey, tabId)) { + paneKeysToClear.add(commitment.paneKey) + } + } + let aliasChanged = false + for (const [legacyPaneKey, entry] of this.legacyPaneKeyAliases) { + if (paneCacheKeyMatchesTab(entry.stablePaneKey, tabId)) { + this.legacyPaneKeyAliases.delete(legacyPaneKey) + paneKeysToClear.add(legacyPaneKey) + paneKeysToClear.add(entry.stablePaneKey) + this.markPaneClosedForAgentStatus(legacyPaneKey) + this.markPaneClosedForAgentStatus(entry.stablePaneKey) + aliasChanged = true + } + } + const authorityChanged = this.revokeHydratedAuthorityForPaneKeys(paneKeysToClear) + let statusChanged = false + for (const paneKey of paneKeysToClear) { + if (this.state.lastStatusByPaneKey.has(paneKey)) { + statusChanged = true + } + this.clearAssistantMessageRetry(paneKey) + this.clearCodexSubagentPoll(paneKey) + clearPaneCacheState(this.state, paneKey) + this.activeHookTurnCompletedAtByPaneKey.delete(paneKey) + this.runtimeObservedStatusPaneKeys.delete(paneKey) + this.currentAuthorityObservations.delete(paneKey) + this.promptSentDedupeByPaneKey.delete(paneKey) + this.restartedStatusLaunchTokenHashByPaneKey.delete(paneKey) + } + if (aliasChanged) { + this.notifyPaneKeyAliasPersistenceListener() + } + if (statusChanged || authorityChanged) { + this.scheduleStatusPersist() + this.notifyStatusChangeListeners() + } + } + + clearPaneState(paneKey: string): void { + const resolvedPaneKey = this.resolvePaneKeyAlias(paneKey) + const paneKeys = new Set([paneKey, resolvedPaneKey]) + // Why: only persist when a status entry was actually evicted; dropping prompt/tool caches doesn't change the file. + const hadStatus = this.state.lastStatusByPaneKey.has(resolvedPaneKey) + this.clearAssistantMessageRetry(resolvedPaneKey) + this.clearCodexSubagentPoll(resolvedPaneKey) + clearPaneCacheState(this.state, resolvedPaneKey) + this.activeHookTurnCompletedAtByPaneKey.delete(resolvedPaneKey) + this.currentAuthorityObservations.delete(resolvedPaneKey) + this.promptSentDedupeByPaneKey.delete(resolvedPaneKey) + this.restartedStatusLaunchTokenHashByPaneKey.delete(resolvedPaneKey) + let clearedAlias = false + for (const [legacyPaneKey, alias] of this.legacyPaneKeyAliases) { + if (alias.stablePaneKey === resolvedPaneKey) { + this.legacyPaneKeyAliases.delete(legacyPaneKey) + paneKeys.add(legacyPaneKey) + paneKeys.add(alias.stablePaneKey) + clearPaneCacheState(this.state, legacyPaneKey) + this.activeHookTurnCompletedAtByPaneKey.delete(legacyPaneKey) + this.currentAuthorityObservations.delete(legacyPaneKey) + this.promptSentDedupeByPaneKey.delete(legacyPaneKey) + this.restartedStatusLaunchTokenHashByPaneKey.delete(legacyPaneKey) + clearedAlias = true + } + } + const authorityChanged = this.revokeHydratedAuthorityForPaneKeys(paneKeys) + if (clearedAlias) { + this.notifyPaneKeyAliasPersistenceListener() + } + if (hadStatus || authorityChanged) { + this.runtimeObservedStatusPaneKeys.delete(resolvedPaneKey) + this.scheduleStatusPersist() + this.notifyStatusChangeListeners() + this.emitPaneStatusCleared({ paneKey: resolvedPaneKey }) + } + } +} diff --git a/src/main/agent-hooks/server/server-transport-rules.ts b/src/main/agent-hooks/server/server-transport-rules.ts new file mode 100644 index 00000000000..2f1aacc370e --- /dev/null +++ b/src/main/agent-hooks/server/server-transport-rules.ts @@ -0,0 +1,13 @@ +import { track } from '../../telemetry/client' + +/** Keep unattributed hook deliveries visible in telemetry without rejecting the request. */ +export function trackEmptyPaneKeyHook(body: unknown): void { + if (typeof body !== 'object' || body === null) { + return + } + const paneKey = (body as Record).paneKey + if (typeof paneKey === 'string' && paneKey.trim().length > 0) { + return + } + track('agent_hook_unattributed', { reason: 'empty_pane_key' }) +} diff --git a/src/main/agent-hooks/server/server-types.ts b/src/main/agent-hooks/server/server-types.ts new file mode 100644 index 00000000000..913bcd7067e --- /dev/null +++ b/src/main/agent-hooks/server/server-types.ts @@ -0,0 +1,113 @@ +import type { ClaudeStatusLineRateLimits } from '../../../shared/claude-statusline-rate-limits' +import type { AgentHookEventPayload } from '../../../shared/agent-hook-listener/listener-event' +import type { + AgentStatusClearIpcPayload, + AgentStatusState +} from '../../../shared/agent-status-types' +import type { AgentStatusObservation } from '../../../shared/agent-status-observation' +import type { AgentKind } from '../../../shared/telemetry-events' +import type { LegacyPaneKeyAliasEntry } from '../../../shared/persisted-state-types' + +// Why: server-side enrichment — receivedAt = latest event arrival, stateStartedAt = when the current state first appeared; extra fields ride the shared map untouched (it only writes/clears). +export type EnrichedAgentHookEventPayload = AgentHookEventPayload & { + receivedAt: number + stateStartedAt: number + /** Provenance/ordering stamped by this server as the pane authority (STA-4293). Read by nothing yet. */ + observation?: AgentStatusObservation + /** Stamped at hydrate for nonterminal states; never persisted (hydrate re-stamps) and cleared by any accepted live event replacing the entry. */ + restoredUnconfirmed?: true + /** User-hidden resume identity retained solely for destructive liveness checks. */ + retainedForLiveness?: true + /** Persisted proof that a lead boundary was held working only by child agents. */ + claudeLeadBoundaryChildOnly?: true +} + +export type PersistedAgentHookEventPayload = Omit< + EnrichedAgentHookEventPayload, + | 'claudeRunningNonAgentTask' + | 'launchToken' + | 'promptInteractionKey' + | 'restoredUnconfirmed' + // Why: revision counters are in-memory and the authority id is regenerated per process, so + // a stored observation could only rehydrate as a stale ordering claim from a dead authority. + | 'observation' +> & { + launchTokenHash?: string +} + +export type PersistedAgentHookAuthorityCommitment = { + paneKey: string + launchTokenHash: string + connectionId: string | null + tabId?: string + worktreeId?: string + observedAt: number +} + +export type AgentHookStatusChangeEntry = { + state: AgentStatusState + receivedAt: number + observedInCurrentRuntime: boolean +} + +export type AgentHookProviderSessionIdentity = { + paneKey: string + sessionId: string + transcriptPath?: string + worktreeId?: string +} + +export type AgentHookAuthorityEvidence = Readonly<{ + paneKey: string + launchTokenHash: string + connectionId: string | null + tabId?: string + worktreeId?: string + observedAt: number +}> + +export type AgentHookAuthorityAttestation = Readonly<{ + paneKey: string + source: 'current_hook' | 'hydrated_commitment' +}> + +export type StatusChangeListener = (statuses: AgentHookStatusChangeEntry[]) => void +export type ProviderSessionChangeListener = ( + providerSessions: AgentHookProviderSessionIdentity[] +) => void +export type PaneStatusClearListener = (clear: AgentStatusClearIpcPayload) => void +export type StatusDropListener = (paneKey: string) => void +export type PaneKeyAliasPersistenceListener = (entries: LegacyPaneKeyAliasEntry[]) => void + +export type PaneKeyAliasEntry = { + stablePaneKey: string + ptyId: string | null + updatedAt: number + authorityVerified: boolean +} +export type RetiredPaneAlias = { physicalPaneKey: string; entry: PaneKeyAliasEntry } +/** What one retirement fenced, so a re-attach can lift exactly that set and no more. */ +export type RetiredPaneFence = { + paneKeys: readonly string[] + aliases: readonly RetiredPaneAlias[] +} + +export type LastStatusFile = { + version: number + entries: Record + authorityCommitments?: Record +} + +export type AgentPromptSentDedupeEntry = { + agentKind: AgentKind + promptHash: string + promptInteractionKey?: string +} + +export type NormalizedLocalHook = { + event: AgentHookEventPayload | null + onAccepted?: () => void +} + +export type ServerStatusLineListener = ((event: ClaudeStatusLineRateLimits) => void) | null +export type ServerAgentStatusListener = ((payload: EnrichedAgentHookEventPayload) => void) | null diff --git a/src/main/ai-vault/codex-session-root-dedup.test.ts b/src/main/ai-vault/codex-session-root-dedup.test.ts index 1f9acbc3cc6..519245c7618 100644 --- a/src/main/ai-vault/codex-session-root-dedup.test.ts +++ b/src/main/ai-vault/codex-session-root-dedup.test.ts @@ -1,6 +1,7 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import type { AiVaultSession } from '../../shared/ai-vault-types' import { + dedupeCodexRolloutCopyAliases, dedupeCodexRolloutFileAliases, dedupeCodexSessionsBySessionId } from './codex-session-root-dedup' @@ -204,6 +205,106 @@ describe('dedupeCodexRolloutFileAliases', () => { }) }) +describe('dedupeCodexRolloutCopyAliases', () => { + type Candidate = { + agent: string + path: string + codexHome: string | null + } + const accessors = { + isCodex: (candidate: Candidate) => candidate.agent === 'codex', + getFilePath: (candidate: Candidate) => candidate.path, + getCodexHome: (candidate: Candidate) => candidate.codexHome + } + + it('collapses cross-volume copies only after session_meta proves the same id', async () => { + const real = { agent: 'codex', path: REAL_HOME_ROLLOUT, codexHome: null } + const managed = { + agent: 'codex', + path: MANAGED_HOME_ROLLOUT, + codexHome: MANAGED_HOME + } + const readSessionMetaId = vi.fn(async () => 'shared-session-id') + + await expect( + dedupeCodexRolloutCopyAliases([managed, real], accessors, readSessionMetaId) + ).resolves.toEqual([real]) + expect(readSessionMetaId).toHaveBeenCalledTimes(2) + }) + + it('keeps same-name files when ids differ or metadata cannot prove identity', async () => { + const real = { agent: 'codex', path: REAL_HOME_ROLLOUT, codexHome: null } + const managed = { + agent: 'codex', + path: MANAGED_HOME_ROLLOUT, + codexHome: MANAGED_HOME + } + + await expect( + dedupeCodexRolloutCopyAliases([real, managed], accessors, async (path) => + path === REAL_HOME_ROLLOUT ? 'real-id' : 'managed-id' + ) + ).resolves.toEqual([real, managed]) + await expect( + dedupeCodexRolloutCopyAliases([real, managed], accessors, async () => null) + ).resolves.toEqual([real, managed]) + }) + + it('does not compare copies across native and WSL execution namespaces', async () => { + const rolloutName = REAL_HOME_ROLLOUT.split('/').at(-1) + const native = { + agent: 'codex', + path: `C:\\Users\\ada\\.codex\\sessions\\${rolloutName}`, + codexHome: null + } + const wsl = { + agent: 'codex', + path: `\\\\wsl$\\Ubuntu\\home\\ada\\.codex\\sessions\\${rolloutName}`, + codexHome: '\\\\wsl$\\Ubuntu\\home\\ada\\.codex' + } + const readSessionMetaId = vi.fn(async () => 'shared-session-id') + + await expect( + dedupeCodexRolloutCopyAliases([native, wsl], accessors, readSessionMetaId) + ).resolves.toEqual([native, wsl]) + expect(readSessionMetaId).not.toHaveBeenCalled() + }) + + it('proves only contested same-name candidates', async () => { + const real = { agent: 'codex', path: REAL_HOME_ROLLOUT, codexHome: null } + const managed = { agent: 'codex', path: MANAGED_HOME_ROLLOUT, codexHome: MANAGED_HOME } + const lone = { + agent: 'codex', + path: '/Users/ada/.codex/sessions/rollout-2026-08-20T09-00-00-lone.jsonl', + codexHome: null + } + const readSessionMetaId = vi.fn(async () => 'shared-session-id') + + await expect( + dedupeCodexRolloutCopyAliases([real, managed, lone], accessors, readSessionMetaId) + ).resolves.toEqual([real, lone]) + expect(readSessionMetaId).toHaveBeenCalledTimes(2) + }) + + // Why: the proof reads run inside the scan's 130s deadline, so a superseded + // scan must stop rather than drain a whole second history copy (#17888). + it('stops proving copies once the scan is cancelled', async () => { + const real = { agent: 'codex', path: REAL_HOME_ROLLOUT, codexHome: null } + const managed = { agent: 'codex', path: MANAGED_HOME_ROLLOUT, codexHome: MANAGED_HOME } + const controller = new AbortController() + controller.abort() + + await expect( + dedupeCodexRolloutCopyAliases( + [real, managed], + accessors, + async () => 'shared-session-id', + controller.signal + ) + ).rejects.toThrow() + }) +}) + describe('dedupeCodexSessionsBySessionId', () => { it('collapses a both-roots session to the real-home row', () => { const managed = codexSession({ diff --git a/src/main/ai-vault/codex-session-root-dedup.ts b/src/main/ai-vault/codex-session-root-dedup.ts index f40d29c1b14..6d01fdcc0ba 100644 --- a/src/main/ai-vault/codex-session-root-dedup.ts +++ b/src/main/ai-vault/codex-session-root-dedup.ts @@ -1,5 +1,7 @@ import type { AiVaultSession } from '../../shared/ai-vault-types' import { parseWslUncPath } from '../../shared/wsl-paths' +import { mapWithConcurrency } from '../../shared/map-with-concurrency' +import { throwIfAiVaultScanCancelled } from './ai-vault-scan-cancellation' import { sessionSortTime } from './session-scanner-accumulator' // Why: the session bridge and the real-home backfill hardlink one physical @@ -120,6 +122,107 @@ export function dedupeCodexRolloutFileAliases( }) } +/** Applies cheap hardlink proof before bounded cross-volume copy proof. */ +export async function dedupeCodexRolloutAliases( + candidates: readonly T[], + accessors: { + isCodex: (candidate: T) => boolean + getFilePath: (candidate: T) => string + getCodexHome: (candidate: T) => string | null + getHardlinkIdentity: (candidate: T) => string | null + }, + readSessionMetaId: (filePath: string) => Promise, + signal?: AbortSignal +): Promise { + const hardlinkDeduped = dedupeCodexRolloutFileAliases(candidates, accessors) + return dedupeCodexRolloutCopyAliases(hardlinkDeduped, accessors, readSessionMetaId, signal) +} + +// Matches the scan's own parse batch width. UNC candidates are additionally +// serialized by the WSL transcript gate, so this only widens native reads. +const COPY_PROOF_READ_CONCURRENCY = 8 + +/** + * Drops cross-volume rollout copies only when bounded session metadata proves + * the same Codex session id. Unreadable or ambiguous candidates remain for the + * full parser and its existing post-parse identity check. + */ +export async function dedupeCodexRolloutCopyAliases( + candidates: readonly T[], + accessors: { + isCodex: (candidate: T) => boolean + getFilePath: (candidate: T) => string + getCodexHome: (candidate: T) => string | null + }, + readSessionMetaId: (filePath: string) => Promise, + signal?: AbortSignal +): Promise { + const groups = new Map() + for (const candidate of candidates) { + if (!accessors.isCodex(candidate)) { + continue + } + const filePath = accessors.getFilePath(candidate) + const fileName = lastPathSegment(filePath) + if (!CODEX_ROLLOUT_FILE_NAME_PATTERN.test(fileName)) { + continue + } + const key = `${codexPathExecutionNamespace(filePath)}\0${fileName}` + const group = groups.get(key) + if (group) { + group.push(candidate) + } else { + groups.set(key, [candidate]) + } + } + + // Only same-name groups can alias, so the read fans out across every + // contested candidate at once rather than one group at a time — a corpus + // with a full second history copy has thousands of two-file groups. + const contested = [...groups.values()].filter((group) => group.length > 1).flat() + if (contested.length === 0) { + return [...candidates] + } + const identifiedIds = await mapWithConcurrency( + contested, + COPY_PROOF_READ_CONCURRENCY, + async (candidate) => { + throwIfAiVaultScanCancelled(signal) + return readSessionMetaId(accessors.getFilePath(candidate)) + } + ) + const idByCandidate = new Map( + contested.map((candidate, index) => [candidate, identifiedIds[index]]) + ) + + const aliasesToDrop = new Set() + for (const group of groups.values()) { + if (group.length < 2) { + continue + } + const bestById = new Map() + for (const candidate of group) { + const id = idByCandidate.get(candidate) + if (!id) { + continue + } + const filePath = accessors.getFilePath(candidate) + const rank = codexSessionRootRank(accessors.getCodexHome(candidate)) + const best = bestById.get(id) + if (!best || rank < best.rank || (rank === best.rank && filePath < best.filePath)) { + bestById.set(id, { candidate, rank, filePath }) + } + } + for (const candidate of group) { + const id = idByCandidate.get(candidate) + if (id && bestById.get(id)?.candidate !== candidate) { + aliasesToDrop.add(candidate) + } + } + } + return candidates.filter((candidate) => !aliasesToDrop.has(candidate)) +} + /** * Collapses parsed Codex sessions that share a rollout name and session id on * one execution host, keeping the canonical root's row. Requiring both the diff --git a/src/main/ai-vault/session-parse-cache-persistence.test.ts b/src/main/ai-vault/session-parse-cache-persistence.test.ts index 05d94acab1f..27aac09ce22 100644 --- a/src/main/ai-vault/session-parse-cache-persistence.test.ts +++ b/src/main/ai-vault/session-parse-cache-persistence.test.ts @@ -14,6 +14,7 @@ import * as fsPromises from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { AiVaultSession } from '../../shared/ai-vault-types' import { ensureSessionParseCacheLoaded, flushSessionParseCachePersistForTests, @@ -126,6 +127,53 @@ async function coldParseStats(path: string): Promise { return stats } +/** + * Schema 2 dropped the `appVersion` equality gate: a cache written by any + * release with this schema is now replayed straight into Agent Session + * History without re-reading the transcript (#17888 — the gate forced a + * multi-gigabyte cold scan after every update). The cost of that reuse is + * that nothing else invalidates a stale row, so `SCHEMA_VERSION` is the only + * remaining compatibility signal and forgetting to bump it ships wrong data. + * + * This table is the reminder. Changing the persisted session shape — or the + * meaning of a field the parsers fill — breaks this `satisfies` and the fix + * is to bump `SCHEMA_VERSION` in session-parse-cache-persistence.ts, not to + * silently extend the list. + */ +const CACHED_SESSION_FIELDS = { + id: true, + executionHostId: true, + executionHostPlatform: true, + agent: true, + sessionId: true, + title: true, + cwd: true, + branch: true, + model: true, + filePath: true, + codexHome: true, + createdAt: true, + updatedAt: true, + modifiedAt: true, + messageCount: true, + totalTokens: true, + previewMessages: true, + previewMessagesTruncated: true, + firstUserPrompt: true, + lastUserPrompt: true, + queuedMessageCount: true, + subagentTranscriptCount: true, + resumeCommand: true, + subagent: true, + structuredSession: true +} satisfies Record + +describe('cached session compatibility', () => { + it('pins the persisted session shape to the current parse-cache schema', () => { + expect(Object.keys(CACHED_SESSION_FIELDS).length).toBeGreaterThan(0) + }) +}) + describe('session parse cache persistence', () => { it('exposes a copy of the active configuration for background scanners', () => { const configured = { filePath: '/tmp/ai-vault-cache.json', appVersion: APP_VERSION } @@ -197,19 +245,45 @@ describe('session parse cache persistence', () => { expect(stats.reused).toBe(0) }) - it('ignores a cache file written by a different app version', async () => { + it('rejects the legacy schema 1 cache after parser semantics changed', async () => { const root = await makeTempDir() const cacheFile = join(root, 'session-parse-cache.json') initSessionParseCachePersistence({ filePath: cacheFile, appVersion: APP_VERSION }) const transcript = await writeTranscript(root) await parseAndPersist(transcript) - simulateRestart(cacheFile, '9.9.9-other') + const persisted = JSON.parse(await readFile(cacheFile, 'utf-8')) + persisted.schemaVersion = 1 + await writeFile(cacheFile, JSON.stringify(persisted)) + + simulateRestart(cacheFile) const stats = await coldParseStats(transcript) expect(stats.fullParses).toBe(1) expect(stats.reused).toBe(0) }) + it('reuses a schema-compatible cache written by a different app version', async () => { + const root = await makeTempDir() + const cacheFile = join(root, 'session-parse-cache.json') + initSessionParseCachePersistence({ filePath: cacheFile, appVersion: APP_VERSION }) + const transcript = await writeTranscript(root) + const candidate = await claudeCandidate(transcript) + await parseAndPersist(transcript) + + simulateRestart(cacheFile, '9.9.9-other') + await ensureSessionParseCacheLoaded() + + // Deleting the transcript proves the cross-version result comes entirely + // from the schema-compatible cache and performs no transcript read. + await rm(transcript) + const stats = createSessionParseStats() + const session = await parseAgentSessionFileCached(candidate, process.platform, stats) + expect(session).not.toBeNull() + expect(stats.reused).toBe(1) + expect(stats.fullParses).toBe(0) + expect(stats.bytesRead).toBe(0) + }) + it('seeding never clobbers a live in-memory entry', async () => { const root = await makeTempDir() const transcript = await writeTranscript(root) @@ -266,7 +340,13 @@ describe('session parse cache persistence', () => { vi.clearAllMocks() await ensureSessionParseCacheLoaded() - scheduleSessionParseCachePersist({ reused: 0, incremental: 2, fullParses: 5, bytesRead: 10 }) + scheduleSessionParseCachePersist({ + reused: 0, + incremental: 2, + fullParses: 5, + earlyStopped: 0, + bytesRead: 10 + }) await flushSessionParseCachePersistForTests() expect(fsPromises.readFile).not.toHaveBeenCalled() diff --git a/src/main/ai-vault/session-parse-cache-persistence.ts b/src/main/ai-vault/session-parse-cache-persistence.ts index 93e17941ab8..0a0cd44095e 100644 --- a/src/main/ai-vault/session-parse-cache-persistence.ts +++ b/src/main/ai-vault/session-parse-cache-persistence.ts @@ -12,8 +12,9 @@ import { type SessionParseStats } from './session-scanner-parse-cache' -// Bump when the persisted entry layout changes; a mismatched file is discarded whole. -const SCHEMA_VERSION = 1 +// Bump when the persisted entry layout or cached session semantics change; a +// mismatched file is discarded whole. +const SCHEMA_VERSION = 2 // Debounce so back-to-back scans (desktop IPC + runtime RPC) collapse into one write. const SAVE_DEBOUNCE_MS = 1_500 // The payload contains transcript-derived preview text; keep it user-only @@ -63,11 +64,13 @@ export function ensureSessionParseCacheLoaded(): Promise { } /** - * Schedule a debounced snapshot write after a scan that parsed something. - * Reused-only scans schedule no write (the file already reflects the cache). + * Schedule a debounced snapshot write after a scan that parsed something. An + * early-stopped transcript counts: its stat key moved, so the entry must be + * re-persisted or the next launch re-reads it. Reused-only scans schedule no + * write (the file already reflects the cache). */ export function scheduleSessionParseCachePersist(stats: SessionParseStats): void { - if (options === null || stats.incremental + stats.fullParses <= 0) { + if (options === null || stats.incremental + stats.fullParses + stats.earlyStopped <= 0) { return } const current = options @@ -105,7 +108,7 @@ async function loadPersistedEntries(current: SessionParseCachePersistenceOptions await sweepOrphanedTempFiles(current.filePath) try { const raw = await readFile(current.filePath, 'utf-8') - const entries = parsePersistedFile(JSON.parse(raw), current.appVersion) + const entries = parsePersistedFile(JSON.parse(raw)) if (entries) { seedSessionParseCache(entries) } @@ -132,17 +135,14 @@ async function sweepOrphanedTempFiles(filePath: string): Promise { } } -function parsePersistedFile( - parsed: unknown, - appVersion: string -): [string, PersistedSessionParseCacheEntry][] | null { +function parsePersistedFile(parsed: unknown): [string, PersistedSessionParseCacheEntry][] | null { if (typeof parsed !== 'object' || parsed === null) { return null } const file = parsed as Record - // Why: parser output shape/semantics may change between app versions, so a - // cross-version file is discarded — one cold scan per update is the price. - if (file.schemaVersion !== SCHEMA_VERSION || file.appVersion !== appVersion) { + // Why: application releases that keep this schema promise compatible cached + // session semantics, so an update does not force a multi-gigabyte cold scan. + if (file.schemaVersion !== SCHEMA_VERSION || typeof file.appVersion !== 'string') { return null } if (!Array.isArray(file.entries)) { diff --git a/src/main/ai-vault/session-scanner-codex-fast-path.test.ts b/src/main/ai-vault/session-scanner-codex-fast-path.test.ts new file mode 100644 index 00000000000..92e8d95ae93 --- /dev/null +++ b/src/main/ai-vault/session-scanner-codex-fast-path.test.ts @@ -0,0 +1,269 @@ +import { mkdir, mkdtemp, rm, stat, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { parseCodexSessionFile } from './session-scanner-codex-parser' +import { readCodexTimelineOnlyRecord } from './session-scanner-codex-record-fast-path' +import { + createSessionParseStats, + parseAgentSessionFileCached, + resetSessionParseCacheForTests +} from './session-scanner-parse-cache' +import type { FileWithMtime, SessionFileCandidate } from './session-scanner-types' + +// Codex serializes `RolloutLine` as `timestamp` + the adjacently tagged +// `RolloutItem`; payload enums (`ResponseItem`, `EventMsg`) are internally +// tagged, so their own `type` leads. Every fixture below matches that spelling. +const PAD = 'x'.repeat(2048) + +function record(type: string, payload: Record, timestamp: string): string { + return JSON.stringify({ timestamp, type, payload }) +} + +// Padded past the fast path's prefix limit: an undersized record is parsed +// exactly either way, so only oversized fixtures exercise the prefix match. +function paddedPayload(payloadType: string, extra: Record = {}) { + return { type: payloadType, ...extra, pad: PAD } +} + +let tempRoots: string[] = [] + +async function writeTranscript(lines: string[], name: string): Promise { + const root = await mkdtemp(join(tmpdir(), 'orca-codex-fast-path-')) + tempRoots.push(root) + const path = join(root, 'sessions', '2026', '08', '20', name) + await mkdir(dirname(path), { recursive: true }) + await writeFile(path, `${lines.join('\n')}\n`) + const fileStat = await stat(path) + return { + path, + mtimeMs: fileStat.mtimeMs, + modifiedAt: fileStat.mtime.toISOString(), + sizeBytes: fileStat.size + } +} + +beforeEach(() => { + resetSessionParseCacheForTests() +}) + +afterEach(async () => { + vi.restoreAllMocks() + await Promise.all(tempRoots.map((root) => rm(root, { recursive: true, force: true }))) + tempRoots = [] +}) + +describe('readCodexTimelineOnlyRecord', () => { + // The fast path is the complement of what `consumeCodexRecordLine` reads, so + // every record that feeds a visible field must fail the prefix match even + // when it is large — a >1KiB opening prompt is ordinary, and it is the title. + it.each([ + ['session_meta', record('session_meta', { id: 'a', pad: PAD }, '2026-08-20T10:00:00.000Z')], + [ + 'turn_context', + record('turn_context', { cwd: '/repo', pad: PAD }, '2026-08-20T10:00:00.000Z') + ], + [ + 'response_item/message', + record( + 'response_item', + paddedPayload('message', { role: 'user' }), + '2026-08-20T10:00:00.000Z' + ) + ], + [ + 'event_msg/user_message', + record('event_msg', paddedPayload('user_message'), '2026-08-20T10:00:00.000Z') + ], + [ + 'event_msg/agent_message', + record('event_msg', paddedPayload('agent_message'), '2026-08-20T10:00:00.000Z') + ], + [ + 'event_msg/item_completed', + record('event_msg', paddedPayload('item_completed'), '2026-08-20T10:00:00.000Z') + ], + [ + 'event_msg/token_count', + record('event_msg', paddedPayload('token_count'), '2026-08-20T10:00:00.000Z') + ] + ])('keeps %s on the full parser', (_label, line) => { + expect(readCodexTimelineOnlyRecord(Buffer.from(line))).toBeNull() + }) + + it.each([ + ['compacted', record('compacted', { message: PAD }, '2026-08-20T10:00:00.000Z')], + [ + 'response_item/function_call_output', + record('response_item', paddedPayload('function_call_output'), '2026-08-20T10:00:00.000Z') + ], + [ + 'response_item/image_generation_call', + record('response_item', paddedPayload('image_generation_call'), '2026-08-20T10:00:00.000Z') + ], + [ + 'response_item/reasoning', + record('response_item', paddedPayload('reasoning'), '2026-08-20T10:00:00.000Z') + ], + [ + 'event_msg/task_complete', + record('event_msg', paddedPayload('task_complete'), '2026-08-20T10:00:00.000Z') + ], + [ + 'event_msg/exec_command_output_delta', + record('event_msg', paddedPayload('exec_command_output_delta'), '2026-08-20T10:00:00.000Z') + ] + ])('takes %s off the full parser', (_label, line) => { + expect(readCodexTimelineOnlyRecord(Buffer.from(line))).toEqual({ + timestamp: '2026-08-20T10:00:00.000Z' + }) + }) + + it('falls back for small, reordered, or prefix-ambiguous records', () => { + const small = record('compacted', { message: 'short' }, '2026-08-20T10:00:00.000Z') + expect(readCodexTimelineOnlyRecord(Buffer.from(small))).toBeNull() + const reordered = `${JSON.stringify({ type: 'compacted', timestamp: '2026-08-20T10:00:00.000Z', payload: { message: PAD } })}` + expect(readCodexTimelineOnlyRecord(Buffer.from(reordered))).toBeNull() + // `type` pushed past the bounded prefix leaves the payload unidentified. + const lateType = `{"timestamp":"2026-08-20T10:00:00.000Z","type":"response_item","payload":{"pad":"${PAD}","type":"reasoning"}}` + expect(readCodexTimelineOnlyRecord(Buffer.from(lateType))).toBeNull() + }) +}) + +describe('Codex resumable parser fast path', () => { + it('matches the one-shot parser without JSON-parsing proven irrelevant large records', async () => { + // Distinct fillers so the assertion can name which large records the fast + // path skipped and which correctly fell back to the full parser. + const skippedFiller = `skipped-${'x'.repeat(2 * 1024 * 1024)}` + const fallbackFiller = `fallback-${'x'.repeat(2 * 1024 * 1024)}` + const file = await writeTranscript( + [ + record( + 'session_meta', + { id: 'fast-path-session', cwd: '/repo/app' }, + '2026-08-20T10:00:00.000Z' + ), + // A long opening prompt is the session title; it must survive the size gate. + record( + 'response_item', + { + type: 'message', + role: 'user', + content: [{ type: 'text', text: `Keep visible messages exact ${PAD}` }] + }, + '2026-08-20T10:00:01.000Z' + ), + record( + 'response_item', + { type: 'function_call_output', output: skippedFiller }, + '2026-08-20T10:00:02.000Z' + ), + record('compacted', { message: skippedFiller }, '2026-08-20T10:00:03.000Z'), + record( + 'event_msg', + { type: 'token_count', info: { total_token_usage: { total_tokens: 120 } } }, + '2026-08-20T10:00:04.000Z' + ), + record( + 'response_item', + { + type: 'message', + role: 'assistant', + content: [{ type: 'output_text', text: 'Visible answer' }] + }, + '2026-08-20T10:00:05.000Z' + ), + // A nested `payload.type` must not be mistaken for the record's own. + record( + 'event_msg', + { + metadata: { payload: { type: 'turn_aborted' } }, + type: 'token_count', + info: { total_token_usage: { total_tokens: 150 } } + }, + '2026-08-20T10:00:06.000Z' + ), + record('event_msg', { type: 'future_event', value: 1 }, '2026-08-20T10:00:07.000Z'), + // Reordered envelopes take the compatibility fallback. + JSON.stringify({ + type: 'future_record', + timestamp: '2026-08-20T10:00:08.000Z', + payload: { value: fallbackFiller } + }), + record('world_state', { state: skippedFiller }, '2026-08-20T10:00:09.000Z') + ], + 'rollout-2026-08-20T10-00-00-fast-path.jsonl' + ) + const expected = await parseCodexSessionFile(file, process.platform, null) + const candidate: SessionFileCandidate = { agent: 'codex', file, codexHome: null } + + const parseSpy = vi.spyOn(JSON, 'parse') + const actual = await parseAgentSessionFileCached(candidate, process.platform) + + expect(actual).toEqual(expected) + expect(actual).toMatchObject({ + messageCount: 2, + totalTokens: 150, + updatedAt: '2026-08-20T10:00:09.000Z' + }) + const parsedInputs = parseSpy.mock.calls + .map(([input]) => input) + .filter((input): input is string => typeof input === 'string') + expect(parsedInputs.some((input) => input.includes('skipped-xxx'))).toBe(false) + // A reordered envelope is not proven irrelevant, so it still parses whole. + expect(parsedInputs.some((input) => input.includes('fallback-xxx'))).toBe(true) + }) + + it('stops reading as soon as session_meta rejects a worker transcript', async () => { + const file = await writeTranscript( + [ + record( + 'session_meta', + { id: 'worker-session', source: { subagent: { thread_spawn: true } } }, + '2026-08-20T10:00:00.000Z' + ), + record('compacted', { message: 'x'.repeat(4 * 1024 * 1024) }, '2026-08-20T10:00:01.000Z') + ], + 'rollout-2026-08-20T10-00-00-worker.jsonl' + ) + const stats = createSessionParseStats() + + const session = await parseAgentSessionFileCached( + { agent: 'codex', file, codexHome: null }, + process.platform, + stats + ) + + expect(session).toBeNull() + expect(stats.bytesRead).toBeLessThan((file.sizeBytes ?? 0) / 2) + }) + + it('never re-reads a worker transcript that later grew', async () => { + const file = await writeTranscript( + [ + record( + 'session_meta', + { id: 'worker-session', thread_source: 'subagent' }, + '2026-08-20T10:00:00.000Z' + ), + record('compacted', { message: 'x'.repeat(1024 * 1024) }, '2026-08-20T10:00:01.000Z') + ], + 'rollout-2026-08-20T10-00-00-worker-grown.jsonl' + ) + const candidate: SessionFileCandidate = { agent: 'codex', file, codexHome: null } + await parseAgentSessionFileCached(candidate, process.platform) + + const grown = createSessionParseStats() + const session = await parseAgentSessionFileCached( + { ...candidate, file: { ...file, mtimeMs: file.mtimeMs + 1, sizeBytes: 99_000_000 } }, + process.platform, + grown + ) + + expect(session).toBeNull() + expect(grown.bytesRead).toBe(0) + // Reported apart from `incremental` so the scan span shows a dismissal, not + // an incremental parse that happened to read nothing. + expect(grown).toMatchObject({ earlyStopped: 1, incremental: 0, fullParses: 0 }) + }) +}) diff --git a/src/main/ai-vault/session-scanner-codex-parser.ts b/src/main/ai-vault/session-scanner-codex-parser.ts index 6887b7ed5d8..80a76ce0a92 100644 --- a/src/main/ai-vault/session-scanner-codex-parser.ts +++ b/src/main/ai-vault/session-scanner-codex-parser.ts @@ -34,6 +34,7 @@ import { subtractCodexUsage } from './session-scanner-values' import { remoteSessionContentLines } from './remote-session-content-lines' +import { readCodexTimelineOnlyRecord } from './session-scanner-codex-record-fast-path' export async function parseCodexSessionFile( file: FileWithMtime, @@ -270,6 +271,15 @@ function codexResumeStateFromParseState( ): ResumableSessionParseState { return { consumeLine: (line) => consumeCodexRecordLine(state, line), + consumeLineBytes: (line) => { + const timelineOnlyRecord = readCodexTimelineOnlyRecord(line) + if (timelineOnlyRecord) { + updateTimeline(state.accumulator, timelineOnlyRecord.timestamp) + } else { + consumeCodexRecordLine(state, line.toString('utf8')) + } + }, + shouldStop: () => state.rejectedWorkerSession, clone: () => codexResumeStateFromParseState(cloneCodexParseState(state), codexHome, titleReader), touchFile: (file) => { @@ -305,12 +315,8 @@ async function parseCodexSessionLines(args: { }) } -function extractCodexThreadSource(payload: Record): string | null { - return extractString(payload.thread_source) ?? extractString(payload.threadSource) -} - function isCodexWorkerSession(payload: Record): boolean { - const threadSource = extractCodexThreadSource(payload) + const threadSource = extractString(payload.thread_source) ?? extractString(payload.threadSource) if (threadSource) { return threadSource.toLowerCase() !== 'user' } diff --git a/src/main/ai-vault/session-scanner-codex-record-fast-path.ts b/src/main/ai-vault/session-scanner-codex-record-fast-path.ts new file mode 100644 index 00000000000..1c4322f89f6 --- /dev/null +++ b/src/main/ai-vault/session-scanner-codex-record-fast-path.ts @@ -0,0 +1,47 @@ +// Records below this size are decoded and parsed exactly: JSON.parse on a +// kilobyte costs less than the risk of a prefix heuristic, and the scan cost +// this path exists to remove is entirely in megabyte-scale records. +const CODEX_RECORD_PREFIX_LIMIT = 1024 +// serde emits `RolloutLine` as `timestamp` then the adjacently tagged +// `RolloutItem` (`type`, `payload`), and every tagged payload enum writes its +// own `type` first. Anything spelled differently takes the full parser. +const CODEX_RECORD_ENVELOPE_PATTERN = /^\{"timestamp":"([^"]+)","type":"([^"]+)","payload":/ +const CODEX_PAYLOAD_TYPE_PATTERN = /^\{"type":"([^"]+)"/ + +// Every record `consumeCodexRecordLine` reads beyond the timeline clock; the +// fast path is the complement, so teaching the parser a new record means +// adding it here or the scanner silently stops seeing it. +// `session-scanner-codex-fast-path.test.ts` pins each entry. +const PARSED_RECORD_TYPES = new Set(['session_meta', 'turn_context']) +const PARSED_RESPONSE_ITEM_TYPES = new Set(['message']) +const PARSED_EVENT_TYPES = new Set([ + 'item_completed', + 'user_message', + 'agent_message', + 'token_count' +]) + +/** Returns the timestamp only when the record cannot affect other visible session fields. */ +export function readCodexTimelineOnlyRecord(line: Buffer): { timestamp: string } | null { + if (line.length <= CODEX_RECORD_PREFIX_LIMIT) { + return null + } + const prefix = line.toString('utf8', 0, CODEX_RECORD_PREFIX_LIMIT) + const envelope = CODEX_RECORD_ENVELOPE_PATTERN.exec(prefix) + const timestamp = envelope?.[1] + const recordType = envelope?.[2] + if (!timestamp || !recordType || PARSED_RECORD_TYPES.has(recordType)) { + return null + } + if (recordType !== 'response_item' && recordType !== 'event_msg') { + return { timestamp } + } + // A payload whose type is unreadable from the bounded prefix stays ambiguous. + const payloadType = CODEX_PAYLOAD_TYPE_PATTERN.exec(prefix.slice(envelope[0].length))?.[1] + if (!payloadType) { + return null + } + const parsedPayloadTypes = + recordType === 'response_item' ? PARSED_RESPONSE_ITEM_TYPES : PARSED_EVENT_TYPES + return parsedPayloadTypes.has(payloadType) ? null : { timestamp } +} diff --git a/src/main/ai-vault/session-scanner-jsonl-reader.ts b/src/main/ai-vault/session-scanner-jsonl-reader.ts new file mode 100644 index 00000000000..613c50ef0ba --- /dev/null +++ b/src/main/ai-vault/session-scanner-jsonl-reader.ts @@ -0,0 +1,83 @@ +import { openTranscriptReadStream } from '../native-chat/wsl-transcript-fs-access' + +const NEWLINE_BYTE = 0x0a +const CARRIAGE_RETURN_BYTE = 0x0d + +type JsonlReadResult = { + consumedThrough: number + trailingPartialLine: string | null + bytesRead: number +} + +// Byte-accurate JSONL fold: offsets count bytes rather than decoded UTF-8 +// characters, so an incremental read resumes at an exact line boundary. +export async function consumeCompleteJsonlLines(args: { + path: string + start: number + onLine: (line: string) => void + onLineBytes?: (line: Buffer) => void + shouldStop?: () => boolean +}): Promise { + if (args.shouldStop?.()) { + return { consumedThrough: args.start, trailingPartialLine: null, bytesRead: 0 } + } + let consumedThrough = args.start + let bytesRead = 0 + // A piece list avoids O(record^2) copying when one record spans many chunks. + let remainderParts: Buffer[] = [] + let remainderLength = 0 + let stopped = false + + const stream = openTranscriptReadStream(args.path, { start: args.start }, 'scan') + for await (const chunk of stream as AsyncIterable) { + bytesRead += chunk.length + if (!chunk.includes(NEWLINE_BYTE)) { + remainderParts.push(chunk) + remainderLength += chunk.length + continue + } + const data = + remainderLength > 0 + ? Buffer.concat([...remainderParts, chunk], remainderLength + chunk.length) + : chunk + remainderParts = [] + remainderLength = 0 + let lineStart = 0 + let newlineIndex = data.indexOf(NEWLINE_BYTE, lineStart) + while (newlineIndex !== -1) { + let lineEnd = newlineIndex + if (lineEnd > lineStart && data[lineEnd - 1] === CARRIAGE_RETURN_BYTE) { + lineEnd-- + } + if (args.onLineBytes) { + args.onLineBytes(data.subarray(lineStart, lineEnd)) + } else { + args.onLine(data.toString('utf-8', lineStart, lineEnd)) + } + lineStart = newlineIndex + 1 + if (args.shouldStop?.()) { + stopped = true + break + } + newlineIndex = data.indexOf(NEWLINE_BYTE, lineStart) + } + consumedThrough += lineStart + if (stopped) { + remainderParts = [] + remainderLength = 0 + break + } + if (lineStart < data.length) { + // Copy the tail so retaining it does not pin the whole chunk buffer. + remainderParts = [Buffer.from(data.subarray(lineStart))] + remainderLength = data.length - lineStart + } + } + + return { + consumedThrough, + trailingPartialLine: + remainderLength > 0 ? Buffer.concat(remainderParts, remainderLength).toString('utf-8') : null, + bytesRead + } +} diff --git a/src/main/ai-vault/session-scanner-parse-cache-agents.test.ts b/src/main/ai-vault/session-scanner-parse-cache-agents.test.ts index edb5b1db20a..9208b490fb1 100644 --- a/src/main/ai-vault/session-scanner-parse-cache-agents.test.ts +++ b/src/main/ai-vault/session-scanner-parse-cache-agents.test.ts @@ -163,7 +163,9 @@ describe('codex-specific resume behavior', () => { process.platform, stats ) - expect(stats.incremental).toBe(1) + // The append is dismissed without a read, so it is an early stop rather + // than an incremental parse. + expect(stats).toMatchObject({ earlyStopped: 1, incremental: 0 }) expect(grown).toBeNull() }) diff --git a/src/main/ai-vault/session-scanner-parse-cache.ts b/src/main/ai-vault/session-scanner-parse-cache.ts index aaabaf76d6e..27fc5e0f950 100644 --- a/src/main/ai-vault/session-scanner-parse-cache.ts +++ b/src/main/ai-vault/session-scanner-parse-cache.ts @@ -1,7 +1,4 @@ -import { - openTranscriptReadStream, - readTranscriptSlice -} from '../native-chat/wsl-transcript-fs-access' +import { readTranscriptSlice } from '../native-chat/wsl-transcript-fs-access' import type { AiVaultSession } from '../../shared/ai-vault-types' import { createAntigravitySessionResumeState } from './session-scanner-antigravity-parser' import { parseAgentSessionFile } from './session-scanner-agent-parser' @@ -16,13 +13,12 @@ import { countSubagentTranscripts } from './session-scanner-subagent-transcripts import { countOmpSubagentTranscripts } from './session-scanner-omp-subagent-transcripts' import type { ResumableSessionParseState, SessionFileCandidate } from './session-scanner-types' import { refreshCachedCodexTitle } from './session-scanner-codex-cached-title' +import { consumeCompleteJsonlLines } from './session-scanner-jsonl-reader' // Sized past the default recency cap (1000) plus the in-scope cap (2000) so a // full steady-state result set stays resident between forced rescans. const MAX_CACHE_ENTRIES = 4096 - const NEWLINE_BYTE = 0x0a -const CARRIAGE_RETURN_BYTE = 0x0d type ResumePoint = { state: ResumableSessionParseState @@ -88,11 +84,15 @@ export type SessionParseStats = { reused: number incremental: number fullParses: number + // Transcripts the parser already excluded (Codex workers), re-listed after a + // write and dismissed without reading. Counted apart from `incremental` so a + // scan span still shows how much work the early stop actually removed. + earlyStopped: number bytesRead: number } export function createSessionParseStats(): SessionParseStats { - return { reused: 0, incremental: 0, fullParses: 0, bytesRead: 0 } + return { reused: 0, incremental: 0, fullParses: 0, earlyStopped: 0, bytesRead: 0 } } const cache = new Map() @@ -264,8 +264,13 @@ async function parseResumableCandidate(args: { // or the next resume would double-count the lines applied before the error. const state = canResume ? resume.state.clone() : args.stateFactory() const startOffset = canResume ? resume.byteOffset : 0 + // Mirrors the reader's entry guard so a dismissed transcript is not reported + // as an incremental parse that read nothing. + const stoppedBeforeRead = state.shouldStop?.() === true if (args.stats) { - if (canResume) { + if (stoppedBeforeRead) { + args.stats.earlyStopped++ + } else if (canResume) { args.stats.incremental++ } else { args.stats.fullParses++ @@ -275,7 +280,11 @@ async function parseResumableCandidate(args: { const readResult = await consumeCompleteJsonlLines({ path: file.path, start: startOffset, - onLine: (line) => state.consumeLine(line) + onLine: (line) => state.consumeLine(line), + // Bound: the optional hooks are declared as methods, so a parser written + // with method syntax must not lose `this` on the way into the reader. + onLineBytes: state.consumeLineBytes?.bind(state), + shouldStop: state.shouldStop?.bind(state) }) if (args.stats) { args.stats.bytesRead += readResult.bytesRead @@ -311,70 +320,3 @@ async function endsWithNewlineAt(path: string, offset: number): Promise const slice = await readTranscriptSlice(path, offset - 1, 1, 'scan') return slice.length === 1 && slice[0] === NEWLINE_BYTE } - -type JsonlReadResult = { - consumedThrough: number - trailingPartialLine: string | null - bytesRead: number -} - -// Byte-accurate replacement for readline: offsets must count bytes (not -// UTF-8-decoded characters) so a resumed read starts exactly where the last -// complete line ended. -async function consumeCompleteJsonlLines(args: { - path: string - start: number - onLine: (line: string) => void -}): Promise { - let consumedThrough = args.start - let bytesRead = 0 - // Why a piece list: re-joining the partial line with every chunk made one - // oversized record (a big tool result) cost O(record^2). Joining once, when a - // newline finally arrives, keeps it linear. - let remainderParts: Buffer[] = [] - let remainderLength = 0 - - const stream = openTranscriptReadStream(args.path, { start: args.start }, 'scan') - for await (const chunk of stream as AsyncIterable) { - bytesRead += chunk.length - // Why check the chunk alone: the pieces held over are all mid-line, so none - // of them contains a newline. - if (!chunk.includes(NEWLINE_BYTE)) { - remainderParts.push(chunk) - remainderLength += chunk.length - continue - } - const data = - remainderLength > 0 - ? Buffer.concat([...remainderParts, chunk], remainderLength + chunk.length) - : chunk - remainderParts = [] - remainderLength = 0 - let lineStart = 0 - let newlineIndex = data.indexOf(NEWLINE_BYTE, lineStart) - while (newlineIndex !== -1) { - let lineEnd = newlineIndex - if (lineEnd > lineStart && data[lineEnd - 1] === CARRIAGE_RETURN_BYTE) { - lineEnd-- - } - args.onLine(data.toString('utf-8', lineStart, lineEnd)) - lineStart = newlineIndex + 1 - newlineIndex = data.indexOf(NEWLINE_BYTE, lineStart) - } - consumedThrough += lineStart - if (lineStart < data.length) { - // Copy the tail so retaining it doesn't pin the whole chunk buffer. - remainderParts = [Buffer.from(data.subarray(lineStart))] - remainderLength = data.length - lineStart - } - } - - const trailingPartialLine = - remainderLength > 0 ? Buffer.concat(remainderParts, remainderLength).toString('utf-8') : null - - return { - consumedThrough, - trailingPartialLine, - bytesRead - } -} diff --git a/src/main/ai-vault/session-scanner-types.ts b/src/main/ai-vault/session-scanner-types.ts index ab553b63568..6216e3d7cf8 100644 --- a/src/main/ai-vault/session-scanner-types.ts +++ b/src/main/ai-vault/session-scanner-types.ts @@ -92,6 +92,11 @@ export type ResumableParseFinalizeOptions = { // read or a display-only trailing line can never corrupt the cached fold. export type ResumableSessionParseState = { consumeLine(line: string): void + // Optional zero-copy path for parsers that can reject irrelevant records + // from a bounded byte prefix before decoding a potentially huge JSONL line. + consumeLineBytes?(line: Buffer): void + // Lets a parser terminate an excluded transcript without draining the file. + shouldStop?(): boolean clone(): ResumableSessionParseState // Refresh per-scan file metadata (mtime display string) without re-parsing. touchFile(file: FileWithMtime): void diff --git a/src/main/ai-vault/session-scanner.ts b/src/main/ai-vault/session-scanner.ts index 4b0deb4c68f..3d27dccd1ef 100644 --- a/src/main/ai-vault/session-scanner.ts +++ b/src/main/ai-vault/session-scanner.ts @@ -8,9 +8,10 @@ import { withSpan } from '../observability/tracer' import { sessionSortTime } from './session-scanner-accumulator' import { codexRolloutHardlinkIdentity, - dedupeCodexRolloutFileAliases, + dedupeCodexRolloutAliases, dedupeCodexSessionsBySessionId } from './codex-session-root-dedup' +import { readCodexRolloutSessionMetaId } from '../codex/codex-rollout-session-meta' import { createAntigravityWorkspaceResolver, readLocalAntigravityHistory, @@ -82,7 +83,7 @@ export async function scanAiVaultSessions( const discoveries = await discoverAiVaultSessionSources({ options, limitPerAgent, issues }) throwIfAiVaultScanCancelled(options.signal) - const candidates = dedupeCodexRolloutFileAliases( + const candidates = await dedupeCodexRolloutAliases( discoveries .flatMap((discovery) => discovery.files.map((file): SessionFileCandidate => ({ @@ -107,7 +108,9 @@ export async function scanAiVaultSessions( getFilePath: (candidate) => candidate.file.path, getCodexHome: (candidate) => candidate.codexHome, getHardlinkIdentity: (candidate) => codexRolloutHardlinkIdentity(candidate.file) - } + }, + (filePath) => readCodexRolloutSessionMetaId(filePath, options.signal, 'scan'), + options.signal ) const parsedSessions = await parseSessionCandidates({ @@ -144,6 +147,7 @@ export async function scanAiVaultSessions( span.setAttribute('reused', parseStats.reused) span.setAttribute('incremental', parseStats.incremental) span.setAttribute('fullParses', parseStats.fullParses) + span.setAttribute('earlyStopped', parseStats.earlyStopped) span.setAttribute('bytesRead', parseStats.bytesRead) span.setAttribute('issues', issues.length) diff --git a/src/main/appimage-runtime-identity.test.ts b/src/main/appimage-runtime-identity.test.ts new file mode 100644 index 00000000000..f9a7319ee1c --- /dev/null +++ b/src/main/appimage-runtime-identity.test.ts @@ -0,0 +1,240 @@ +import { + chmodSync, + mkdirSync, + mkdtempSync, + renameSync, + rmSync, + symlinkSync, + writeFileSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { + hasAppImagePathEnvironment, + resolveAppImageRuntimeIdentity +} from './appimage-runtime-identity' + +const fixtureRoots: string[] = [] + +function appImageHeader(machine: number): Buffer { + const header = Buffer.alloc(64) + header.set([0x7f, 0x45, 0x4c, 0x46, 0x02, 0x01, 0x01]) + header.set([0x41, 0x49, 0x02], 8) + header.writeUInt16LE(machine, 18) + return header +} + +function createFixture(appDirName = '.mount_Orca123', machine = 0x3e) { + const root = mkdtempSync(join(tmpdir(), 'orca-appimage-identity-')) + const appImagePath = join(root, 'Applications', 'Orca.AppImage') + const appDirPath = join(root, appDirName) + const execPath = join(appDirPath, 'orca-ide') + const resourcesPath = join(appDirPath, 'resources') + const packageTypePath = join(resourcesPath, 'package-type') + const packageMarkerPath = join(resourcesPath, 'app.asar.unpacked', 'out', 'package.json') + fixtureRoots.push(root) + mkdirSync(dirname(appImagePath), { recursive: true }) + mkdirSync(dirname(packageMarkerPath), { recursive: true }) + writeFileSync(appImagePath, appImageHeader(machine), { mode: 0o755 }) + writeFileSync(join(appDirPath, 'AppRun'), '#!/bin/sh\n', { mode: 0o755 }) + writeFileSync(execPath, appImageHeader(machine), { mode: 0o755 }) + writeFileSync( + packageMarkerPath, + JSON.stringify({ name: 'orca-compiled-output', type: 'commonjs', private: true }) + ) + return { + root, + appImagePath, + appDirPath, + execPath, + resourcesPath, + packageTypePath, + packageMarkerPath, + identity: { + platform: 'linux' as const, + environment: { APPIMAGE: appImagePath, APPDIR: appDirPath }, + execPath, + resourcesPath + } + } +} + +afterEach(() => { + for (const root of fixtureRoots.splice(0)) { + rmSync(root, { recursive: true, force: true }) + } +}) + +describe.skipIf(process.platform === 'win32')('resolveAppImageRuntimeIdentity', () => { + it.each([ + ['x64', 0x3e, '.mount_Orca123'], + ['ARM64 extract-and-run', 0xb7, 'appimage_extracted_123'] + ])('accepts a complete %s AppImage runtime', (_architecture, machine, appDirName) => { + const fixture = createFixture(appDirName, machine) + expect(resolveAppImageRuntimeIdentity(fixture.identity)).toEqual({ + appImagePath: fixture.appImagePath + }) + }) + + it('accepts an AppImage moved independently of its runtime directory', () => { + const fixture = createFixture() + const movedPath = join(fixture.root, 'Moved Apps', 'Orca current.AppImage') + mkdirSync(dirname(movedPath), { recursive: true }) + renameSync(fixture.appImagePath, movedPath) + fixture.identity.environment.APPIMAGE = movedPath + expect(resolveAppImageRuntimeIdentity(fixture.identity)?.appImagePath).toBe(movedPath) + }) + + it('accepts the exact AppImage package-type marker', () => { + const fixture = createFixture() + rmSync(fixture.packageMarkerPath) + writeFileSync(fixture.packageTypePath, 'AppImage') + expect(resolveAppImageRuntimeIdentity(fixture.identity)).not.toBeNull() + }) + + it.each([ + ['APPIMAGE', undefined], + ['APPIMAGE', 'relative/Orca.AppImage'], + ['APPDIR', undefined], + ['APPDIR', 'relative/mount'], + ['APPIMAGE', '/tmp/Orca\0.AppImage'] + ] as const)('rejects an unusable %s value', (key, value) => { + const fixture = createFixture() + expect( + resolveAppImageRuntimeIdentity({ + ...fixture.identity, + environment: { ...fixture.identity.environment, [key]: value } + }) + ).toBeNull() + }) + + it.each([ + ['an ordinary executable', (path: string) => writeFileSync(path, '#!/bin/sh\n')], + [ + 'bad ELF magic', + (path: string) => { + const header = appImageHeader(0x3e) + header[0] = 0 + writeFileSync(path, header) + } + ], + [ + 'bad AppImage type magic', + (path: string) => { + const header = appImageHeader(0x3e) + header[10] = 1 + writeFileSync(path, header) + } + ], + ['a non-executable file', (path: string) => chmodSync(path, 0o644)], + [ + 'a directory', + (path: string) => { + rmSync(path) + mkdirSync(path) + } + ] + ])('rejects APPIMAGE pointing to %s', (_case, mutate) => { + const fixture = createFixture() + mutate(fixture.appImagePath) + expect(resolveAppImageRuntimeIdentity(fixture.identity)).toBeNull() + }) + + it.each([ + [ + 'AppRun', + (fixture: ReturnType) => rmSync(join(fixture.appDirPath, 'AppRun')) + ], + [ + 'an executable AppRun', + (fixture: ReturnType) => + chmodSync(join(fixture.appDirPath, 'AppRun'), 0o644) + ], + [ + 'the Orca package marker', + (fixture: ReturnType) => rmSync(fixture.packageMarkerPath) + ] + ])('rejects a runtime missing %s', (_case, mutate) => { + const fixture = createFixture() + mutate(fixture) + expect(resolveAppImageRuntimeIdentity(fixture.identity)).toBeNull() + }) + + it('rejects forged AppImage variables around an ordinary packaged layout', () => { + const fixture = createFixture() + rmSync(join(fixture.appDirPath, 'AppRun')) + expect(resolveAppImageRuntimeIdentity(fixture.identity)).toBeNull() + }) + + it('rejects a package marker for a different application', () => { + const fixture = createFixture() + writeFileSync( + fixture.packageMarkerPath, + JSON.stringify({ name: 'foreign-compiled-output', type: 'commonjs' }) + ) + expect(resolveAppImageRuntimeIdentity(fixture.identity)).toBeNull() + }) + + it('rejects an inexact package-type marker without the Orca fallback', () => { + const fixture = createFixture() + rmSync(fixture.packageMarkerPath) + writeFileSync(fixture.packageTypePath, 'appimage') + expect(resolveAppImageRuntimeIdentity(fixture.identity)).toBeNull() + }) + + it('rejects payload evidence that resolves outside APPDIR', () => { + const fixture = createFixture() + const externalAppRun = join(fixture.root, 'foreign-AppRun') + writeFileSync(externalAppRun, '#!/bin/sh\n', { mode: 0o755 }) + rmSync(join(fixture.appDirPath, 'AppRun')) + symlinkSync(externalAppRun, join(fixture.appDirPath, 'AppRun')) + expect(resolveAppImageRuntimeIdentity(fixture.identity)).toBeNull() + }) + + it('rejects a package marker that resolves outside APPDIR', () => { + const fixture = createFixture() + const externalMarker = join(fixture.root, 'foreign-package.json') + writeFileSync( + externalMarker, + JSON.stringify({ name: 'orca-compiled-output', type: 'commonjs' }) + ) + rmSync(fixture.packageMarkerPath) + symlinkSync(externalMarker, fixture.packageMarkerPath) + expect(resolveAppImageRuntimeIdentity(fixture.identity)).toBeNull() + }) + + it('rejects inherited AppImage variables around a non-AppImage executable', () => { + const fixture = createFixture() + expect( + resolveAppImageRuntimeIdentity({ + ...fixture.identity, + execPath: join(fixture.root, 'opt', 'Orca', 'orca-ide'), + resourcesPath: join(fixture.root, 'opt', 'Orca', 'resources') + }) + ).toBeNull() + }) + + it('rejects a resources path outside the runtime root', () => { + const fixture = createFixture() + expect( + resolveAppImageRuntimeIdentity({ + ...fixture.identity, + resourcesPath: `${fixture.appDirPath}-other/resources` + }) + ).toBeNull() + }) + + it('rejects the identity off Linux', () => { + const fixture = createFixture() + expect(resolveAppImageRuntimeIdentity({ ...fixture.identity, platform: 'darwin' })).toBeNull() + }) +}) + +describe('hasAppImagePathEnvironment', () => { + it('requires the AppImage file path before treating the runtime as verifiable', () => { + expect(hasAppImagePathEnvironment({ APPIMAGE: '/tmp/Orca.AppImage' })).toBe(true) + expect(hasAppImagePathEnvironment({ APPDIR: '/tmp/.mount_Orca123' })).toBe(false) + expect(hasAppImagePathEnvironment({ APPIMAGE: '', APPDIR: '/tmp/.mount_Orca123' })).toBe(false) + }) +}) diff --git a/src/main/appimage-runtime-identity.ts b/src/main/appimage-runtime-identity.ts new file mode 100644 index 00000000000..08b2a92dd65 --- /dev/null +++ b/src/main/appimage-runtime-identity.ts @@ -0,0 +1,196 @@ +import { + closeSync, + constants, + fstatSync, + openSync, + readSync, + realpathSync, + statSync, + type Stats +} from 'node:fs' +import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path' + +const APPIMAGE_HEADER_LENGTH = 11 +const ORCA_PACKAGE_MARKER_MAX_BYTES = 1_024 +const PACKAGE_TYPE_MARKER_MAX_BYTES = 32 + +export type AppImageRuntimeIdentity = { + appImagePath: string +} + +export type AppImageRuntimeIdentityInput = { + platform?: NodeJS.Platform + environment?: NodeJS.ProcessEnv + execPath?: unknown + resourcesPath?: unknown +} + +function isAbsolutePath(value: unknown): value is string { + return typeof value === 'string' && value.length > 0 && !value.includes('\0') && isAbsolute(value) +} + +function readExact(fd: number, length: number): Buffer | null { + const bytes = Buffer.alloc(length) + let offset = 0 + while (offset < length) { + const count = readSync(fd, bytes, offset, length - offset, offset) + if (count === 0) { + return null + } + offset += count + } + return bytes +} + +function inspectRegularFile( + filePath: string, + inspect: (fd: number, stats: Stats) => T +): T | null { + let fd: number | undefined + try { + fd = openSync(filePath, constants.O_RDONLY | constants.O_NONBLOCK) + const stats = fstatSync(fd) + return stats.isFile() ? inspect(fd, stats) : null + } catch { + return null + } finally { + if (fd !== undefined) { + closeSync(fd) + } + } +} + +function hasAppImageHeader(appImagePath: string): boolean { + return ( + inspectRegularFile(appImagePath, (fd, stats) => { + const header = readExact(fd, APPIMAGE_HEADER_LENGTH) + return ( + (stats.mode & 0o111) !== 0 && + header?.subarray(0, 4).equals(Buffer.from([0x7f, 0x45, 0x4c, 0x46])) === true && + header.subarray(8, 11).equals(Buffer.from([0x41, 0x49, 0x02])) + ) + }) === true + ) +} + +function isInside(rootPath: string, candidatePath: string): boolean { + const candidateRelative = relative(rootPath, candidatePath) + return ( + candidateRelative.length > 0 && + candidateRelative !== '..' && + !candidateRelative.startsWith(`..${sep}`) && + !isAbsolute(candidateRelative) + ) +} + +function isExecutablePayloadFile(runtimeRoot: string, filePath: string): boolean { + try { + const canonicalPath = realpathSync(filePath) + const stats = statSync(canonicalPath) + return stats.isFile() && (stats.mode & 0o111) !== 0 && isInside(runtimeRoot, canonicalPath) + } catch { + return false + } +} + +function readPayloadMarker( + runtimeRoot: string, + markerPath: string, + maxBytes: number +): string | null { + try { + const canonicalPath = realpathSync(markerPath) + if (!isInside(runtimeRoot, canonicalPath)) { + return null + } + return inspectRegularFile(canonicalPath, (fd, stats) => + stats.size === 0 || stats.size > maxBytes + ? null + : (readExact(fd, stats.size)?.toString('utf8') ?? null) + ) + } catch { + return null + } +} + +function hasAppImagePackageEvidence(runtimeRoot: string, resourcesPath: string): boolean { + const packageType = readPayloadMarker( + runtimeRoot, + join(resourcesPath, 'package-type'), + PACKAGE_TYPE_MARKER_MAX_BYTES + ) + if (packageType === 'AppImage') { + return true + } + + const content = readPayloadMarker( + runtimeRoot, + join(resourcesPath, 'app.asar.unpacked', 'out', 'package.json'), + ORCA_PACKAGE_MARKER_MAX_BYTES + ) + try { + const marker: unknown = JSON.parse(content ?? '') + return ( + typeof marker === 'object' && + marker !== null && + 'name' in marker && + marker.name === 'orca-compiled-output' && + 'type' in marker && + marker.type === 'commonjs' + ) + } catch { + return false + } +} + +/** Returns whether the process inherited an AppImage file path to validate. */ +export function hasAppImagePathEnvironment(environment: NodeJS.ProcessEnv = process.env): boolean { + return Boolean(environment.APPIMAGE) +} + +export function resolveAppImageRuntimeIdentity( + input: AppImageRuntimeIdentityInput = {} +): AppImageRuntimeIdentity | null { + if ((input.platform ?? process.platform) !== 'linux') { + return null + } + + const environment = input.environment ?? process.env + const appImagePath = environment.APPIMAGE + const appDirPath = environment.APPDIR + const execPath = input.execPath ?? process.execPath + const resourcesPath = input.resourcesPath ?? process.resourcesPath + if ( + !isAbsolutePath(appImagePath) || + !isAbsolutePath(appDirPath) || + !isAbsolutePath(execPath) || + !isAbsolutePath(resourcesPath) + ) { + return null + } + + const runtimeRoot = resolve(appDirPath) + if ( + resolve(dirname(execPath)) !== runtimeRoot || + resolve(resourcesPath) !== resolve(join(runtimeRoot, 'resources')) || + !hasAppImageHeader(appImagePath) + ) { + return null + } + + try { + const realRuntimeRoot = realpathSync(runtimeRoot) + if ( + realpathSync(resourcesPath) !== join(realRuntimeRoot, 'resources') || + !isExecutablePayloadFile(realRuntimeRoot, execPath) || + !isExecutablePayloadFile(realRuntimeRoot, join(runtimeRoot, 'AppRun')) || + !hasAppImagePackageEvidence(realRuntimeRoot, resourcesPath) + ) { + return null + } + } catch { + return null + } + + return { appImagePath } +} diff --git a/src/main/artifacts/artifact-create-intent-store.test.ts b/src/main/artifacts/artifact-create-intent-store.test.ts index 70cdbec4bb1..f512f5ab439 100644 --- a/src/main/artifacts/artifact-create-intent-store.test.ts +++ b/src/main/artifacts/artifact-create-intent-store.test.ts @@ -274,9 +274,12 @@ describe('artifact create intent store', () => { ).toThrow(/unsupported format/) }) - it('persists a 5 MiB escaped artifact within the recovery limit', async () => { + it('persists an escaped artifact within the recovery limit', async () => { const userDataPath = await createUserDataPath() - const nearLimitBody = { ...body, content: '"'.repeat(ARTIFACT_MAX_CONTENT_BYTES) } + const nearLimitBody = { + ...body, + content: '"'.repeat(Math.floor(ARTIFACT_MAX_CONTENT_BYTES / 2)) + } expect( artifactWriteRequestByteLength({ sourceKey: '/repo/report.html', ...nearLimitBody }) ).toBeLessThanOrEqual(ARTIFACT_MAX_REQUEST_BYTES) @@ -307,7 +310,7 @@ describe('artifact create intent store', () => { 'key-a', { ...body, content: 'x'.repeat(ARTIFACT_MAX_CONTENT_BYTES + 1) } ) - ).toThrow(/5 MiB limit/) + ).toThrow(/10 MiB limit/) }) it('rejects a recovery body whose escaped request exceeds the transport budget', async () => { diff --git a/src/main/artifacts/artifact-create-intent-store.ts b/src/main/artifacts/artifact-create-intent-store.ts index 8816f5e1d97..02869245ebd 100644 --- a/src/main/artifacts/artifact-create-intent-store.ts +++ b/src/main/artifacts/artifact-create-intent-store.ts @@ -207,7 +207,7 @@ export function getOrCreateArtifactCreateIntent( body: ArtifactWriteBody ): ArtifactCreateIntent { if (artifactContentByteLength(body.content) > ARTIFACT_MAX_CONTENT_BYTES) { - throw new Error('Artifact content exceeds the 5 MiB limit.') + throw new Error('Artifact content exceeds the 10 MiB limit.') } if (artifactIntentRequestByteLength(sourceKey, body) > ARTIFACT_MAX_REQUEST_BYTES) { throw new Error('Artifact create recovery record exceeds the supported size.') diff --git a/src/main/browser/agent-browser-bridge-capture-commands.ts b/src/main/browser/agent-browser-bridge-capture-commands.ts new file mode 100644 index 00000000000..5719bfbc7b6 --- /dev/null +++ b/src/main/browser/agent-browser-bridge-capture-commands.ts @@ -0,0 +1,185 @@ +import { existsSync, readFileSync } from 'node:fs' +import type { BrowserScreenshotResult, BrowserEvalResult } from '../../shared/runtime-types' +import { BrowserError } from './cdp-bridge' +import { captureFullPageScreenshot } from './cdp-screenshot' +import { acquireElectronDebugger } from './electron-debugger-lease' +import { AgentBrowserBridgeUtilityCommands } from './agent-browser-bridge-utility-commands' +import { ORCA_TAB_SESSION_PREFIX } from './agent-browser-orphan-sweep' + +export abstract class AgentBrowserBridgeCaptureCommands extends AgentBrowserBridgeUtilityCommands { + async screenshot( + format?: string, + worktreeId?: string, + browserPageId?: string + ): Promise { + // Why: agent-browser writes the screenshot to a temp file and returns its path; read it and return base64. + return this.enqueueTargetedCommand( + worktreeId, + browserPageId, + async (sessionName) => { + return this.captureScreenshotCommand(sessionName, ['screenshot'], 300, format) + }, + { ensureVisible: false } + ) + } + + async fullPageScreenshot( + format?: string, + worktreeId?: string, + browserPageId?: string + ): Promise { + return this.enqueueTargetedCommand( + worktreeId, + browserPageId, + async (sessionName, target) => { + return this.captureFullPageScreenshotCommand( + sessionName, + target.webContentsId, + 500, + format === 'jpeg' ? 'jpeg' : 'png' + ) + }, + { ensureVisible: false } + ) + } + + private readScreenshotFromResult(raw: unknown, format?: string): BrowserScreenshotResult { + const parsed = raw as { path?: string } | undefined + if (!parsed?.path) { + throw new BrowserError('browser_error', 'Screenshot returned no file path') + } + if (!existsSync(parsed.path)) { + throw new BrowserError('browser_error', `Screenshot file not found: ${parsed.path}`) + } + const data = readFileSync(parsed.path).toString('base64') + return { data, format: format === 'jpeg' ? 'jpeg' : 'png' } as BrowserScreenshotResult + } + + private async captureScreenshotCommand( + sessionName: string, + commandArgs: string[], + settleMs: number, + format?: string + ): Promise { + return this.withSerializedScreenshotAccess(async () => { + const session = this.sessions.get(sessionName) + const restore = session + ? await this.browserManager.acquireAutomationVisibility(session.webContentsId) + : () => {} + try { + // Why: let the compositor settle to a painted frame after the lease, inside the screenshot lock so another tab can't change lease state first. + await new Promise((r) => setTimeout(r, settleMs)) + const raw = await this.execAgentBrowser(sessionName, commandArgs) + return this.readScreenshotFromResult(raw, format) + } finally { + restore() + } + }) + } + + private async captureFullPageScreenshotCommand( + sessionName: string, + webContentsId: number, + settleMs: number, + format: 'png' | 'jpeg' + ): Promise { + return this.withSerializedScreenshotAccess(async () => { + const session = this.sessions.get(sessionName) + const restore = session + ? await this.browserManager.acquireAutomationVisibility(session.webContentsId) + : () => {} + try { + // Why: the guest compositor needs a beat to paint a fresh frame after becoming paintable, or CDP captures a stale surface. + await new Promise((r) => setTimeout(r, settleMs)) + const wc = this.getWebContents(webContentsId) + if (!wc) { + throw new BrowserError('browser_tab_not_found', 'Tab is no longer available') + } + return await captureFullPageScreenshot(wc, format) + } catch (error) { + throw new BrowserError('browser_error', (error as Error).message) + } finally { + restore() + } + }) + } + + private async withSerializedScreenshotAccess(execute: () => Promise): Promise { + const previousTurn = this.screenshotTurn.catch(() => {}) + let releaseTurn!: () => void + this.screenshotTurn = new Promise((resolve) => { + releaseTurn = resolve + }) + await previousTurn + try { + return await execute() + } finally { + releaseTurn() + } + } + + async evaluate( + expression: string, + worktreeId?: string, + browserPageId?: string + ): Promise { + return this.enqueueTargetedCommand( + worktreeId, + browserPageId, + async (_sessionName, target) => { + const wc = this.requireTargetWebContents(target) + let releaseDebugger = (): void => {} + try { + releaseDebugger = acquireElectronDebugger(wc).release + const { result, exceptionDetails } = (await wc.debugger.sendCommand('Runtime.evaluate', { + expression, + returnByValue: true, + awaitPromise: true + })) as { + result: { value?: unknown; description?: string } + exceptionDetails?: { text: string; exception?: { description?: string } } + } + if (exceptionDetails) { + throw new BrowserError( + 'browser_eval_error', + exceptionDetails.exception?.description ?? exceptionDetails.text + ) + } + + const currentTarget = this.resolveCommandTarget(worktreeId, target.browserPageId) + if (currentTarget.webContentsId !== target.webContentsId) { + throw new BrowserError( + 'browser_tab_changed', + `Browser page ${target.browserPageId} changed while evaluating; retry the command` + ) + } + return { + result: + result.value !== undefined + ? typeof result.value === 'object' && result.value !== null + ? JSON.stringify(result.value) + : String(result.value) + : (result.description ?? ''), + origin: wc.getURL() + } + } catch (error) { + if (error instanceof BrowserError) { + throw error + } + if (!this.getWebContents(target.webContentsId)) { + throw this.createPageUnavailableError( + `${ORCA_TAB_SESSION_PREFIX}${target.browserPageId}` + ) + } + throw new BrowserError( + 'browser_error', + `Failed to evaluate in browser page ${target.browserPageId}: ${error instanceof Error ? error.message : String(error)}` + ) + } finally { + releaseDebugger() + } + }, + { ensureSession: false } + ) + } +} diff --git a/src/main/browser/agent-browser-bridge-core-commands.ts b/src/main/browser/agent-browser-bridge-core-commands.ts new file mode 100644 index 00000000000..aac82f77487 --- /dev/null +++ b/src/main/browser/agent-browser-bridge-core-commands.ts @@ -0,0 +1,186 @@ +import type { + BrowserSnapshotResult, + BrowserClickResult, + BrowserGotoResult, + BrowserFillResult +} from '../../shared/runtime-types' +import { assertClipboardTextWriteWithinLimitWithYield } from '../../shared/clipboard-text' +import { normalizeBrowserNavigationUrl } from '../../shared/browser-url' +import { iterateBrowserTextInsertionChunks } from './browser-text-insertion' +import { BrowserError } from './cdp-bridge' +import { ORCA_TAB_SESSION_PREFIX } from './agent-browser-orphan-sweep' +import { focusedValueSetExpression } from './agent-browser-bridge-input' +import { + AGENT_BROWSER_TEXT_ARGUMENT_MAX_BYTES, + EMBEDDED_NAVIGATION_TIMEOUT_MS +} from './agent-browser-bridge-types' +import { + isAbortedNavigationError, + waitForAbortedNavigationReplacement +} from './agent-browser-bridge-process' +import { AgentBrowserBridgeQueue } from './agent-browser-bridge-queue' + +export abstract class AgentBrowserBridgeCoreCommands extends AgentBrowserBridgeQueue { + async snapshot(worktreeId?: string, browserPageId?: string): Promise { + // Why: snapshot creates fresh refs so it must bypass the stale-ref guard + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName, target) => { + const result = (await this.execAgentBrowser(sessionName, [ + 'snapshot' + ])) as BrowserSnapshotResult + return { + ...result, + browserPageId: target.browserPageId + } + }) + } + + async click( + element: string, + worktreeId?: string, + browserPageId?: string + ): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + return (await this.execAgentBrowser(sessionName, ['click', element])) as BrowserClickResult + }) + } + + async dblclick( + element: string, + worktreeId?: string, + browserPageId?: string + ): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + return (await this.execAgentBrowser(sessionName, ['dblclick', element])) as BrowserClickResult + }) + } + + async goto(url: string, worktreeId?: string, browserPageId?: string): Promise { + return this.enqueueTargetedCommand( + worktreeId, + browserPageId, + async (_sessionName, target) => { + const wc = this.requireTargetWebContents(target) + const navigationUrl = normalizeBrowserNavigationUrl(url) + if (!navigationUrl) { + throw new BrowserError('invalid_argument', `Unsupported browser URL: ${url}`) + } + const navigationState: { preventUnloadEvent: Electron.Event | null } = { + preventUnloadEvent: null + } + const onWillPreventUnload = (event: Electron.Event): void => { + navigationState.preventUnloadEvent = event + } + wc.on('will-prevent-unload', onWillPreventUnload) + let navigationAborted = false + const navigationDeadline = Date.now() + EMBEDDED_NAVIGATION_TIMEOUT_MS + let navigationTimeout: ReturnType | null = null + try { + await Promise.race([ + wc.loadURL(navigationUrl), + new Promise((_resolve, reject) => { + navigationTimeout = setTimeout( + () => + reject( + new Error( + `Browser navigation timed out after ${EMBEDDED_NAVIGATION_TIMEOUT_MS}ms` + ) + ), + EMBEDDED_NAVIGATION_TIMEOUT_MS + ) + navigationTimeout.unref?.() + }) + ]) + } catch (error) { + if (navigationTimeout) { + clearTimeout(navigationTimeout) + navigationTimeout = null + } + if (!this.getWebContents(target.webContentsId)) { + throw this.createPageUnavailableError( + `${ORCA_TAB_SESSION_PREFIX}${target.browserPageId}` + ) + } + // Why: ERR_ABORTED also covers a page vetoing unload; that navigation did not succeed. + if ( + !isAbortedNavigationError(error) || + (navigationState.preventUnloadEvent !== null && + !navigationState.preventUnloadEvent.defaultPrevented) + ) { + throw new BrowserError( + 'browser_error', + `Failed to navigate browser page ${target.browserPageId}: ${error instanceof Error ? error.message : String(error)}` + ) + } + navigationAborted = true + // Why: a superseding navigation rejects the first load before its replacement has landed. + await waitForAbortedNavigationReplacement( + wc, + target.browserPageId, + Math.max(0, navigationDeadline - Date.now()) + ) + } finally { + wc.removeListener('will-prevent-unload', onWillPreventUnload) + if (navigationTimeout) { + clearTimeout(navigationTimeout) + } + } + + // Why: cross-process navigation can replace the guest while retaining the same authoritative page id. + const navigatedTarget = this.resolveCommandTarget(worktreeId, target.browserPageId) + const navigatedWebContents = this.requireTargetWebContents(navigatedTarget) + const loadError = navigationAborted + ? this.browserManager.getBrowserPageLoadError(target.browserPageId) + : null + if (loadError) { + throw new BrowserError( + 'browser_error', + `Failed to navigate browser page ${target.browserPageId}: ${loadError.description} (${loadError.code})` + ) + } + return { url: navigatedWebContents.getURL(), title: navigatedWebContents.getTitle() } + }, + { ensureSession: false } + ) + } + + async fill( + element: string, + value: string, + worktreeId?: string, + browserPageId?: string + ): Promise { + await assertClipboardTextWriteWithinLimitWithYield(value) + // Why: agent-browser's CDP text insertion loses focus in Electron guests; edit through the browser's input pipeline instead. + return this.enqueueTargetedCommand( + worktreeId, + browserPageId, + async (sessionName) => { + if (!(await this.isExplicitContentEditableTarget(sessionName, element))) { + await this.execAgentBrowser(sessionName, ['focus', element]) + await this.execAgentBrowser(sessionName, [ + 'eval', + focusedValueSetExpression(JSON.stringify('')) + ]) + for (const chunk of iterateBrowserTextInsertionChunks( + value, + AGENT_BROWSER_TEXT_ARGUMENT_MAX_BYTES + )) { + await this.execAgentBrowser(sessionName, [ + 'eval', + focusedValueSetExpression(JSON.stringify(chunk), { append: true }) + ]) + } + await this.execAgentBrowser(sessionName, [ + 'eval', + focusedValueSetExpression(JSON.stringify(''), { append: true, dispatchEvents: true }) + ]) + return { filled: element } as BrowserFillResult + } + + await this.fillExplicitContentEditable(sessionName, element, value) + return { filled: element } as BrowserFillResult + }, + { requireScopedTarget: true } + ) + } +} diff --git a/src/main/browser/agent-browser-bridge-execution.ts b/src/main/browser/agent-browser-bridge-execution.ts new file mode 100644 index 00000000000..65f64b6fb08 --- /dev/null +++ b/src/main/browser/agent-browser-bridge-execution.ts @@ -0,0 +1,263 @@ +import { execFile } from 'node:child_process' +import type { WebContents } from 'electron' +import { BrowserError } from './cdp-bridge' +import { + focusedRichTextEditExpression, + isExplicitContentEditableResult +} from './agent-browser-bridge-input' +import { + isTabClosedTransportError, + pageUnavailableMessageForSession +} from './agent-browser-bridge-process' +import { translateResult } from './agent-browser-bridge-result' +import { AgentBrowserBridgeTabs } from './agent-browser-bridge-tabs' +import { ORCA_TAB_SESSION_PREFIX } from './agent-browser-orphan-sweep' +import { + STALE_SESSION_CLOSE_TIMEOUT_MS, + type AgentBrowserExecOptions, + type SessionState, + type ResolvedBrowserCommandTarget +} from './agent-browser-bridge-types' + +export abstract class AgentBrowserBridgeExecution extends AgentBrowserBridgeTabs { + protected abstract destroySession( + sessionName: string, + options?: { closeTimeoutMs?: number } + ): Promise + + protected abstract runAgentBrowserRaw( + sessionName: string, + args: string[], + execOptions?: AgentBrowserExecOptions + ): Promise + + protected requireTargetWebContents(target: ResolvedBrowserCommandTarget): WebContents { + const wc = this.getWebContents(target.webContentsId) + if (!wc || wc.isDestroyed()) { + throw this.createPageUnavailableError(`${ORCA_TAB_SESSION_PREFIX}${target.browserPageId}`) + } + return wc + } + + /** + * Notice that the daemon retired itself between two commands. + * + * A replacement daemon still serves the page (every call reasserts `--cdp`) + * but carries none of the session's network routes, so without this the + * interception the caller configured is silently gone (#16367). + */ + protected reinitializeIfDaemonIdledOut(sessionName: string, session: SessionState): void { + if ( + this.agentBrowserIdleTimeoutMs === null || + Date.now() - session.lastCommandAt < this.agentBrowserIdleTimeoutMs + ) { + return + } + session.initialized = false + if (session.activeInterceptPatterns.length > 0) { + this.pendingInterceptRestore.set(sessionName, [...session.activeInterceptPatterns]) + } + } + + protected assertCommandAdmission(): void { + if (this.shutdownStarted) { + throw new BrowserError('browser_owner_unavailable', 'Browser runtime is shutting down') + } + } + + protected async execAgentBrowser( + sessionName: string, + commandArgs: string[], + execOptions?: AgentBrowserExecOptions + ): Promise { + const session = this.sessions.get(sessionName) + if (!session) { + // Why: a queued command can run after a concurrent close deleted the session — surface a tab-lifecycle error, not an opaque failure. + throw this.createPageUnavailableError(sessionName) + } + + // Why: the webContents can be destroyed during queue delay — check here to avoid cryptic Electron debugger errors. + if (!this.getWebContents(session.webContentsId)) { + await this.destroySession(sessionName) + throw this.createPageUnavailableError(sessionName) + } + + this.reinitializeIfDaemonIdledOut(sessionName, session) + session.lastCommandAt = Date.now() + + const args = ['--session', sessionName] + const managesInterceptRoutes = + commandArgs[0] === 'network' && (commandArgs[1] === 'route' || commandArgs[1] === 'unroute') + + const needsInit = !session.initialized + // Why: a restarted named daemon auto-launches Chrome unless every invocation reasserts Orca's CDP owner. + args.push('--cdp', String(session.proxy.getPort())) + + // Why: exec passthrough can produce a large argv; spreading into push risks V8 argument limits. + for (const commandArg of commandArgs) { + args.push(commandArg) + } + args.push('--json') + + const stdout = await this.runAgentBrowserRaw(sessionName, args, execOptions) + const translated = translateResult(stdout) + + if (!translated.ok) { + throw this.createCommandError( + sessionName, + translated.error.message, + translated.error.code, + session.webContentsId + ) + } + + // Why: mark initialized only after success, so a failed first --cdp connection retries with --cdp. + if (needsInit) { + session.initialized = true + + // Why: a process swap loses intercept patterns — restore them now unless the caller's first command reconfigured routing. + const pendingPatterns = managesInterceptRoutes + ? undefined + : this.pendingInterceptRestore.get(sessionName) + if (pendingPatterns && pendingPatterns.length > 0) { + this.pendingInterceptRestore.delete(sessionName) + try { + const urlPattern = pendingPatterns[0] ?? '**/*' + await this.runAgentBrowserRaw(sessionName, [ + '--session', + sessionName, + '--cdp', + String(session.proxy.getPort()), + 'network', + 'route', + urlPattern, + '--json' + ]) + session.activeInterceptPatterns = pendingPatterns + } catch { + // Why: intercept restore is best-effort — don't fail the user's command if the new page can't support it. + } + } + } + + return translated.result + } + + protected async isExplicitContentEditableTarget( + sessionName: string, + element: string + ): Promise { + const result = await this.execAgentBrowser(sessionName, [ + 'get', + 'attr', + element, + 'contenteditable' + ]) + return isExplicitContentEditableResult(result) + } + + protected async fillExplicitContentEditable( + sessionName: string, + element: string, + value: string + ): Promise { + await this.execAgentBrowser(sessionName, ['focus', element]) + // Why: stdin avoids argv limits and keeps replacement atomic; chunked edits can move focus and split a fill across controls. + await this.execAgentBrowser(sessionName, ['eval', '--stdin'], { + stdinText: focusedRichTextEditExpression(JSON.stringify(value), { selectAll: true }) + }) + } + + protected createPageUnavailableError(sessionName: string): BrowserError { + return new BrowserError('browser_tab_not_found', pageUnavailableMessageForSession(sessionName)) + } + + protected closeStaleAgentBrowserSession(sessionName: string): Promise { + return new Promise((resolve, reject) => { + let child: ReturnType | null = null + let settled = false + + const finish = (error?: Error): void => { + if (settled) { + return + } + settled = true + clearTimeout(timeout) + if (error) { + reject(error) + } else { + resolve() + } + } + + // Why: proceeding after an unverified close can reuse a daemon that owns an unrelated browser. + const timeout = setTimeout(() => { + child?.kill() + finish( + new BrowserError( + 'browser_owner_unavailable', + `Could not reset stale helper session ${sessionName}; retry after agent-browser exits` + ) + ) + }, STALE_SESSION_CLOSE_TIMEOUT_MS) + + try { + child = execFile( + this.agentBrowserBin, + ['--session', sessionName, 'close'], + // Why windowsHide: agent-browser is console-subsystem and Orca's main + // process owns no console, so each spawn gets a fresh visible conhost + // that takes foreground -- keystrokes typed into a terminal at that + // moment land in the black box (#14543). + { + env: this.agentBrowserEnv, + timeout: STALE_SESSION_CLOSE_TIMEOUT_MS, + windowsHide: true + }, + (error) => + finish( + error + ? new BrowserError( + 'browser_owner_unavailable', + `Could not reset stale helper session ${sessionName}: ${error.message}` + ) + : undefined + ) + ) + } catch (error) { + finish( + new BrowserError( + 'browser_owner_unavailable', + `Could not reset stale helper session ${sessionName}: ${error instanceof Error ? error.message : String(error)}` + ) + ) + } + }) + } + + protected createCommandError( + sessionName: string, + message: string, + fallbackCode: string, + webContentsId?: number + ): BrowserError { + // Why: CDP "connection refused" can also mean a real proxy failure — only map to closed-page when the target is confirmed gone. + if ( + fallbackCode === 'browser_error' && + isTabClosedTransportError(message) && + this.isSessionTargetClosed(sessionName, webContentsId) + ) { + return this.createPageUnavailableError(sessionName) + } + return new BrowserError(fallbackCode, message) + } + + protected isSessionTargetClosed(sessionName: string, webContentsId?: number): boolean { + const session = this.sessions.get(sessionName) + if (!session) { + return true + } + const targetWebContentsId = webContentsId ?? session.webContentsId + return !this.getWebContents(targetWebContentsId) + } +} diff --git a/src/main/browser/agent-browser-bridge-input-commands.ts b/src/main/browser/agent-browser-bridge-input-commands.ts new file mode 100644 index 00000000000..7eef4a1ddf3 --- /dev/null +++ b/src/main/browser/agent-browser-bridge-input-commands.ts @@ -0,0 +1,124 @@ +import type { + BrowserTypeResult, + BrowserSelectResult, + BrowserScrollResult +} from '../../shared/runtime-types' +import { assertClipboardTextWriteWithinLimitWithYield } from '../../shared/clipboard-text' +import { iterateBrowserTextInsertionChunks } from './browser-text-insertion' +import { AGENT_BROWSER_TEXT_ARGUMENT_MAX_BYTES } from './agent-browser-bridge-types' +import { AgentBrowserBridgeCoreCommands } from './agent-browser-bridge-core-commands' + +export abstract class AgentBrowserBridgeInputCommands extends AgentBrowserBridgeCoreCommands { + async type( + input: string, + worktreeId?: string, + browserPageId?: string + ): Promise { + await assertClipboardTextWriteWithinLimitWithYield(input) + return this.enqueueTargetedCommand( + worktreeId, + browserPageId, + async (sessionName) => { + for (const chunk of iterateBrowserTextInsertionChunks( + input, + AGENT_BROWSER_TEXT_ARGUMENT_MAX_BYTES + )) { + await this.execAgentBrowser(sessionName, ['keyboard', 'type', chunk]) + } + return { typed: true } as BrowserTypeResult + }, + { requireScopedTarget: true } + ) + } + + async select( + element: string, + value: string, + worktreeId?: string, + browserPageId?: string + ): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + return (await this.execAgentBrowser(sessionName, [ + 'select', + element, + value + ])) as BrowserSelectResult + }) + } + + async scroll( + direction: string, + amount?: number, + worktreeId?: string, + browserPageId?: string + ): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + const args = ['scroll', direction] + if (amount != null) { + args.push(String(amount)) + } + return (await this.execAgentBrowser(sessionName, args)) as BrowserScrollResult + }) + } + + async scrollIntoView( + element: string, + worktreeId?: string, + browserPageId?: string + ): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + return await this.execAgentBrowser(sessionName, ['scrollintoview', element]) + }) + } + + async get( + what: string, + selector?: string, + worktreeId?: string, + browserPageId?: string + ): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + const args = ['get', what] + if (selector) { + args.push(selector) + } + return await this.execAgentBrowser(sessionName, args) + }) + } + + async is( + what: string, + selector: string, + worktreeId?: string, + browserPageId?: string + ): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + return await this.execAgentBrowser(sessionName, ['is', what, selector]) + }) + } + + // ── Keyboard commands ── + + async keyboardInsertText( + text: string, + worktreeId?: string, + browserPageId?: string + ): Promise { + await assertClipboardTextWriteWithinLimitWithYield(text) + return this.enqueueTargetedCommand( + worktreeId, + browserPageId, + async (sessionName) => { + let result: unknown = { inserted: true } + for (const chunk of iterateBrowserTextInsertionChunks( + text, + AGENT_BROWSER_TEXT_ARGUMENT_MAX_BYTES + )) { + result = await this.execAgentBrowser(sessionName, ['keyboard', 'inserttext', chunk]) + } + return result + }, + { requireScopedTarget: true } + ) + } +} diff --git a/src/main/browser/agent-browser-bridge-input.ts b/src/main/browser/agent-browser-bridge-input.ts new file mode 100644 index 00000000000..cc4869b90bf --- /dev/null +++ b/src/main/browser/agent-browser-bridge-input.ts @@ -0,0 +1,67 @@ +export function focusedValueSetExpression( + valueExpression: string, + options?: { append?: boolean; dispatchEvents?: boolean } +): string { + const nextValue = options?.append + ? ["String(target.value ?? '') + ", valueExpression].join('') + : valueExpression + const dispatchEvents = options?.dispatchEvents + ? " target.dispatchEvent(new Event('input', { bubbles: true })); target.dispatchEvent(new Event('change', { bubbles: true }));" + : '' + return [ + '(() => { const el = document.activeElement; if (el) {', + // Why: ARIA spinbutton wrappers can hold focus while a contained or controlled input owns the value. + " const editableSelector = \"input:not([type='hidden']):not([type='button']):not([type='checkbox']):not([type='radio']):not([type='file']):not([type='image']):not([type='reset']):not([type='submit']), textarea\";", + " const isEditable = (node) => !!node && (node.matches?.(editableSelector) ?? (node.tagName === 'TEXTAREA' || (node.tagName === 'INPUT' && !/^(hidden|button|checkbox|radio|file|image|reset|submit)$/i.test(node.getAttribute?.('type') ?? ''))));", + ' const findEditable = (root) => root?.querySelector?.(editableSelector) ?? null;', + ' let target = el;', + " if (!isEditable(target) && target.getAttribute?.('role') === 'spinbutton') {", + " const controls = target.getAttribute('aria-controls');", + ' if (controls) { for (const id of controls.split(/\\s+/)) { if (!id) continue; const controlled = document.getElementById(id); if (isEditable(controlled)) { target = controlled; break; } const descendant = findEditable(controlled); if (descendant) { target = descendant; break; } } }', + ' if (target === el) { const descendant = findEditable(target); if (descendant) target = descendant; }', + ' }', + " const nativeSetter = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(target), 'value')?.set;", + ' const nextValue = ', + nextValue, + '; if (nativeSetter) { nativeSetter.call(target, nextValue); } else { target.value = nextValue; }', + dispatchEvents, + ' } })()' + ].join('') +} + +// Why: rich editors reconcile only real browser edit transactions; a direct-DOM fallback can leave their model stale. +export function focusedRichTextEditExpression( + valueExpression: string, + options?: { selectAll?: boolean } +): string { + const selectAll = options?.selectAll ? 'true' : 'false' + return [ + '(() => {', + ' const target = document.activeElement;', + ' const value = ', + valueExpression, + ';', + ` const selectAll = ${selectAll};`, + " const isEditable = target?.isContentEditable === true || /^(|true|plaintext-only)$/i.test(target?.getAttribute?.('contenteditable') ?? 'false');", + " if (!target || target === document.body || !isEditable) { throw new Error('Focused rich-text target is unavailable'); }", + ' if (selectAll) {', + " if (typeof window.getSelection !== 'function') { throw new Error('Rich-text selection is unavailable'); }", + ' const selection = window.getSelection();', + " if (!selection) { throw new Error('Rich-text selection is unavailable'); }", + ' selection.selectAllChildren(target);', + ' }', + " const editCommand = selectAll && value.length === 0 ? 'delete' : 'insertText';", + ' let edited = false;', + ' try {', + ' edited = document.execCommand(editCommand, false, value) === true;', + ' } catch { edited = false; }', + " if (!edited) { throw new Error('Browser rich-text editing command failed'); }", + ' })()' + ].join('') +} + +export function isExplicitContentEditableResult(result: unknown): boolean { + const value = + result && typeof result === 'object' ? (result as { value?: unknown }).value : undefined + return typeof value === 'string' && /^(|true|plaintext-only)$/i.test(value) +} diff --git a/src/main/browser/agent-browser-bridge-interaction-commands.ts b/src/main/browser/agent-browser-bridge-interaction-commands.ts new file mode 100644 index 00000000000..51854878c0b --- /dev/null +++ b/src/main/browser/agent-browser-bridge-interaction-commands.ts @@ -0,0 +1,192 @@ +import type { + BrowserHoverResult, + BrowserDragResult, + BrowserUploadResult, + BrowserWaitResult, + BrowserCheckResult, + BrowserFocusResult, + BrowserClearResult, + BrowserSelectAllResult, + BrowserKeypressResult, + BrowserPdfResult +} from '../../shared/runtime-types' +import { BrowserError } from './cdp-bridge' +import { WAIT_PROCESS_TIMEOUT_GRACE_MS } from './agent-browser-bridge-types' +import { AgentBrowserBridgeCaptureCommands } from './agent-browser-bridge-capture-commands' + +export abstract class AgentBrowserBridgeInteractionCommands extends AgentBrowserBridgeCaptureCommands { + async hover( + element: string, + worktreeId?: string, + browserPageId?: string + ): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + return (await this.execAgentBrowser(sessionName, ['hover', element])) as BrowserHoverResult + }) + } + + async drag( + from: string, + to: string, + worktreeId?: string, + browserPageId?: string + ): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + return (await this.execAgentBrowser(sessionName, ['drag', from, to])) as BrowserDragResult + }) + } + + async upload( + element: string, + filePaths: string[], + worktreeId?: string, + browserPageId?: string + ): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + return (await this.execAgentBrowser(sessionName, [ + 'upload', + element, + ...filePaths + ])) as BrowserUploadResult + }) + } + + async wait( + options?: { + selector?: string + timeout?: number + text?: string + url?: string + load?: string + fn?: string + state?: string + }, + worktreeId?: string, + browserPageId?: string + ): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + const args = ['wait'] + const hasCondition = + !!options?.selector || !!options?.text || !!options?.url || !!options?.load || !!options?.fn + if (options?.selector) { + args.push(options.selector) + } else if (options?.timeout != null && !hasCondition) { + args.push(String(options.timeout)) + } + if (options?.text) { + args.push('--text', options.text) + } + if (options?.url) { + args.push('--url', options.url) + } + if (options?.load) { + args.push('--load', options.load) + } + if (options?.fn) { + args.push('--fn', options.fn) + } + const normalizedState = options?.state === 'visible' ? undefined : options?.state + if (normalizedState) { + args.push('--state', normalizedState) + } + // Why: agent-browser's selector wait lacks a per-command timeout — enforce it here so a missing selector fails as browser_timeout, not a hang. + return (await this.execAgentBrowser(sessionName, args, { + timeoutMs: + options?.timeout != null && hasCondition + ? options.timeout + WAIT_PROCESS_TIMEOUT_GRACE_MS + : undefined, + timeoutError: + options?.timeout != null && hasCondition + ? new BrowserError( + 'browser_timeout', + `Timed out waiting for browser condition after ${options.timeout}ms.` + ) + : undefined + })) as BrowserWaitResult + }) + } + + async check( + element: string, + checked: boolean, + worktreeId?: string, + browserPageId?: string + ): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + const args = checked ? ['check', element] : ['uncheck', element] + return (await this.execAgentBrowser(sessionName, args)) as BrowserCheckResult + }) + } + + async focus( + element: string, + worktreeId?: string, + browserPageId?: string + ): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + return (await this.execAgentBrowser(sessionName, ['focus', element])) as BrowserFocusResult + }) + } + + async clear( + element: string, + worktreeId?: string, + browserPageId?: string + ): Promise { + return this.enqueueTargetedCommand( + worktreeId, + browserPageId, + async (sessionName) => { + if (!(await this.isExplicitContentEditableTarget(sessionName, element))) { + // Why: agent-browser resolves the ref directly, preserving iframe/shadow-root/unfocusable semantics for ordinary fields. + await this.execAgentBrowser(sessionName, ['fill', element, '']) + return { cleared: element } + } + + await this.fillExplicitContentEditable(sessionName, element, '') + return { cleared: element } + }, + { requireScopedTarget: true } + ) + } + + async selectAll( + element: string, + worktreeId?: string, + browserPageId?: string + ): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + // Why: agent-browser has no select-all command — implement as focus + Ctrl+A + await this.execAgentBrowser(sessionName, ['focus', element]) + return (await this.execAgentBrowser(sessionName, [ + 'press', + 'Control+a' + ])) as BrowserSelectAllResult + }) + } + + async keypress( + key: string, + worktreeId?: string, + browserPageId?: string + ): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + return (await this.execAgentBrowser(sessionName, ['press', key])) as BrowserKeypressResult + }) + } + + async pdf(worktreeId?: string, browserPageId?: string): Promise { + // Why: agent-browser's CDP printToPDF hangs in Electron webviews — use the native webContents.printToPDF(). + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (_sessionName, target) => { + const wc = this.getWebContents(target.webContentsId) + if (!wc) { + throw new BrowserError('browser_no_tab', 'Tab is no longer available') + } + const buffer = await wc.printToPDF({ + printBackground: true, + preferCSSPageSize: true + }) + return { data: buffer.toString('base64') } + }) + } +} diff --git a/src/main/browser/agent-browser-bridge-lifecycle.ts b/src/main/browser/agent-browser-bridge-lifecycle.ts new file mode 100644 index 00000000000..c3ec6b170e9 --- /dev/null +++ b/src/main/browser/agent-browser-bridge-lifecycle.ts @@ -0,0 +1,266 @@ +import { CdpWsProxy } from './cdp-ws-proxy' +import { BrowserError } from './cdp-bridge' +import { ORCA_TAB_SESSION_PREFIX } from './agent-browser-orphan-sweep' +import { AgentBrowserBridgeRawProcess } from './agent-browser-bridge-raw-process' +import type { AgentBrowserCleanupOptions } from './agent-browser-bridge-types' +import { AGENT_BROWSER_CLEANUP_TIMEOUT_MS } from './agent-browser-bridge-types' + +export abstract class AgentBrowserBridgeLifecycle extends AgentBrowserBridgeRawProcess { + async onTabClosed(webContentsId: number): Promise { + const browserPageId = this.resolveTabIdSafe(webContentsId) + const owningWorktreeId = browserPageId + ? this.browserManager.getWorktreeIdForTab(browserPageId) + : undefined + let nextWorktreeActiveWebContentsId: number | null = null + if ( + owningWorktreeId && + this.activeWebContentsPerWorktree.get(owningWorktreeId) === webContentsId + ) { + nextWorktreeActiveWebContentsId = this.selectFallbackActiveWebContents( + owningWorktreeId, + webContentsId + ) + } + if (this.activeWebContentsId === webContentsId) { + this.activeWebContentsId = nextWorktreeActiveWebContentsId + } + if (browserPageId) { + await this.onPageClosed(browserPageId) + } + this.options.onTabsChanged?.(owningWorktreeId) + } + + /** + * Retire a page's daemon by page id. + * + * The headless offscreen backend owns pages by id and unregisters the guest + * itself, so `onTabClosed`'s webContentsId lookup can never resolve one — it + * has to say which page closed (#16367). + */ + async onPageClosed(browserPageId: string): Promise { + const sessionName = `${ORCA_TAB_SESSION_PREFIX}${browserPageId}` + await this.destroySession(sessionName) + this.pendingInterceptRestore.delete(sessionName) + } + + async onProcessSwap( + browserPageId: string, + newWebContentsId: number, + previousWebContentsId?: number + ): Promise { + // Why: an Electron process swap keeps browserPageId but gives a new webContentsId — destroy the session so the next command recreates it. + const sessionName = `${ORCA_TAB_SESSION_PREFIX}${browserPageId}` + const session = this.sessions.get(sessionName) + const oldWebContentsId = previousWebContentsId ?? session?.webContentsId + const owningWorktreeId = this.browserManager.getWorktreeIdForTab(browserPageId) + // Why: save intercept patterns before destroy so the new session can restore them after init. + if (session && session.activeInterceptPatterns.length > 0) { + this.pendingInterceptRestore.set(sessionName, [...session.activeInterceptPatterns]) + } + await this.destroySession(sessionName) + if (oldWebContentsId != null && this.activeWebContentsId === oldWebContentsId) { + this.activeWebContentsId = newWebContentsId + } + if ( + owningWorktreeId && + oldWebContentsId != null && + this.activeWebContentsPerWorktree.get(owningWorktreeId) === oldWebContentsId + ) { + this.activeWebContentsPerWorktree.set(owningWorktreeId, newWebContentsId) + } + this.options.onTabsChanged?.(owningWorktreeId ?? undefined) + } + protected async ensureSession( + sessionName: string, + browserPageId: string, + webContentsId: number + ): Promise { + const pendingDestruction = this.pendingSessionDestruction.get(sessionName) + if (pendingDestruction) { + await pendingDestruction + } + this.assertCommandAdmission() + + if (this.sessions.has(sessionName)) { + return + } + + // Why: without this lock, two concurrent calls both create proxies and the second leaks the first's server/debugger. + const pending = this.pendingSessionCreation.get(sessionName) + if (pending) { + await pending + this.assertCommandAdmission() + return + } + + const createSession = async (): Promise => { + const wc = this.getWebContents(webContentsId) + if (!wc) { + // Why: the webview can be destroyed between target resolution and session creation — keep the same closed-tab error shape. + throw new BrowserError( + 'browser_tab_not_found', + `Browser page ${browserPageId} is no longer available` + ) + } + + // Why: the daemon persists sessions (incl. CDP port) across restarts; close the stale one first or it ignores --cdp and hits the dead port. + await this.closeStaleAgentBrowserSession(sessionName) + + const proxy = new CdpWsProxy(wc) + const cdpEndpoint = await proxy.start() + + this.sessions.set(sessionName, { + proxy, + cdpEndpoint, + initialized: false, + consecutiveTimeouts: 0, + activeInterceptPatterns: [], + activeCapture: false, + lastCommandAt: Date.now(), + webContentsId, + activeProcess: null + }) + } + + const promise = createSession() + this.pendingSessionCreation.set(sessionName, promise) + try { + await promise + } finally { + this.pendingSessionCreation.delete(sessionName) + } + } + + protected async restartSessionForTarget( + sessionName: string, + browserPageId: string, + webContentsId: number, + options: { recreate: boolean } = { recreate: true } + ): Promise { + const pendingCreation = this.pendingSessionCreation.get(sessionName) + if (pendingCreation) { + await pendingCreation.catch(() => {}) + } + + const session = this.sessions.get(sessionName) + if (session) { + if (session.activeInterceptPatterns.length > 0) { + this.pendingInterceptRestore.set(sessionName, [...session.activeInterceptPatterns]) + } + this.sessions.delete(sessionName) + this.pendingSessionCreation.delete(sessionName) + if (session.activeProcess) { + this.cancelledProcesses.add(session.activeProcess) + try { + session.activeProcess.kill() + } catch { + // Process may already be exiting. + } + session.activeProcess = null + } + + const destroy = (async (): Promise => { + try { + await this.runAgentBrowserRaw(sessionName, ['--session', sessionName, 'close'], { + timeoutMs: AGENT_BROWSER_CLEANUP_TIMEOUT_MS + }) + } catch { + // Session may already be dead. + } + await session.proxy.stop() + })() + this.pendingSessionDestruction.set(sessionName, destroy) + try { + await destroy + } finally { + this.pendingSessionDestruction.delete(sessionName) + } + } + + if (options.recreate) { + await this.ensureSession(sessionName, browserPageId, webContentsId) + } + } + + protected async destroySession( + sessionName: string, + options: AgentBrowserCleanupOptions = { closeTimeoutMs: AGENT_BROWSER_CLEANUP_TIMEOUT_MS } + ): Promise { + const pendingDestruction = this.pendingSessionDestruction.get(sessionName) + if (pendingDestruction) { + await pendingDestruction + return + } + + const pendingCreation = this.pendingSessionCreation.get(sessionName) + if (pendingCreation) { + // Why: tab close can race session creation before sessions.set(); await it so no late proxy survives the close. + try { + await pendingCreation + } catch { + // Creation failures are handled by the original caller; teardown still rejects queued work below. + } + } + + const session = this.sessions.get(sessionName) + if (!session) { + this.rejectQueuedCommandsForClosedSession(sessionName) + return + } + + this.sessions.delete(sessionName) + this.pendingSessionCreation.delete(sessionName) + + // Why: queued commands would hang forever if we just delete the queue — drain and reject them. + this.rejectQueuedCommandsForClosedSession(sessionName) + + if (session.activeProcess) { + // Why: rejecting the queue isn't enough for an in-flight command — kill the process so callers don't wait out the exec timeout. + this.cancelledProcesses.add(session.activeProcess) + try { + session.activeProcess.kill() + } catch { + // Process may already be exiting. + } + session.activeProcess = null + } + + const destroy = (async (): Promise => { + try { + // Why: each tab has its own named session — close without --session leaves this tab's daemon running. + // Why bounded: this runs inside the 20s will-quit barrier, so it cannot inherit the 90s exec timeout. + await this.runAgentBrowserRaw( + sessionName, + ['--session', sessionName, 'close'], + options.closeTimeoutMs === undefined ? undefined : { timeoutMs: options.closeTimeoutMs } + ) + } catch { + // Session may already be dead + } + + await session.proxy.stop() + })() + this.pendingSessionDestruction.set(sessionName, destroy) + try { + await destroy + } finally { + this.pendingSessionDestruction.delete(sessionName) + } + } + + protected rejectQueuedCommandsForClosedSession(sessionName: string): void { + const queue = this.commandQueues.get(sessionName) + this.commandQueues.delete(sessionName) + this.processingQueues.delete(sessionName) + if (queue) { + const err = new BrowserError( + 'browser_tab_closed', + 'Tab was closed while commands were queued' + ) + for (const cmd of queue) { + cmd.reject(err) + } + queue.length = 0 + } + } +} diff --git a/src/main/browser/agent-browser-bridge-mouse-commands.ts b/src/main/browser/agent-browser-bridge-mouse-commands.ts new file mode 100644 index 00000000000..36697978748 --- /dev/null +++ b/src/main/browser/agent-browser-bridge-mouse-commands.ts @@ -0,0 +1,206 @@ +import type { BrowserMouseModifier } from './agent-browser-bridge-types' +import { BrowserError } from './cdp-bridge' +import { + normalizeCdpMouseButton, + cdpMouseButtonMask, + cdpMouseModifierMask, + resolveMobileTouchClickPoint +} from './agent-browser-bridge-mouse' +import { acquireElectronDebugger } from './electron-debugger-lease' +import { AgentBrowserBridgeInputCommands } from './agent-browser-bridge-input-commands' + +export abstract class AgentBrowserBridgeMouseCommands extends AgentBrowserBridgeInputCommands { + // ── Mouse commands ── + + async mouseMove( + x: number, + y: number, + worktreeId?: string, + browserPageId?: string + ): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + return await this.execAgentBrowser(sessionName, ['mouse', 'move', String(x), String(y)]) + }) + } + + async mouseDown(button?: string, worktreeId?: string, browserPageId?: string): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + const args = ['mouse', 'down'] + if (button) { + args.push(button) + } + return await this.execAgentBrowser(sessionName, args) + }) + } + + async mouseClick( + x: number, + y: number, + button?: string, + worktreeId?: string, + browserPageId?: string, + radius?: number, + modifiers?: BrowserMouseModifier[] + ): Promise { + return this.enqueueTargetedCommand( + worktreeId, + browserPageId, + async (_sessionName, target) => { + const wc = this.getWebContents(target.webContentsId) + if (!wc || wc.isDestroyed()) { + throw new BrowserError( + 'browser_tab_not_found', + `Browser page ${target.browserPageId} is no longer available` + ) + } + const cdpButton = normalizeCdpMouseButton(button) + const buttons = cdpMouseButtonMask(cdpButton) + const cdpModifiers = cdpMouseModifierMask(modifiers) + const lease = acquireElectronDebugger(wc) + try { + wc.focus() + const point = + cdpButton === 'left' + ? // Why: DOM activation can't carry Cmd/Ctrl/Alt/Shift, so modifier clicks use the adjusted point and let CDP dispatch the event. + await resolveMobileTouchClickPoint(wc.debugger, x, y, radius, cdpModifiers === 0) + : { x, y, adjusted: false, handled: false } + // Why: land the tap as one atomic op — separate move/down/up CLI calls visibly hover and can miss small controls. + // Why: mobile-emulated BrowserViews can ignore CDP mouse clicks, so the runtime may already have activated DOM controls. + if (!point.handled) { + await wc.debugger.sendCommand('Input.dispatchMouseEvent', { + type: 'mousePressed', + x: point.x, + y: point.y, + button: cdpButton, + buttons, + modifiers: cdpModifiers, + clickCount: 1 + }) + await wc.debugger.sendCommand('Input.dispatchMouseEvent', { + type: 'mouseReleased', + x: point.x, + y: point.y, + button: cdpButton, + buttons: 0, + modifiers: cdpModifiers, + clickCount: 1 + }) + } + return { + clicked: { + x: point.x, + y: point.y, + button: cdpButton, + adjusted: point.adjusted, + handled: point.handled + } + } + } finally { + lease.release() + } + }, + { ensureSession: false } + ) + } + + async mouseUp(button?: string, worktreeId?: string, browserPageId?: string): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + const args = ['mouse', 'up'] + if (button) { + args.push(button) + } + return await this.execAgentBrowser(sessionName, args) + }) + } + + async mouseWheel( + dy: number, + dx?: number, + worktreeId?: string, + browserPageId?: string + ): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + const args = ['mouse', 'wheel', String(dy)] + if (dx != null) { + args.push(String(dx)) + } + return await this.execAgentBrowser(sessionName, args) + }) + } + + // ── Find (semantic locators) ── + + async find( + locator: string, + value: string, + action: string, + text?: string, + worktreeId?: string, + browserPageId?: string + ): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + const args = ['find', locator, value, action] + if (text) { + args.push(text) + } + return await this.execAgentBrowser(sessionName, args) + }) + } + + // ── Set commands ── + + async setDevice(name: string, worktreeId?: string, browserPageId?: string): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + return await this.execAgentBrowser(sessionName, ['set', 'device', name]) + }) + } + + async setOffline(state?: string, worktreeId?: string, browserPageId?: string): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + const args = ['set', 'offline'] + if (state) { + args.push(state) + } + return await this.execAgentBrowser(sessionName, args) + }) + } + + async setHeaders( + headersJson: string, + worktreeId?: string, + browserPageId?: string + ): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + return await this.execAgentBrowser(sessionName, ['set', 'headers', headersJson]) + }) + } + + async setCredentials( + user: string, + pass: string, + worktreeId?: string, + browserPageId?: string + ): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + return await this.execAgentBrowser(sessionName, ['set', 'credentials', user, pass]) + }) + } + + async setMedia( + colorScheme?: string, + reducedMotion?: string, + worktreeId?: string, + browserPageId?: string + ): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + const args = ['set', 'media'] + if (colorScheme) { + args.push(colorScheme) + } + if (reducedMotion) { + args.push(reducedMotion) + } + return await this.execAgentBrowser(sessionName, args) + }) + } +} diff --git a/src/main/browser/agent-browser-bridge-mouse.ts b/src/main/browser/agent-browser-bridge-mouse.ts new file mode 100644 index 00000000000..db2ba55d150 --- /dev/null +++ b/src/main/browser/agent-browser-bridge-mouse.ts @@ -0,0 +1,195 @@ +import type { WebContents } from 'electron' +import type { BrowserMouseModifier } from './agent-browser-bridge-types' + +type CdpMouseButton = 'left' | 'middle' | 'right' + +type BrowserClickPoint = { + x: number + y: number + adjusted: boolean + handled: boolean +} + +export function normalizeCdpMouseButton(button?: string): CdpMouseButton { + return button === 'middle' || button === 'right' ? button : 'left' +} + +export function cdpMouseButtonMask(button: CdpMouseButton): number { + if (button === 'right') { + return 2 + } + if (button === 'middle') { + return 4 + } + return 1 +} + +export function cdpMouseModifierMask(modifiers: BrowserMouseModifier[] | undefined): number { + if (!modifiers || modifiers.length === 0) { + return 0 + } + let mask = 0 + for (const modifier of modifiers) { + if (modifier === 'alt') { + mask |= 1 + } else if (modifier === 'ctrl') { + mask |= 2 + } else if (modifier === 'cmd') { + mask |= 4 + } else if (modifier === 'shift') { + mask |= 8 + } + } + return mask +} + +export function readClickPoint(value: unknown, fallback: BrowserClickPoint): BrowserClickPoint { + const point = value && typeof value === 'object' ? (value as Record) : null + const x = point?.x + const y = point?.y + if ( + typeof x !== 'number' || + !Number.isFinite(x) || + typeof y !== 'number' || + !Number.isFinite(y) + ) { + return fallback + } + return { x, y, adjusted: point?.adjusted === true, handled: point?.handled === true } +} + +export function mobileTouchClickExpression( + x: number, + y: number, + radius: number, + allowDomActivation: boolean +): string { + return `(() => { + const inputX = ${JSON.stringify(x)}; + const inputY = ${JSON.stringify(y)}; + const radius = ${JSON.stringify(radius)}; + const allowDomActivation = ${JSON.stringify(allowDomActivation)}; + const selector = [ + 'a[href]', + 'button', + 'input', + 'textarea', + 'select', + 'summary', + 'label', + '[role="button"]', + '[role="link"]', + '[role="menuitem"]', + '[role="tab"]', + '[role="checkbox"]', + '[role="radio"]', + '[role="switch"]', + '[onclick]', + '[tabindex]:not([tabindex="-1"])' + ].join(','); + const clamp = (value, min, max) => Math.min(max, Math.max(min, value)); + const isUsable = (el) => { + const rect = el.getBoundingClientRect(); + const style = window.getComputedStyle(el); + return rect.width > 0 && rect.height > 0 && style.display !== 'none' && + style.visibility !== 'hidden' && style.pointerEvents !== 'none'; + }; + const dispatchClick = (target, clickX, clickY) => { + try { + if (typeof target.focus === 'function') { + target.focus({ preventScroll: true }); + } + } catch { + try { target.focus(); } catch {} + } + if (typeof target.click === 'function') { + target.click(); + return true; + } + const init = { + bubbles: true, + cancelable: true, + composed: true, + view: window, + clientX: clickX, + clientY: clickY, + screenX: clickX, + screenY: clickY, + button: 0, + buttons: 1 + }; + try { + if (typeof PointerEvent === 'function') { + target.dispatchEvent(new PointerEvent('pointerdown', { ...init, pointerType: 'touch', pointerId: 1 })); + target.dispatchEvent(new PointerEvent('pointerup', { ...init, buttons: 0, pointerType: 'touch', pointerId: 1 })); + } + } catch {} + target.dispatchEvent(new MouseEvent('mousedown', init)); + target.dispatchEvent(new MouseEvent('mouseup', { ...init, buttons: 0 })); + target.dispatchEvent(new MouseEvent('click', { ...init, buttons: 0 })); + return true; + }; + const clickableFor = (el) => { + for (let node = el; node && node.nodeType === 1; node = node.parentElement) { + if (node.matches(selector)) return node; + if (window.getComputedStyle(node).cursor === 'pointer') return node; + } + return null; + }; + const offsets = [[0, 0]]; + for (const distance of [radius * 0.45, radius, radius * 1.35]) { + for (const angle of [0, Math.PI / 4, Math.PI / 2, Math.PI * 3 / 4, Math.PI, + Math.PI * 5 / 4, Math.PI * 3 / 2, Math.PI * 7 / 4]) { + offsets.push([Math.cos(angle) * distance, Math.sin(angle) * distance]); + } + } + let best = null; + for (const [dx, dy] of offsets) { + const px = inputX + dx; + const py = inputY + dy; + if (px < 0 || py < 0 || px > window.innerWidth || py > window.innerHeight) continue; + for (const el of document.elementsFromPoint(px, py)) { + const target = clickableFor(el); + if (!target || !isUsable(target)) continue; + const rect = target.getBoundingClientRect(); + const clickX = clamp(inputX, rect.left + 1, rect.right - 1); + const clickY = clamp(inputY, rect.top + 1, rect.bottom - 1); + const score = Math.hypot(clickX - inputX, clickY - inputY) + Math.hypot(dx, dy) * 0.25; + if (!best || score < best.score) best = { score, x: clickX, y: clickY, target }; + break; + } + } + if (best && allowDomActivation && dispatchClick(best.target, best.x, best.y)) { + return { x: best.x, y: best.y, adjusted: true, handled: true }; + } + if (best) { + return { x: best.x, y: best.y, adjusted: true, handled: false }; + } + return { x: inputX, y: inputY, adjusted: false, handled: false }; + })()` +} + +export async function resolveMobileTouchClickPoint( + dbg: WebContents['debugger'], + x: number, + y: number, + radius: number | undefined, + allowDomActivation: boolean +): Promise { + const fallback = { x, y, adjusted: false, handled: false } + if (typeof radius !== 'number' || !Number.isFinite(radius) || radius <= 0) { + return fallback + } + try { + const result = await dbg.sendCommand('Runtime.evaluate', { + expression: mobileTouchClickExpression(x, y, radius, allowDomActivation), + returnByValue: true, + silent: true + }) + const raw = result && typeof result === 'object' ? (result as Record) : null + const evaluated = raw?.result && typeof raw.result === 'object' ? raw.result : null + return readClickPoint((evaluated as Record | null)?.value, fallback) + } catch { + return fallback + } +} diff --git a/src/main/browser/agent-browser-bridge-process.ts b/src/main/browser/agent-browser-bridge-process.ts new file mode 100644 index 00000000000..363721099d9 --- /dev/null +++ b/src/main/browser/agent-browser-bridge-process.ts @@ -0,0 +1,183 @@ +import { app } from 'electron' +import { existsSync, accessSync, chmodSync, constants } from 'node:fs' +import { join } from 'node:path' +import { platform, arch } from 'node:os' +import type { WebContents } from 'electron' +import { BrowserError } from './cdp-bridge' +import { ORCA_TAB_SESSION_PREFIX } from './agent-browser-orphan-sweep' +import { EMBEDDED_NAVIGATION_TIMEOUT_MS } from './agent-browser-bridge-types' + +export function agentBrowserNativeName(): string { + const ext = process.platform === 'win32' ? '.exe' : '' + return `agent-browser-${platform()}-${arch()}${ext}` +} + +export function resolveAgentBrowserBinary(): string { + // Why: use Electron's resourcesPath (not hand-rolled ../resources) so packaged macOS case-sensitive builds resolve the binary. + const bundledResourcesPath = + process.resourcesPath ?? + (process.platform === 'darwin' + ? join(app.getPath('exe'), '..', '..', 'Resources') + : join(app.getPath('exe'), '..', 'resources')) + const bundled = join(bundledResourcesPath, agentBrowserNativeName()) + if (existsSync(bundled)) { + return bundled + } + + // Why: dev mode — resolve from node_modules via app.getAppPath(); __dirname is unreliable after electron-vite bundling. + const nmBin = join( + app.getAppPath(), + 'node_modules', + 'agent-browser', + 'bin', + agentBrowserNativeName() + ) + if (existsSync(nmBin)) { + if (process.platform !== 'win32') { + try { + accessSync(nmBin, constants.X_OK) + } catch { + chmodSync(nmBin, 0o755) + } + } + return nmBin + } + + // Last resort: assume it's on PATH + return 'agent-browser' +} + +// Why: exec commands arrive as one string; split on whitespace but respect quotes so quoted args stay intact. +export function parseShellArgs(input: string): string[] { + const args: string[] = [] + let current = '' + let inDouble = false + let inSingle = false + + for (let i = 0; i < input.length; i++) { + const ch = input[i] + if (ch === '"' && !inSingle) { + inDouble = !inDouble + } else if (ch === "'" && !inDouble) { + inSingle = !inSingle + } else if (ch === ' ' && !inDouble && !inSingle) { + if (current) { + args.push(current) + current = '' + } + } else { + current += ch + } + } + if (current) { + args.push(current) + } + return args +} + +export function stripAgentBrowserTargetArgs(args: string[]): string[] { + const stripped: string[] = [] + for (let index = 0; index < args.length; index++) { + const arg = args[index] + if (arg === '--cdp' || arg === '--session') { + index++ + continue + } + if (arg.startsWith('--cdp=') || arg.startsWith('--session=')) { + continue + } + stripped.push(arg) + } + return stripped +} + +// Why: agent-browser returns generic errors for stale/unknown refs; map to a specific code so agents can detect and re-snapshot. +export function classifyErrorCode(message: string): string { + if (/unknown ref|ref not found|element not found: @e/i.test(message)) { + return 'browser_stale_ref' + } + return 'browser_error' +} + +export function isAbortedNavigationError(error: unknown): boolean { + if (!error || typeof error !== 'object') { + return false + } + const { code, errno } = error as { code?: unknown; errno?: unknown } + return code === 'ERR_ABORTED' || errno === -3 +} + +export function isWebContentsLoading(wc: WebContents): boolean { + try { + return wc.isLoading() + } catch { + // Why: destruction races are resolved against the authoritative page registration after the wait. + return false + } +} + +export function waitForAbortedNavigationReplacement( + wc: WebContents, + browserPageId: string, + timeoutMs: number +): Promise { + if (!isWebContentsLoading(wc)) { + return Promise.resolve() + } + + return new Promise((resolve, reject) => { + let settled = false + let timeout: ReturnType | null = null + const finish = (error?: BrowserError): void => { + if (settled) { + return + } + settled = true + wc.removeListener('did-stop-loading', onDidStopLoading) + wc.removeListener('destroyed', onDestroyed) + if (timeout) { + clearTimeout(timeout) + } + if (error) { + reject(error) + } else { + resolve() + } + } + const onDidStopLoading = (): void => finish() + const onDestroyed = (): void => finish() + + wc.on('did-stop-loading', onDidStopLoading) + wc.on('destroyed', onDestroyed) + timeout = setTimeout( + () => + finish( + new BrowserError( + 'browser_error', + `Failed to navigate browser page ${browserPageId}: Browser navigation timed out after ${EMBEDDED_NAVIGATION_TIMEOUT_MS}ms` + ) + ), + timeoutMs + ) + timeout.unref?.() + + // Why: the replacement can finish between loadURL rejecting and listener attachment. + if (!isWebContentsLoading(wc)) { + finish() + } + }) +} + +export function isTabClosedTransportError(message: string): boolean { + return /session destroyed while command|session destroyed while commands|connection refused|cdp discovery methods failed|websocket connect failed/i.test( + message + ) +} + +export function pageUnavailableMessageForSession(sessionName: string): string { + const prefix = ORCA_TAB_SESSION_PREFIX + const browserPageId = sessionName.startsWith(prefix) ? sessionName.slice(prefix.length) : null + return browserPageId + ? `Browser page ${browserPageId} is no longer available` + : 'Browser tab is no longer available' +} diff --git a/src/main/browser/agent-browser-bridge-queue.ts b/src/main/browser/agent-browser-bridge-queue.ts new file mode 100644 index 00000000000..74cdb50c14a --- /dev/null +++ b/src/main/browser/agent-browser-bridge-queue.ts @@ -0,0 +1,174 @@ +import type { BrowserTabSwitchResult } from '../../shared/runtime-types' +import { BrowserError } from './cdp-bridge' +import { AgentBrowserBridgeShutdown } from './agent-browser-bridge-shutdown' +import { ORCA_TAB_SESSION_PREFIX } from './agent-browser-orphan-sweep' +import type { + EnqueueTargetedCommandOptions, + ResolvedBrowserCommandTarget +} from './agent-browser-bridge-types' + +export abstract class AgentBrowserBridgeQueue extends AgentBrowserBridgeShutdown { + // Why: route tab switch through the command queue so it can't race in-flight commands targeting the old tab. + async tabSwitch( + index: number | undefined, + worktreeId?: string, + browserPageId?: string + ): Promise { + return this.enqueueCommand(worktreeId, async () => { + const tabs = this.getRegisteredTabs(worktreeId) + // Why: queue delay can change the tab list before execution — recompute against live webContents so no vanished index is activated. + const liveEntries = [...tabs.entries()].filter(([, wcId]) => this.getWebContents(wcId)) + let switchedIndex = index ?? -1 + let resolvedPageId = browserPageId + if (resolvedPageId) { + switchedIndex = liveEntries.findIndex(([tabId]) => tabId === resolvedPageId) + } + if (switchedIndex < 0 || switchedIndex >= liveEntries.length) { + const targetLabel = + resolvedPageId != null ? `Browser page ${resolvedPageId}` : `Tab index ${index}` + throw new BrowserError( + 'browser_tab_not_found', + `${targetLabel} out of range (0-${liveEntries.length - 1})` + ) + } + const [tabId, wcId] = liveEntries[switchedIndex] + this.activeWebContentsId = wcId + // Why: resolveActiveTab prefers the per-worktree map, so update it or later commands keep routing to the old tab. + const owningWorktreeId = worktreeId ?? this.browserManager.getWorktreeIdForTab(tabId) + // Why: `tab switch --page` may omit --worktree, so still update the owning worktree's active slot for later scoped commands. + if (owningWorktreeId) { + this.activeWebContentsPerWorktree.set(owningWorktreeId, wcId) + } + this.options.onTabsChanged?.(owningWorktreeId ?? undefined) + return { switched: switchedIndex, browserPageId: tabId } + }) + } + // ── Internal ── + + protected async enqueueCommand( + worktreeId: string | undefined, + execute: (sessionName: string) => Promise + ): Promise { + return this.enqueueTargetedCommand( + worktreeId, + undefined, + async (sessionName) => execute(sessionName), + { ensureVisible: false } + ) + } + + protected async enqueueTargetedCommand( + worktreeId: string | undefined, + browserPageId: string | undefined, + execute: (sessionName: string, target: ResolvedBrowserCommandTarget) => Promise, + options: EnqueueTargetedCommandOptions = {} + ): Promise { + this.assertCommandAdmission() + const target = this.resolveCommandTarget(worktreeId, browserPageId, options.requireScopedTarget) + const sessionName = `${ORCA_TAB_SESSION_PREFIX}${target.browserPageId}` + + if (options.ensureSession !== false) { + await this.ensureSession(sessionName, target.browserPageId, target.webContentsId) + } + this.assertCommandAdmission() + + return new Promise((resolve, reject) => { + let queue = this.commandQueues.get(sessionName) + if (!queue) { + queue = [] + this.commandQueues.set(sessionName, queue) + } + queue.push({ + execute: (() => + this.executeWithVisibleTarget( + sessionName, + worktreeId, + target, + execute, + options + )) as () => Promise, + resolve: resolve as (value: unknown) => void, + reject + }) + this.processQueue(sessionName) + }) + } + + protected async executeWithVisibleTarget( + sessionName: string, + worktreeId: string | undefined, + target: ResolvedBrowserCommandTarget, + execute: (sessionName: string, target: ResolvedBrowserCommandTarget) => Promise, + options: EnqueueTargetedCommandOptions + ): Promise { + if (options.ensureVisible === false) { + return execute(sessionName, target) + } + + // Why: inactive panes are display:none; the automation lease makes only this target paintable without selecting it. + const restore = await this.browserManager.acquireAutomationVisibility(target.webContentsId) + try { + const visibleTarget = await this.refreshTargetAfterAutomationVisibility( + sessionName, + worktreeId, + target, + options + ) + return await execute(sessionName, visibleTarget) + } finally { + restore() + } + } + + protected async refreshTargetAfterAutomationVisibility( + sessionName: string, + worktreeId: string | undefined, + target: ResolvedBrowserCommandTarget, + options: EnqueueTargetedCommandOptions + ): Promise { + const visibleTarget = this.resolveCommandTarget(worktreeId, target.browserPageId) + if (visibleTarget.webContentsId === target.webContentsId) { + return visibleTarget + } + + if (this.activeWebContentsId === target.webContentsId) { + this.activeWebContentsId = visibleTarget.webContentsId + } + if (worktreeId && this.activeWebContentsPerWorktree.get(worktreeId) === target.webContentsId) { + this.activeWebContentsPerWorktree.set(worktreeId, visibleTarget.webContentsId) + } + + // Why: making a parked webview paintable can re-register the page with a new guest webContents; tear down the stale session. + await this.restartSessionForTarget( + sessionName, + visibleTarget.browserPageId, + visibleTarget.webContentsId, + { recreate: options.ensureSession !== false } + ) + + return visibleTarget + } + + protected async processQueue(sessionName: string): Promise { + if (this.processingQueues.has(sessionName)) { + return + } + this.processingQueues.add(sessionName) + + const queue = this.commandQueues.get(sessionName) + while (queue && queue.length > 0) { + const cmd = queue.shift()! + try { + const result = await cmd.execute() + cmd.resolve(result) + } catch (error) { + cmd.reject(error) + } + } + + if (queue && queue.length === 0 && this.commandQueues.get(sessionName) === queue) { + this.commandQueues.delete(sessionName) + } + this.processingQueues.delete(sessionName) + } +} diff --git a/src/main/browser/agent-browser-bridge-raw-process.ts b/src/main/browser/agent-browser-bridge-raw-process.ts new file mode 100644 index 00000000000..bdc5aadbbd9 --- /dev/null +++ b/src/main/browser/agent-browser-bridge-raw-process.ts @@ -0,0 +1,108 @@ +import { execFile, type ChildProcess } from 'node:child_process' +import { BrowserError } from './cdp-bridge' +import { classifyErrorCode } from './agent-browser-bridge-process' +import { AgentBrowserBridgeExecution } from './agent-browser-bridge-execution' +import { + CONSECUTIVE_TIMEOUT_LIMIT, + EXEC_TIMEOUT_MS, + type AgentBrowserExecOptions +} from './agent-browser-bridge-types' + +export abstract class AgentBrowserBridgeRawProcess extends AgentBrowserBridgeExecution { + protected abstract destroySession( + sessionName: string, + options?: { closeTimeoutMs?: number } + ): Promise + + protected runAgentBrowserRaw( + sessionName: string, + args: string[], + execOptions?: AgentBrowserExecOptions + ): Promise { + return new Promise((resolve, reject) => { + const session = this.sessions.get(sessionName) + let child: ChildProcess | null = null + child = execFile( + this.agentBrowserBin, + args, + // Why: screenshots return large base64 that exceeds Node's default 1MB maxBuffer (ENOBUFS). + { + timeout: execOptions?.timeoutMs ?? EXEC_TIMEOUT_MS, + maxBuffer: 50 * 1024 * 1024, + // Why windowsHide: see the stale-session close above -- every + // agent-browser invocation would otherwise flash a console (#14543). + windowsHide: true, + env: execOptions?.envOverrides + ? { ...this.agentBrowserEnv, ...execOptions.envOverrides } + : this.agentBrowserEnv + }, + (error, stdout, stderr) => { + if (session && session.activeProcess === child) { + session.activeProcess = null + } + if (child && this.cancelledProcesses.has(child)) { + this.cancelledProcesses.delete(child) + reject( + new BrowserError('browser_tab_closed', 'Tab was closed while command was running') + ) + return + } + + const liveSession = this.sessions.get(sessionName) + + if (error && (error as NodeJS.ErrnoException & { killed?: boolean }).killed) { + if (execOptions?.timeoutError) { + reject(execOptions.timeoutError) + return + } + if (liveSession) { + liveSession.consecutiveTimeouts++ + if (liveSession.consecutiveTimeouts >= CONSECUTIVE_TIMEOUT_LIMIT) { + // Why: 3 consecutive timeouts means the daemon is likely stuck — destroy and recreate + this.destroySession(sessionName) + } + } + reject(new BrowserError('browser_error', 'Browser command timed out')) + return + } + + if (liveSession) { + liveSession.consecutiveTimeouts = 0 + } + + if (error) { + // Why: agent-browser exits non-zero on failure but still writes structured JSON to stdout — parse it for the real error. + if (stdout) { + try { + const parsed = JSON.parse(stdout) + if (parsed.error) { + const code = classifyErrorCode(parsed.error) + reject( + this.createCommandError(sessionName, parsed.error, code, session?.webContentsId) + ) + return + } + } catch { + // stdout not valid JSON — fall through to stderr/error.message + } + } + const message = stderr || error.message + const code = classifyErrorCode(message) + reject(this.createCommandError(sessionName, message, code, session?.webContentsId)) + return + } + + resolve(stdout) + } + ) + if (session) { + session.activeProcess = child + } + if (execOptions?.stdinText !== undefined && child?.stdin) { + // Why: eval --stdin keeps paste-sized scripts out of argv on every platform. + child.stdin.on('error', () => {}) + child.stdin.end(execOptions.stdinText) + } + }) + } +} diff --git a/src/main/browser/agent-browser-bridge-result.ts b/src/main/browser/agent-browser-bridge-result.ts new file mode 100644 index 00000000000..9366389c2fd --- /dev/null +++ b/src/main/browser/agent-browser-bridge-result.ts @@ -0,0 +1,29 @@ +import { classifyErrorCode } from './agent-browser-bridge-process' + +export function translateResult( + stdout: string +): { ok: true; result: unknown } | { ok: false; error: { code: string; message: string } } { + let parsed: { success?: boolean; data?: unknown; error?: string } + try { + parsed = JSON.parse(stdout) + } catch { + return { + ok: false, + error: { + code: 'browser_error', + message: `Unexpected output from agent-browser: ${stdout.slice(0, 1000)}` + } + } + } + if (parsed.success) { + return { ok: true, result: parsed.data } + } + const message = parsed.error ?? 'Unknown browser error' + return { + ok: false, + error: { + code: classifyErrorCode(message), + message + } + } +} diff --git a/src/main/browser/agent-browser-bridge-shutdown.ts b/src/main/browser/agent-browser-bridge-shutdown.ts new file mode 100644 index 00000000000..4b53cc74df8 --- /dev/null +++ b/src/main/browser/agent-browser-bridge-shutdown.ts @@ -0,0 +1,40 @@ +import { mapSettledWithConcurrency } from '../../shared/map-with-concurrency' +import { sweepOrphanedAgentBrowserSessions } from './agent-browser-orphan-sweep' +import { AgentBrowserBridgeLifecycle } from './agent-browser-bridge-lifecycle' +import { + AGENT_BROWSER_CLEANUP_CONCURRENCY, + type AgentBrowserCleanupOptions +} from './agent-browser-bridge-types' + +export abstract class AgentBrowserBridgeShutdown extends AgentBrowserBridgeLifecycle { + // ── Session lifecycle ── + + // Why: a previous run that crashed or was SIGKILL'd left one daemon per open tab with + // nobody holding its name — closeStaleAgentBrowserSession only resets a name being reused. + async sweepOrphanedSessions(): Promise { + return sweepOrphanedAgentBrowserSessions({ + binaryPath: this.agentBrowserBin, + env: this.agentBrowserEnv, + ownsSocketDirectory: this.ownsAgentBrowserSocketDirectory, + isSessionLive: (sessionName) => + this.sessions.has(sessionName) || this.pendingSessionCreation.has(sessionName) + }) + } + + async destroyAllSessions(options?: AgentBrowserCleanupOptions): Promise { + this.shutdownStarted = true + // Why the union: a session still being created has already spawned its daemon but is not in + // `sessions` yet, so closing only `sessions` lets that daemon outlive the quit (#16367). + const sessionNames = new Set([ + ...this.sessions.keys(), + ...this.pendingSessionCreation.keys(), + ...this.pendingSessionDestruction.keys() + ]) + await mapSettledWithConcurrency( + [...sessionNames], + AGENT_BROWSER_CLEANUP_CONCURRENCY, + (sessionName) => this.destroySession(sessionName, options) + ) + this.pendingInterceptRestore.clear() + } +} diff --git a/src/main/browser/agent-browser-bridge-state-commands.ts b/src/main/browser/agent-browser-bridge-state-commands.ts new file mode 100644 index 00000000000..4e03055da57 --- /dev/null +++ b/src/main/browser/agent-browser-bridge-state-commands.ts @@ -0,0 +1,266 @@ +import type { + BrowserCookieGetResult, + BrowserCookieSetResult, + BrowserCookieDeleteResult, + BrowserCookie, + BrowserViewportResult, + BrowserGeolocationResult, + BrowserInterceptEnableResult, + BrowserInterceptDisableResult, + BrowserCaptureStartResult, + BrowserCaptureStopResult, + BrowserConsoleResult, + BrowserNetworkLogResult +} from '../../shared/runtime-types' +import { BrowserError } from './cdp-bridge' +import { parseShellArgs, stripAgentBrowserTargetArgs } from './agent-browser-bridge-process' +import { AgentBrowserBridgeInteractionCommands } from './agent-browser-bridge-interaction-commands' + +export abstract class AgentBrowserBridgeStateCommands extends AgentBrowserBridgeInteractionCommands { + // ── Cookie commands ── + + async cookieGet( + _url?: string, + worktreeId?: string, + browserPageId?: string + ): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + return (await this.execAgentBrowser(sessionName, [ + 'cookies', + 'get' + ])) as BrowserCookieGetResult + }) + } + + async cookieSet( + cookie: Partial, + worktreeId?: string, + browserPageId?: string + ): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + const args = ['cookies', 'set', cookie.name ?? '', cookie.value ?? ''] + if (cookie.domain) { + args.push('--domain', cookie.domain) + } + if (cookie.path) { + args.push('--path', cookie.path) + } + if (cookie.secure) { + args.push('--secure') + } + if (cookie.httpOnly) { + args.push('--httpOnly') + } + if (cookie.sameSite) { + args.push('--sameSite', cookie.sameSite) + } + if (cookie.expires != null) { + args.push('--expires', String(cookie.expires)) + } + return (await this.execAgentBrowser(sessionName, args)) as BrowserCookieSetResult + }) + } + + async cookieDelete( + name?: string, + domain?: string, + _url?: string, + worktreeId?: string, + browserPageId?: string + ): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + const args = ['cookies', 'clear'] + if (name) { + args.push('--name', name) + } + if (domain) { + args.push('--domain', domain) + } + return (await this.execAgentBrowser(sessionName, args)) as BrowserCookieDeleteResult + }) + } + + // ── Viewport / emulation commands ── + + async setViewport( + width: number, + height: number, + scale = 1, + mobile = false, + worktreeId?: string, + browserPageId?: string + ): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (_sessionName, target) => { + const wc = this.getWebContents(target.webContentsId) + if (!wc) { + throw new BrowserError('browser_tab_not_found', 'Tab is no longer available') + } + const dbg = wc.debugger + if (!dbg.isAttached()) { + throw new BrowserError('browser_error', 'Debugger not attached') + } + + // Why: agent-browser's `set viewport` has no `mobile` flag, so apply the emulation directly via CDP to honor Orca's --mobile. + await dbg.sendCommand('Emulation.setDeviceMetricsOverride', { + width, + height, + deviceScaleFactor: scale, + mobile + }) + // Why: BrowserView's compositor can keep the old host size after a metrics-only resize, cropping remote screencast clients. + await Promise.resolve(dbg.sendCommand('Emulation.setVisibleSize', { width, height })).catch( + () => {} + ) + + return { + width, + height, + deviceScaleFactor: scale, + mobile + } + }) + } + + async setGeolocation( + lat: number, + lon: number, + _accuracy?: number, + worktreeId?: string, + browserPageId?: string + ): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + return (await this.execAgentBrowser(sessionName, [ + 'set', + 'geo', + String(lat), + String(lon) + ])) as BrowserGeolocationResult + }) + } + + // ── Network interception commands ── + + async interceptEnable( + patterns?: string[], + worktreeId?: string, + browserPageId?: string + ): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + // Why: agent-browser uses "network route " to intercept. Route each pattern individually. + const urlPattern = patterns?.[0] ?? '**/*' + const args = ['network', 'route', urlPattern] + const result = (await this.execAgentBrowser( + sessionName, + args + )) as BrowserInterceptEnableResult + const session = this.sessions.get(sessionName) + if (session) { + this.pendingInterceptRestore.delete(sessionName) + session.activeInterceptPatterns = patterns ?? ['*'] + } + return result + }) + } + + async interceptDisable( + worktreeId?: string, + browserPageId?: string + ): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + const result = (await this.execAgentBrowser(sessionName, [ + 'network', + 'unroute' + ])) as BrowserInterceptDisableResult + const session = this.sessions.get(sessionName) + if (session) { + this.pendingInterceptRestore.delete(sessionName) + session.activeInterceptPatterns = [] + } + return result + }) + } + + async interceptList( + worktreeId?: string, + browserPageId?: string + ): Promise<{ requests: unknown[] }> { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + return (await this.execAgentBrowser(sessionName, ['network', 'requests'])) as { + requests: unknown[] + } + }) + } + + // TODO: Add interceptContinue/interceptBlock once agent-browser supports per-request decisions, not just URL-pattern routing. + + // ── Capture commands ── + + async captureStart( + worktreeId?: string, + browserPageId?: string + ): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + const result = (await this.execAgentBrowser(sessionName, [ + 'network', + 'har', + 'start' + ])) as BrowserCaptureStartResult + const session = this.sessions.get(sessionName) + if (session) { + session.activeCapture = true + } + return result + }) + } + + async captureStop( + worktreeId?: string, + browserPageId?: string + ): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + const result = (await this.execAgentBrowser(sessionName, [ + 'network', + 'har', + 'stop' + ])) as BrowserCaptureStopResult + const session = this.sessions.get(sessionName) + if (session) { + session.activeCapture = false + } + return result + }) + } + + async consoleLog( + _limit?: number, + worktreeId?: string, + browserPageId?: string + ): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + return (await this.execAgentBrowser(sessionName, ['console'])) as BrowserConsoleResult + }) + } + + async networkLog( + _limit?: number, + worktreeId?: string, + browserPageId?: string + ): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + return (await this.execAgentBrowser(sessionName, [ + 'network', + 'requests' + ])) as BrowserNetworkLogResult + }) + } + + // ── Generic passthrough ── + + async exec(command: string, worktreeId?: string, browserPageId?: string): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + // Why: strip target/session flags from passthrough so a caller can't override Orca's selected page or CDP proxy. + const args = stripAgentBrowserTargetArgs(parseShellArgs(command.trim())) + return await this.execAgentBrowser(sessionName, args) + }) + } +} diff --git a/src/main/browser/agent-browser-bridge-state.ts b/src/main/browser/agent-browser-bridge-state.ts new file mode 100644 index 00000000000..c23cd8e5ac5 --- /dev/null +++ b/src/main/browser/agent-browser-bridge-state.ts @@ -0,0 +1,64 @@ +import { app } from 'electron' +import type { ChildProcess } from 'node:child_process' +import type { BrowserManager } from './browser-manager' +import { createAgentBrowserProcessEnvironment } from './agent-browser-process-environment' +import { resolveAgentBrowserBinary } from './agent-browser-bridge-process' +import type { + AgentBrowserBridgeOptions, + QueuedCommand, + SessionState +} from './agent-browser-bridge-types' + +export abstract class AgentBrowserBridgeState { + // Why: per-worktree active tab so one worktree's tab switch can't affect another's command targeting. + protected readonly activeWebContentsPerWorktree = new Map() + protected activeWebContentsId: number | null = null + protected readonly sessions = new Map() + protected readonly commandQueues = new Map() + protected readonly processingQueues = new Set() + // Why: screenshot prep mutates shared paintability across tabs; serialize globally so concurrent captures don't blank each other. + protected screenshotTurn: Promise = Promise.resolve() + protected readonly agentBrowserBin: string + protected readonly agentBrowserEnv: NodeJS.ProcessEnv + protected readonly ownsAgentBrowserSocketDirectory: boolean + // Why: null when nothing bounds the daemon, so the bridge never guesses that one was replaced. + protected readonly agentBrowserIdleTimeoutMs: number | null + // Why: stash intercept patterns from a swap-destroyed session, keyed by name, so the next session restores them. + protected readonly pendingInterceptRestore = new Map() + // Why: promise-lock so two concurrent ensureSession calls don't both create the session entry. + protected readonly pendingSessionCreation = new Map>() + // Why: `agent-browser close` is async, keyed by session name — recreating before it finishes lets the old teardown close the new session. + protected readonly pendingSessionDestruction = new Map>() + protected readonly cancelledProcesses = new WeakSet() + protected shutdownStarted = false + + constructor( + protected readonly browserManager: BrowserManager, + protected readonly options: AgentBrowserBridgeOptions = {} + ) { + this.agentBrowserBin = resolveAgentBrowserBinary() + const processEnvironment = createAgentBrowserProcessEnvironment({ + inheritedEnv: process.env, + platform: process.platform, + userDataPath: app.getPath('userData') + }) + this.agentBrowserEnv = processEnvironment.env + this.ownsAgentBrowserSocketDirectory = processEnvironment.ownsSocketDirectory + const idleTimeoutMs = Number(this.agentBrowserEnv.AGENT_BROWSER_IDLE_TIMEOUT_MS) + this.agentBrowserIdleTimeoutMs = idleTimeoutMs > 0 ? idleTimeoutMs : null + } + + protected resolveTabIdSafe(webContentsId: number): string | null { + return this.browserManager.getTabIdForWebContentsId(webContentsId) + } + + protected getWebContents(webContentsId: number): Electron.WebContents | null { + try { + const { webContents } = require('electron') + const target = webContents.fromId(webContentsId) + return target && !target.isDestroyed() ? target : null + } catch { + return null + } + } +} diff --git a/src/main/browser/agent-browser-bridge-tabs.ts b/src/main/browser/agent-browser-bridge-tabs.ts new file mode 100644 index 00000000000..0af381f6d96 --- /dev/null +++ b/src/main/browser/agent-browser-bridge-tabs.ts @@ -0,0 +1,232 @@ +import type { BrowserTabInfo, BrowserTabListResult } from '../../shared/runtime-types' +import { BrowserError } from './cdp-bridge' +import type { ResolvedBrowserCommandTarget } from './agent-browser-bridge-types' +import { AgentBrowserBridgeState } from './agent-browser-bridge-state' + +export abstract class AgentBrowserBridgeTabs extends AgentBrowserBridgeState { + // ── Tab tracking ── + + setActiveTab(webContentsId: number, worktreeId?: string): void { + this.activeWebContentsId = webContentsId + if (worktreeId) { + this.activeWebContentsPerWorktree.set(worktreeId, webContentsId) + } + this.options.onTabsChanged?.(worktreeId) + } + + protected selectFallbackActiveWebContents( + worktreeId: string, + excludedWebContentsId?: number + ): number | null { + for (const [, wcId] of this.getRegisteredTabs(worktreeId)) { + if (wcId === excludedWebContentsId) { + continue + } + if (this.getWebContents(wcId)) { + this.activeWebContentsPerWorktree.set(worktreeId, wcId) + return wcId + } + } + this.activeWebContentsPerWorktree.delete(worktreeId) + return null + } + + getActiveWebContentsId(): number | null { + return this.activeWebContentsId + } + + getPageInfo( + worktreeId?: string, + browserPageId?: string + ): { browserPageId: string; url: string; title: string } | null { + try { + const target = this.resolveCommandTarget(worktreeId, browserPageId) + const wc = this.getWebContents(target.webContentsId) + if (!wc) { + return null + } + return { + browserPageId: target.browserPageId, + url: wc.getURL() ?? '', + title: wc.getTitle() ?? '' + } + } catch { + return null + } + } + onTabChanged(webContentsId: number, worktreeId?: string): void { + this.activeWebContentsId = webContentsId + if (worktreeId) { + this.activeWebContentsPerWorktree.set(worktreeId, webContentsId) + } + this.options.onTabsChanged?.(worktreeId) + } + getRegisteredTabs(worktreeId?: string): Map { + const all = this.browserManager.getWebContentsIdByTabId() + if (!worktreeId) { + return all + } + + const filtered = new Map() + for (const [tabId, wcId] of all) { + if (this.browserManager.getWorktreeIdForTab(tabId) === worktreeId) { + filtered.set(tabId, wcId) + } + } + return filtered + } + + // ── Tab management ── + + tabList(worktreeId?: string): BrowserTabListResult { + const tabs = this.getRegisteredTabs(worktreeId) + // Why: use the per-worktree active tab so listing matches command routing, but read-only — discovery must not mutate active-tab state. + let activeWcId = + (worktreeId && this.activeWebContentsPerWorktree.get(worktreeId)) ?? this.activeWebContentsId + const result: BrowserTabInfo[] = [] + let index = 0 + let firstLiveWcId: number | null = null + for (const [tabId, wcId] of tabs) { + const wc = this.getWebContents(wcId) + if (!wc) { + this.browserManager.unregisterGuest(tabId) + continue + } + if (firstLiveWcId === null) { + firstLiveWcId = wcId + } + const loadError = this.browserManager.getBrowserPageLoadError(tabId) + const certificateFailure = this.browserManager.getBrowserPageCertificateFailure(tabId) + result.push({ + browserPageId: tabId, + index: index++, + // Why: failed WebContents report chrome-error://, not the address the user asked to load. + url: loadError?.validatedUrl ?? wc.getURL() ?? '', + title: wc.getTitle() ?? '', + active: wcId === activeWcId, + loadError, + certificateFailure + }) + } + // Why: with no active tab yet, show the first live tab as active without mutating state — keeps `tab list` side-effect free. + if (activeWcId == null && firstLiveWcId !== null) { + activeWcId = firstLiveWcId + if (result.length > 0) { + result[0].active = true + } + } + return { tabs: result } + } + getActivePageId(worktreeId?: string, browserPageId?: string): string | null { + try { + return this.resolveCommandTarget(worktreeId, browserPageId).browserPageId + } catch { + return null + } + } + + protected resolveCommandTarget( + worktreeId?: string, + browserPageId?: string, + requireScopedTarget = false + ): ResolvedBrowserCommandTarget { + if (!browserPageId) { + return requireScopedTarget + ? this.resolveScopedActiveTab(worktreeId) + : this.resolveActiveTab(worktreeId) + } + + const tabs = this.getRegisteredTabs(worktreeId) + const webContentsId = tabs.get(browserPageId) + if (webContentsId == null) { + const scope = worktreeId ? ' in this worktree' : '' + throw new BrowserError( + 'browser_tab_not_found', + `Browser page ${browserPageId} was not found${scope}` + ) + } + + if (!this.getWebContents(webContentsId)) { + this.browserManager.unregisterGuest(browserPageId) + throw new BrowserError( + 'browser_tab_not_found', + `Browser page ${browserPageId} is no longer available` + ) + } + + return { browserPageId, webContentsId } + } + + protected resolveActiveTab(worktreeId?: string): ResolvedBrowserCommandTarget { + const tabs = this.getRegisteredTabs(worktreeId) + + if (tabs.size === 0) { + throw new BrowserError('browser_no_tab', 'No browser tab open in this worktree') + } + + // Why: prefer per-worktree active tab to avoid cross-worktree interference; fall back to global for callers without worktreeId. + const preferredWcId = + (worktreeId && this.activeWebContentsPerWorktree.get(worktreeId)) ?? this.activeWebContentsId + + if (preferredWcId != null) { + for (const [tabId, wcId] of tabs) { + if (wcId === preferredWcId && this.getWebContents(wcId)) { + return { browserPageId: tabId, webContentsId: wcId } + } + if (wcId === preferredWcId) { + this.browserManager.unregisterGuest(tabId) + if (this.activeWebContentsId === wcId) { + this.activeWebContentsId = null + } + if (worktreeId && this.activeWebContentsPerWorktree.get(worktreeId) === wcId) { + this.activeWebContentsPerWorktree.delete(worktreeId) + } + } + } + } + + // Why: persisted state can leave ghost tabs (dead webContents); skip them and activate the first live tab for consistency. + for (const [tabId, wcId] of tabs) { + if (this.getWebContents(wcId)) { + this.activeWebContentsId = wcId + if (worktreeId) { + this.activeWebContentsPerWorktree.set(worktreeId, wcId) + } + return { browserPageId: tabId, webContentsId: wcId } + } + this.browserManager.unregisterGuest(tabId) + } + + throw new BrowserError( + 'browser_no_tab', + 'No live browser tab available — all registered tabs have been destroyed' + ) + } + + // Why: don't fall back to the global tab for text mutation — it could inject into another worktree's foreground webview and steal focus. + protected resolveScopedActiveTab(worktreeId?: string): ResolvedBrowserCommandTarget { + if (worktreeId) { + return this.resolveActiveTab(worktreeId) + } + + const worktreesWithLiveTabs = new Set() + for (const [tabId, wcId] of this.getRegisteredTabs(undefined)) { + if (this.getWebContents(wcId)) { + worktreesWithLiveTabs.add(this.browserManager.getWorktreeIdForTab(tabId)) + } + } + + if (worktreesWithLiveTabs.size === 0) { + throw new BrowserError('browser_no_tab', 'No browser tab open in this worktree') + } + if (worktreesWithLiveTabs.size > 1) { + throw new BrowserError( + 'browser_target_ambiguous', + 'Multiple worktrees have browser tabs open; pass --worktree to target text insertion safely' + ) + } + + const [onlyWorktreeId] = worktreesWithLiveTabs + return this.resolveActiveTab(onlyWorktreeId) + } +} diff --git a/src/main/browser/agent-browser-bridge-types.ts b/src/main/browser/agent-browser-bridge-types.ts new file mode 100644 index 00000000000..a89c9fcbca8 --- /dev/null +++ b/src/main/browser/agent-browser-bridge-types.ts @@ -0,0 +1,65 @@ +import type { ChildProcess } from 'node:child_process' +import type { CdpWsProxy } from './cdp-ws-proxy' +import type { BrowserError } from './cdp-bridge' + +// Why: must exceed agent-browser's internal timeouts (goto 30s, wait 60s) so the bridge never kills a command before its own timeout fires. +export const EXEC_TIMEOUT_MS = 90_000 +export const CONSECUTIVE_TIMEOUT_LIMIT = 3 +export const WAIT_PROCESS_TIMEOUT_GRACE_MS = 1_000 +export const STALE_SESSION_CLOSE_TIMEOUT_MS = 3_000 +// Why separate from EXEC_TIMEOUT_MS: a close is a member of the 20s will-quit barrier and must finish well inside it. +export const AGENT_BROWSER_CLEANUP_TIMEOUT_MS = 5_000 +export const AGENT_BROWSER_CLEANUP_CONCURRENCY = 4 +export const EMBEDDED_NAVIGATION_TIMEOUT_MS = 30_000 +export const AGENT_BROWSER_TEXT_ARGUMENT_MAX_BYTES = 8 * 1024 +export const AGENT_BROWSER_CLIPBOARD_WRITE_MAX_BYTES = AGENT_BROWSER_TEXT_ARGUMENT_MAX_BYTES + +export type SessionState = { + proxy: CdpWsProxy + cdpEndpoint: string + initialized: boolean + consecutiveTimeouts: number + // Why: track active interception patterns so they can be re-enabled after session restart + activeInterceptPatterns: string[] + activeCapture: boolean + // Why: the daemon retires itself once idle; the gap since the last command is how the bridge notices. + lastCommandAt: number + // Why: verify the tab is alive at execution time, not just enqueue time — queue delay can destroy it in between. + webContentsId: number + activeProcess: ChildProcess | null +} + +export type QueuedCommand = { + execute: () => Promise + resolve: (value: unknown) => void + reject: (reason: unknown) => void +} + +export type ResolvedBrowserCommandTarget = { + browserPageId: string + webContentsId: number +} + +export type AgentBrowserCleanupOptions = { + closeTimeoutMs?: number +} + +export type BrowserMouseModifier = 'cmd' | 'ctrl' | 'alt' | 'shift' + +export type AgentBrowserExecOptions = { + envOverrides?: NodeJS.ProcessEnv + timeoutMs?: number + timeoutError?: BrowserError + stdinText?: string +} + +export type EnqueueTargetedCommandOptions = { + ensureSession?: boolean + ensureVisible?: boolean + // Why: text-mutating commands must never fall back to the global tab (may be a worktree the user is viewing). + requireScopedTarget?: boolean +} + +export type AgentBrowserBridgeOptions = { + onTabsChanged?: (worktreeId?: string) => void +} diff --git a/src/main/browser/agent-browser-bridge-utility-commands.ts b/src/main/browser/agent-browser-bridge-utility-commands.ts new file mode 100644 index 00000000000..f3697168cd8 --- /dev/null +++ b/src/main/browser/agent-browser-bridge-utility-commands.ts @@ -0,0 +1,175 @@ +import { BrowserError } from './cdp-bridge' +import { assertClipboardTextWriteWithinLimitWithYield } from '../../shared/clipboard-text' +import { AGENT_BROWSER_CLIPBOARD_WRITE_MAX_BYTES } from './agent-browser-bridge-types' +import type { BrowserBackResult, BrowserReloadResult } from '../../shared/runtime-types' +import { AgentBrowserBridgeMouseCommands } from './agent-browser-bridge-mouse-commands' + +export abstract class AgentBrowserBridgeUtilityCommands extends AgentBrowserBridgeMouseCommands { + // ── Clipboard commands ── + + async clipboardRead(worktreeId?: string, browserPageId?: string): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + return await this.execAgentBrowser(sessionName, ['clipboard', 'read']) + }) + } + + async clipboardWrite( + text: string, + worktreeId?: string, + browserPageId?: string + ): Promise { + await assertClipboardTextWriteWithinLimitWithYield(text, { + maxBytes: AGENT_BROWSER_CLIPBOARD_WRITE_MAX_BYTES + }) + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + return await this.execAgentBrowser(sessionName, ['clipboard', 'write', text]) + }) + } + + // ── Dialog commands ── + + async dialogAccept(text?: string, worktreeId?: string, browserPageId?: string): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + const args = ['dialog', 'accept'] + if (text) { + args.push(text) + } + return await this.execAgentBrowser(sessionName, args) + }) + } + + async dialogDismiss(worktreeId?: string, browserPageId?: string): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + return await this.execAgentBrowser(sessionName, ['dialog', 'dismiss']) + }) + } + + // ── Storage commands ── + + async storageLocalGet( + key: string, + worktreeId?: string, + browserPageId?: string + ): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + return await this.execAgentBrowser(sessionName, ['storage', 'local', 'get', key]) + }) + } + + async storageLocalSet( + key: string, + value: string, + worktreeId?: string, + browserPageId?: string + ): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + return await this.execAgentBrowser(sessionName, ['storage', 'local', 'set', key, value]) + }) + } + + async storageLocalClear(worktreeId?: string, browserPageId?: string): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + return await this.execAgentBrowser(sessionName, ['storage', 'local', 'clear']) + }) + } + + async storageSessionGet( + key: string, + worktreeId?: string, + browserPageId?: string + ): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + return await this.execAgentBrowser(sessionName, ['storage', 'session', 'get', key]) + }) + } + + async storageSessionSet( + key: string, + value: string, + worktreeId?: string, + browserPageId?: string + ): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + return await this.execAgentBrowser(sessionName, ['storage', 'session', 'set', key, value]) + }) + } + + async storageSessionClear(worktreeId?: string, browserPageId?: string): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + return await this.execAgentBrowser(sessionName, ['storage', 'session', 'clear']) + }) + } + + // ── Download command ── + + async download( + selector: string, + path: string, + worktreeId?: string, + browserPageId?: string + ): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + return await this.execAgentBrowser(sessionName, ['download', selector, path]) + }) + } + + // ── Highlight command ── + + async highlight(selector: string, worktreeId?: string, browserPageId?: string): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + return await this.execAgentBrowser(sessionName, ['highlight', selector]) + }) + } + + async back(worktreeId?: string, browserPageId?: string): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + return (await this.execAgentBrowser(sessionName, ['back'])) as BrowserBackResult + }) + } + + async forward(worktreeId?: string, browserPageId?: string): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + return (await this.execAgentBrowser(sessionName, ['forward'])) as BrowserBackResult + }) + } + + async reload(worktreeId?: string, browserPageId?: string): Promise { + // Why: reload can trigger an Electron process swap that destroys the session mid-command — reload via webContents directly instead. + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (_sessionName, target) => { + const wc = this.getWebContents(target.webContentsId) + if (!wc) { + throw new BrowserError('browser_no_tab', 'Tab is no longer available') + } + wc.reload() + await new Promise((resolve) => { + let settled = false + let fallbackTimer: ReturnType | null = null + + const finish = (): void => { + if (settled) { + return + } + settled = true + wc.removeListener('did-finish-load', onFinish) + wc.removeListener('did-fail-load', onFail) + if (fallbackTimer) { + clearTimeout(fallbackTimer) + fallbackTimer = null + } + resolve() + } + const onFinish = (): void => finish() + const onFail = (): void => finish() + + wc.on('did-finish-load', onFinish) + wc.on('did-fail-load', onFail) + // Why: clear the fallback timer on load; otherwise each reload leaks the webContents + listeners until the 10s timeout. + fallbackTimer = setTimeout(finish, 10_000) + if (typeof fallbackTimer.unref === 'function') { + fallbackTimer.unref() + } + }) + return { url: wc.getURL(), title: wc.getTitle() } + }) + } +} diff --git a/src/main/browser/agent-browser-bridge.ts b/src/main/browser/agent-browser-bridge.ts index 01f77b45451..aeeb64385a3 100644 --- a/src/main/browser/agent-browser-bridge.ts +++ b/src/main/browser/agent-browser-bridge.ts @@ -1,2884 +1,20 @@ -/* eslint-disable max-lines */ -import { execFile, type ChildProcess } from 'node:child_process' -import { existsSync, accessSync, chmodSync, readFileSync, constants } from 'node:fs' -import { join } from 'node:path' -import { platform, arch } from 'node:os' -import { app, type WebContents } from 'electron' -import { CdpWsProxy } from './cdp-ws-proxy' -import { captureFullPageScreenshot } from './cdp-screenshot' -import { acquireElectronDebugger } from './electron-debugger-lease' -import type { BrowserManager } from './browser-manager' -import { BrowserError } from './cdp-bridge' -import type { - BrowserTabInfo, - BrowserTabListResult, - BrowserTabSwitchResult, - BrowserSnapshotResult, - BrowserClickResult, - BrowserGotoResult, - BrowserFillResult, - BrowserTypeResult, - BrowserSelectResult, - BrowserScrollResult, - BrowserBackResult, - BrowserReloadResult, - BrowserScreenshotResult, - BrowserEvalResult, - BrowserHoverResult, - BrowserDragResult, - BrowserUploadResult, - BrowserWaitResult, - BrowserCheckResult, - BrowserFocusResult, - BrowserClearResult, - BrowserSelectAllResult, - BrowserKeypressResult, - BrowserPdfResult, - BrowserCookieGetResult, - BrowserCookieSetResult, - BrowserCookieDeleteResult, - BrowserViewportResult, - BrowserGeolocationResult, - BrowserInterceptEnableResult, - BrowserInterceptDisableResult, - BrowserConsoleResult, - BrowserNetworkLogResult, - BrowserCaptureStartResult, - BrowserCaptureStopResult, - BrowserCookie -} from '../../shared/runtime-types' -import { assertClipboardTextWriteWithinLimitWithYield } from '../../shared/clipboard-text' -import { normalizeBrowserNavigationUrl } from '../../shared/browser-url' -import { mapSettledWithConcurrency } from '../../shared/map-with-concurrency' -import { iterateBrowserTextInsertionChunks } from './browser-text-insertion' -import { createAgentBrowserProcessEnvironment } from './agent-browser-process-environment' -import { - ORCA_TAB_SESSION_PREFIX, - sweepOrphanedAgentBrowserSessions -} from './agent-browser-orphan-sweep' - -// Why: must exceed agent-browser's internal timeouts (goto 30s, wait 60s) so the bridge never kills a command before its own timeout fires. -const EXEC_TIMEOUT_MS = 90_000 -const CONSECUTIVE_TIMEOUT_LIMIT = 3 -const WAIT_PROCESS_TIMEOUT_GRACE_MS = 1_000 -const STALE_SESSION_CLOSE_TIMEOUT_MS = 3_000 -// Why separate from EXEC_TIMEOUT_MS: a close is a member of the 20s will-quit barrier and must finish well inside it. -const AGENT_BROWSER_CLEANUP_TIMEOUT_MS = 5_000 -const AGENT_BROWSER_CLEANUP_CONCURRENCY = 4 -const EMBEDDED_NAVIGATION_TIMEOUT_MS = 30_000 -export const AGENT_BROWSER_TEXT_ARGUMENT_MAX_BYTES = 8 * 1024 -export const AGENT_BROWSER_CLIPBOARD_WRITE_MAX_BYTES = AGENT_BROWSER_TEXT_ARGUMENT_MAX_BYTES - -type SessionState = { - proxy: CdpWsProxy - cdpEndpoint: string - initialized: boolean - consecutiveTimeouts: number - // Why: track active interception patterns so they can be re-enabled after session restart - activeInterceptPatterns: string[] - activeCapture: boolean - // Why: the daemon retires itself once idle; the gap since the last command is how the bridge notices. - lastCommandAt: number - // Why: verify the tab is alive at execution time, not just enqueue time — queue delay can destroy it in between. - webContentsId: number - activeProcess: ChildProcess | null -} - -type QueuedCommand = { - execute: () => Promise - resolve: (value: unknown) => void - reject: (reason: unknown) => void -} - -type ResolvedBrowserCommandTarget = { - browserPageId: string - webContentsId: number -} - -type AgentBrowserCleanupOptions = { - closeTimeoutMs?: number -} - -export type BrowserMouseModifier = 'cmd' | 'ctrl' | 'alt' | 'shift' - -function focusedValueSetExpression( - valueExpression: string, - options?: { append?: boolean; dispatchEvents?: boolean } -): string { - const nextValue = options?.append - ? ["String(target.value ?? '') + ", valueExpression].join('') - : valueExpression - const dispatchEvents = options?.dispatchEvents - ? " target.dispatchEvent(new Event('input', { bubbles: true })); target.dispatchEvent(new Event('change', { bubbles: true }));" - : '' - return [ - '(() => { const el = document.activeElement; if (el) {', - // Why: ARIA spinbutton wrappers can hold focus while a contained or controlled input owns the value. - " const editableSelector = \"input:not([type='hidden']):not([type='button']):not([type='checkbox']):not([type='radio']):not([type='file']):not([type='image']):not([type='reset']):not([type='submit']), textarea\";", - " const isEditable = (node) => !!node && (node.matches?.(editableSelector) ?? (node.tagName === 'TEXTAREA' || (node.tagName === 'INPUT' && !/^(hidden|button|checkbox|radio|file|image|reset|submit)$/i.test(node.getAttribute?.('type') ?? ''))));", - ' const findEditable = (root) => root?.querySelector?.(editableSelector) ?? null;', - ' let target = el;', - " if (!isEditable(target) && target.getAttribute?.('role') === 'spinbutton') {", - " const controls = target.getAttribute('aria-controls');", - ' if (controls) { for (const id of controls.split(/\\s+/)) { if (!id) continue; const controlled = document.getElementById(id); if (isEditable(controlled)) { target = controlled; break; } const descendant = findEditable(controlled); if (descendant) { target = descendant; break; } } }', - ' if (target === el) { const descendant = findEditable(target); if (descendant) target = descendant; }', - ' }', - " const nativeSetter = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(target), 'value')?.set;", - ' const nextValue = ', - nextValue, - '; if (nativeSetter) { nativeSetter.call(target, nextValue); } else { target.value = nextValue; }', - dispatchEvents, - ' } })()' - ].join('') -} - -// Why: rich editors reconcile only real browser edit transactions; a direct-DOM fallback can leave their model stale. -function focusedRichTextEditExpression( - valueExpression: string, - options?: { selectAll?: boolean } -): string { - const selectAll = options?.selectAll ? 'true' : 'false' - return [ - '(() => {', - ' const target = document.activeElement;', - ' const value = ', - valueExpression, - ';', - ` const selectAll = ${selectAll};`, - " const isEditable = target?.isContentEditable === true || /^(|true|plaintext-only)$/i.test(target?.getAttribute?.('contenteditable') ?? 'false');", - " if (!target || target === document.body || !isEditable) { throw new Error('Focused rich-text target is unavailable'); }", - ' if (selectAll) {', - " if (typeof window.getSelection !== 'function') { throw new Error('Rich-text selection is unavailable'); }", - ' const selection = window.getSelection();', - " if (!selection) { throw new Error('Rich-text selection is unavailable'); }", - ' selection.selectAllChildren(target);', - ' }', - " const editCommand = selectAll && value.length === 0 ? 'delete' : 'insertText';", - ' let edited = false;', - ' try {', - ' edited = document.execCommand(editCommand, false, value) === true;', - ' } catch { edited = false; }', - " if (!edited) { throw new Error('Browser rich-text editing command failed'); }", - ' })()' - ].join('') -} - -function isExplicitContentEditableResult(result: unknown): boolean { - const value = - result && typeof result === 'object' ? (result as { value?: unknown }).value : undefined - return typeof value === 'string' && /^(|true|plaintext-only)$/i.test(value) -} - -type AgentBrowserExecOptions = { - envOverrides?: NodeJS.ProcessEnv - timeoutMs?: number - timeoutError?: BrowserError - stdinText?: string -} - -type EnqueueTargetedCommandOptions = { - ensureSession?: boolean - ensureVisible?: boolean - // Why: text-mutating commands must never fall back to the global tab (may be a worktree the user is viewing). - requireScopedTarget?: boolean -} - -type AgentBrowserBridgeOptions = { - onTabsChanged?: (worktreeId?: string) => void -} - -function agentBrowserNativeName(): string { - const ext = process.platform === 'win32' ? '.exe' : '' - return `agent-browser-${platform()}-${arch()}${ext}` -} - -function resolveAgentBrowserBinary(): string { - // Why: use Electron's resourcesPath (not hand-rolled ../resources) so packaged macOS case-sensitive builds resolve the binary. - const bundledResourcesPath = - process.resourcesPath ?? - (process.platform === 'darwin' - ? join(app.getPath('exe'), '..', '..', 'Resources') - : join(app.getPath('exe'), '..', 'resources')) - const bundled = join(bundledResourcesPath, agentBrowserNativeName()) - if (existsSync(bundled)) { - return bundled - } - - // Why: dev mode — resolve from node_modules via app.getAppPath(); __dirname is unreliable after electron-vite bundling. - const nmBin = join( - app.getAppPath(), - 'node_modules', - 'agent-browser', - 'bin', - agentBrowserNativeName() - ) - if (existsSync(nmBin)) { - if (process.platform !== 'win32') { - try { - accessSync(nmBin, constants.X_OK) - } catch { - chmodSync(nmBin, 0o755) - } - } - return nmBin - } - - // Last resort: assume it's on PATH - return 'agent-browser' -} - -// Why: exec commands arrive as one string; split on whitespace but respect quotes so quoted args stay intact. -function parseShellArgs(input: string): string[] { - const args: string[] = [] - let current = '' - let inDouble = false - let inSingle = false - - for (let i = 0; i < input.length; i++) { - const ch = input[i] - if (ch === '"' && !inSingle) { - inDouble = !inDouble - } else if (ch === "'" && !inDouble) { - inSingle = !inSingle - } else if (ch === ' ' && !inDouble && !inSingle) { - if (current) { - args.push(current) - current = '' - } - } else { - current += ch - } - } - if (current) { - args.push(current) - } - return args -} - -function stripAgentBrowserTargetArgs(args: string[]): string[] { - const stripped: string[] = [] - for (let index = 0; index < args.length; index++) { - const arg = args[index] - if (arg === '--cdp' || arg === '--session') { - index++ - continue - } - if (arg.startsWith('--cdp=') || arg.startsWith('--session=')) { - continue - } - stripped.push(arg) - } - return stripped -} - -// Why: agent-browser returns generic errors for stale/unknown refs; map to a specific code so agents can detect and re-snapshot. -function classifyErrorCode(message: string): string { - if (/unknown ref|ref not found|element not found: @e/i.test(message)) { - return 'browser_stale_ref' - } - return 'browser_error' -} - -function isAbortedNavigationError(error: unknown): boolean { - if (!error || typeof error !== 'object') { - return false - } - const { code, errno } = error as { code?: unknown; errno?: unknown } - return code === 'ERR_ABORTED' || errno === -3 -} - -function isWebContentsLoading(wc: WebContents): boolean { - try { - return wc.isLoading() - } catch { - // Why: destruction races are resolved against the authoritative page registration after the wait. - return false - } -} - -function waitForAbortedNavigationReplacement( - wc: WebContents, - browserPageId: string, - timeoutMs: number -): Promise { - if (!isWebContentsLoading(wc)) { - return Promise.resolve() - } - - return new Promise((resolve, reject) => { - let settled = false - let timeout: ReturnType | null = null - const finish = (error?: BrowserError): void => { - if (settled) { - return - } - settled = true - wc.removeListener('did-stop-loading', onDidStopLoading) - wc.removeListener('destroyed', onDestroyed) - if (timeout) { - clearTimeout(timeout) - } - if (error) { - reject(error) - } else { - resolve() - } - } - const onDidStopLoading = (): void => finish() - const onDestroyed = (): void => finish() - - wc.on('did-stop-loading', onDidStopLoading) - wc.on('destroyed', onDestroyed) - timeout = setTimeout( - () => - finish( - new BrowserError( - 'browser_error', - `Failed to navigate browser page ${browserPageId}: Browser navigation timed out after ${EMBEDDED_NAVIGATION_TIMEOUT_MS}ms` - ) - ), - timeoutMs - ) - timeout.unref?.() - - // Why: the replacement can finish between loadURL rejecting and listener attachment. - if (!isWebContentsLoading(wc)) { - finish() - } - }) -} - -function isTabClosedTransportError(message: string): boolean { - return /session destroyed while command|session destroyed while commands|connection refused|cdp discovery methods failed|websocket connect failed/i.test( - message - ) -} - -function pageUnavailableMessageForSession(sessionName: string): string { - const prefix = ORCA_TAB_SESSION_PREFIX - const browserPageId = sessionName.startsWith(prefix) ? sessionName.slice(prefix.length) : null - return browserPageId - ? `Browser page ${browserPageId} is no longer available` - : 'Browser tab is no longer available' -} - -type CdpMouseButton = 'left' | 'middle' | 'right' - -type BrowserClickPoint = { - x: number - y: number - adjusted: boolean - handled: boolean -} - -function normalizeCdpMouseButton(button?: string): CdpMouseButton { - return button === 'middle' || button === 'right' ? button : 'left' -} - -function cdpMouseButtonMask(button: CdpMouseButton): number { - if (button === 'right') { - return 2 - } - if (button === 'middle') { - return 4 - } - return 1 -} - -function cdpMouseModifierMask(modifiers: BrowserMouseModifier[] | undefined): number { - if (!modifiers || modifiers.length === 0) { - return 0 - } - let mask = 0 - for (const modifier of modifiers) { - if (modifier === 'alt') { - mask |= 1 - } else if (modifier === 'ctrl') { - mask |= 2 - } else if (modifier === 'cmd') { - mask |= 4 - } else if (modifier === 'shift') { - mask |= 8 - } - } - return mask -} - -function readClickPoint(value: unknown, fallback: BrowserClickPoint): BrowserClickPoint { - const point = value && typeof value === 'object' ? (value as Record) : null - const x = point?.x - const y = point?.y - if ( - typeof x !== 'number' || - !Number.isFinite(x) || - typeof y !== 'number' || - !Number.isFinite(y) - ) { - return fallback - } - return { x, y, adjusted: point?.adjusted === true, handled: point?.handled === true } -} - -function mobileTouchClickExpression( - x: number, - y: number, - radius: number, - allowDomActivation: boolean -): string { - return `(() => { - const inputX = ${JSON.stringify(x)}; - const inputY = ${JSON.stringify(y)}; - const radius = ${JSON.stringify(radius)}; - const allowDomActivation = ${JSON.stringify(allowDomActivation)}; - const selector = [ - 'a[href]', - 'button', - 'input', - 'textarea', - 'select', - 'summary', - 'label', - '[role="button"]', - '[role="link"]', - '[role="menuitem"]', - '[role="tab"]', - '[role="checkbox"]', - '[role="radio"]', - '[role="switch"]', - '[onclick]', - '[tabindex]:not([tabindex="-1"])' - ].join(','); - const clamp = (value, min, max) => Math.min(max, Math.max(min, value)); - const isUsable = (el) => { - const rect = el.getBoundingClientRect(); - const style = window.getComputedStyle(el); - return rect.width > 0 && rect.height > 0 && style.display !== 'none' && - style.visibility !== 'hidden' && style.pointerEvents !== 'none'; - }; - const dispatchClick = (target, clickX, clickY) => { - try { - if (typeof target.focus === 'function') { - target.focus({ preventScroll: true }); - } - } catch { - try { target.focus(); } catch {} - } - if (typeof target.click === 'function') { - target.click(); - return true; - } - const init = { - bubbles: true, - cancelable: true, - composed: true, - view: window, - clientX: clickX, - clientY: clickY, - screenX: clickX, - screenY: clickY, - button: 0, - buttons: 1 - }; - try { - if (typeof PointerEvent === 'function') { - target.dispatchEvent(new PointerEvent('pointerdown', { ...init, pointerType: 'touch', pointerId: 1 })); - target.dispatchEvent(new PointerEvent('pointerup', { ...init, buttons: 0, pointerType: 'touch', pointerId: 1 })); - } - } catch {} - target.dispatchEvent(new MouseEvent('mousedown', init)); - target.dispatchEvent(new MouseEvent('mouseup', { ...init, buttons: 0 })); - target.dispatchEvent(new MouseEvent('click', { ...init, buttons: 0 })); - return true; - }; - const clickableFor = (el) => { - for (let node = el; node && node.nodeType === 1; node = node.parentElement) { - if (node.matches(selector)) return node; - if (window.getComputedStyle(node).cursor === 'pointer') return node; - } - return null; - }; - const offsets = [[0, 0]]; - for (const distance of [radius * 0.45, radius, radius * 1.35]) { - for (const angle of [0, Math.PI / 4, Math.PI / 2, Math.PI * 3 / 4, Math.PI, - Math.PI * 5 / 4, Math.PI * 3 / 2, Math.PI * 7 / 4]) { - offsets.push([Math.cos(angle) * distance, Math.sin(angle) * distance]); - } - } - let best = null; - for (const [dx, dy] of offsets) { - const px = inputX + dx; - const py = inputY + dy; - if (px < 0 || py < 0 || px > window.innerWidth || py > window.innerHeight) continue; - for (const el of document.elementsFromPoint(px, py)) { - const target = clickableFor(el); - if (!target || !isUsable(target)) continue; - const rect = target.getBoundingClientRect(); - const clickX = clamp(inputX, rect.left + 1, rect.right - 1); - const clickY = clamp(inputY, rect.top + 1, rect.bottom - 1); - const score = Math.hypot(clickX - inputX, clickY - inputY) + Math.hypot(dx, dy) * 0.25; - if (!best || score < best.score) best = { score, x: clickX, y: clickY, target }; - break; - } - } - if (best && allowDomActivation && dispatchClick(best.target, best.x, best.y)) { - return { x: best.x, y: best.y, adjusted: true, handled: true }; - } - if (best) { - return { x: best.x, y: best.y, adjusted: true, handled: false }; - } - return { x: inputX, y: inputY, adjusted: false, handled: false }; - })()` -} - -async function resolveMobileTouchClickPoint( - dbg: WebContents['debugger'], - x: number, - y: number, - radius: number | undefined, - allowDomActivation: boolean -): Promise { - const fallback = { x, y, adjusted: false, handled: false } - if (typeof radius !== 'number' || !Number.isFinite(radius) || radius <= 0) { - return fallback - } - try { - const result = await dbg.sendCommand('Runtime.evaluate', { - expression: mobileTouchClickExpression(x, y, radius, allowDomActivation), - returnByValue: true, - silent: true - }) - const raw = result && typeof result === 'object' ? (result as Record) : null - const evaluated = raw?.result && typeof raw.result === 'object' ? raw.result : null - return readClickPoint((evaluated as Record | null)?.value, fallback) - } catch { - return fallback - } -} - -function translateResult( - stdout: string -): { ok: true; result: unknown } | { ok: false; error: { code: string; message: string } } { - let parsed: { success?: boolean; data?: unknown; error?: string } - try { - parsed = JSON.parse(stdout) - } catch { - return { - ok: false, - error: { - code: 'browser_error', - message: `Unexpected output from agent-browser: ${stdout.slice(0, 1000)}` - } - } - } - if (parsed.success) { - return { ok: true, result: parsed.data } - } - const message = parsed.error ?? 'Unknown browser error' - return { - ok: false, - error: { - code: classifyErrorCode(message), - message - } - } -} - -export class AgentBrowserBridge { - // Why: per-worktree active tab so one worktree's tab switch can't affect another's command targeting. - private readonly activeWebContentsPerWorktree = new Map() - private activeWebContentsId: number | null = null - private readonly sessions = new Map() - private readonly commandQueues = new Map() - private readonly processingQueues = new Set() - // Why: screenshot prep mutates shared paintability across tabs; serialize globally so concurrent captures don't blank each other. - private screenshotTurn: Promise = Promise.resolve() - private readonly agentBrowserBin: string - private readonly agentBrowserEnv: NodeJS.ProcessEnv - private readonly ownsAgentBrowserSocketDirectory: boolean - // Why: null when nothing bounds the daemon, so the bridge never guesses that one was replaced. - private readonly agentBrowserIdleTimeoutMs: number | null - // Why: stash intercept patterns from a swap-destroyed session, keyed by name, so the next session restores them. - private readonly pendingInterceptRestore = new Map() - // Why: promise-lock so two concurrent ensureSession calls don't both create the session entry. - private readonly pendingSessionCreation = new Map>() - // Why: `agent-browser close` is async, keyed by session name — recreating before it finishes lets the old teardown close the new session. - private readonly pendingSessionDestruction = new Map>() - private readonly cancelledProcesses = new WeakSet() - private shutdownStarted = false - - constructor( - private readonly browserManager: BrowserManager, - private readonly options: AgentBrowserBridgeOptions = {} - ) { - this.agentBrowserBin = resolveAgentBrowserBinary() - const processEnvironment = createAgentBrowserProcessEnvironment({ - inheritedEnv: process.env, - platform: process.platform, - userDataPath: app.getPath('userData') - }) - this.agentBrowserEnv = processEnvironment.env - this.ownsAgentBrowserSocketDirectory = processEnvironment.ownsSocketDirectory - const idleTimeoutMs = Number(this.agentBrowserEnv.AGENT_BROWSER_IDLE_TIMEOUT_MS) - this.agentBrowserIdleTimeoutMs = idleTimeoutMs > 0 ? idleTimeoutMs : null - } - - // ── Tab tracking ── - - setActiveTab(webContentsId: number, worktreeId?: string): void { - this.activeWebContentsId = webContentsId - if (worktreeId) { - this.activeWebContentsPerWorktree.set(worktreeId, webContentsId) - } - this.options.onTabsChanged?.(worktreeId) - } - - private selectFallbackActiveWebContents( - worktreeId: string, - excludedWebContentsId?: number - ): number | null { - for (const [, wcId] of this.getRegisteredTabs(worktreeId)) { - if (wcId === excludedWebContentsId) { - continue - } - if (this.getWebContents(wcId)) { - this.activeWebContentsPerWorktree.set(worktreeId, wcId) - return wcId - } - } - this.activeWebContentsPerWorktree.delete(worktreeId) - return null - } - - getActiveWebContentsId(): number | null { - return this.activeWebContentsId - } - - getPageInfo( - worktreeId?: string, - browserPageId?: string - ): { browserPageId: string; url: string; title: string } | null { - try { - const target = this.resolveCommandTarget(worktreeId, browserPageId) - const wc = this.getWebContents(target.webContentsId) - if (!wc) { - return null - } - return { - browserPageId: target.browserPageId, - url: wc.getURL() ?? '', - title: wc.getTitle() ?? '' - } - } catch { - return null - } - } - - onTabChanged(webContentsId: number, worktreeId?: string): void { - this.activeWebContentsId = webContentsId - if (worktreeId) { - this.activeWebContentsPerWorktree.set(worktreeId, webContentsId) - } - this.options.onTabsChanged?.(worktreeId) - } - - async onTabClosed(webContentsId: number): Promise { - const browserPageId = this.resolveTabIdSafe(webContentsId) - const owningWorktreeId = browserPageId - ? this.browserManager.getWorktreeIdForTab(browserPageId) - : undefined - let nextWorktreeActiveWebContentsId: number | null = null - if ( - owningWorktreeId && - this.activeWebContentsPerWorktree.get(owningWorktreeId) === webContentsId - ) { - nextWorktreeActiveWebContentsId = this.selectFallbackActiveWebContents( - owningWorktreeId, - webContentsId - ) - } - if (this.activeWebContentsId === webContentsId) { - this.activeWebContentsId = nextWorktreeActiveWebContentsId - } - if (browserPageId) { - await this.onPageClosed(browserPageId) - } - this.options.onTabsChanged?.(owningWorktreeId) - } - - /** - * Retire a page's daemon by page id. - * - * The headless offscreen backend owns pages by id and unregisters the guest - * itself, so `onTabClosed`'s webContentsId lookup can never resolve one — it - * has to say which page closed (#16367). - */ - async onPageClosed(browserPageId: string): Promise { - const sessionName = `${ORCA_TAB_SESSION_PREFIX}${browserPageId}` - await this.destroySession(sessionName) - this.pendingInterceptRestore.delete(sessionName) - } - - async onProcessSwap( - browserPageId: string, - newWebContentsId: number, - previousWebContentsId?: number - ): Promise { - // Why: an Electron process swap keeps browserPageId but gives a new webContentsId — destroy the session so the next command recreates it. - const sessionName = `${ORCA_TAB_SESSION_PREFIX}${browserPageId}` - const session = this.sessions.get(sessionName) - const oldWebContentsId = previousWebContentsId ?? session?.webContentsId - const owningWorktreeId = this.browserManager.getWorktreeIdForTab(browserPageId) - // Why: save intercept patterns before destroy so the new session can restore them after init. - if (session && session.activeInterceptPatterns.length > 0) { - this.pendingInterceptRestore.set(sessionName, [...session.activeInterceptPatterns]) - } - await this.destroySession(sessionName) - if (oldWebContentsId != null && this.activeWebContentsId === oldWebContentsId) { - this.activeWebContentsId = newWebContentsId - } - if ( - owningWorktreeId && - oldWebContentsId != null && - this.activeWebContentsPerWorktree.get(owningWorktreeId) === oldWebContentsId - ) { - this.activeWebContentsPerWorktree.set(owningWorktreeId, newWebContentsId) - } - this.options.onTabsChanged?.(owningWorktreeId ?? undefined) - } - - // ── Worktree-scoped tab queries ── - - getRegisteredTabs(worktreeId?: string): Map { - const all = this.browserManager.getWebContentsIdByTabId() - if (!worktreeId) { - return all - } - - const filtered = new Map() - for (const [tabId, wcId] of all) { - if (this.browserManager.getWorktreeIdForTab(tabId) === worktreeId) { - filtered.set(tabId, wcId) - } - } - return filtered - } - - // ── Tab management ── - - tabList(worktreeId?: string): BrowserTabListResult { - const tabs = this.getRegisteredTabs(worktreeId) - // Why: use the per-worktree active tab so listing matches command routing, but read-only — discovery must not mutate active-tab state. - let activeWcId = - (worktreeId && this.activeWebContentsPerWorktree.get(worktreeId)) ?? this.activeWebContentsId - const result: BrowserTabInfo[] = [] - let index = 0 - let firstLiveWcId: number | null = null - for (const [tabId, wcId] of tabs) { - const wc = this.getWebContents(wcId) - if (!wc) { - this.browserManager.unregisterGuest(tabId) - continue - } - if (firstLiveWcId === null) { - firstLiveWcId = wcId - } - const loadError = this.browserManager.getBrowserPageLoadError(tabId) - const certificateFailure = this.browserManager.getBrowserPageCertificateFailure(tabId) - result.push({ - browserPageId: tabId, - index: index++, - // Why: failed WebContents report chrome-error://, not the address the user asked to load. - url: loadError?.validatedUrl ?? wc.getURL() ?? '', - title: wc.getTitle() ?? '', - active: wcId === activeWcId, - loadError, - certificateFailure - }) - } - // Why: with no active tab yet, show the first live tab as active without mutating state — keeps `tab list` side-effect free. - if (activeWcId == null && firstLiveWcId !== null) { - activeWcId = firstLiveWcId - if (result.length > 0) { - result[0].active = true - } - } - return { tabs: result } - } - - // Why: route tab switch through the command queue so it can't race in-flight commands targeting the old tab. - async tabSwitch( - index: number | undefined, - worktreeId?: string, - browserPageId?: string - ): Promise { - return this.enqueueCommand(worktreeId, async () => { - const tabs = this.getRegisteredTabs(worktreeId) - // Why: queue delay can change the tab list before execution — recompute against live webContents so no vanished index is activated. - const liveEntries = [...tabs.entries()].filter(([, wcId]) => this.getWebContents(wcId)) - let switchedIndex = index ?? -1 - let resolvedPageId = browserPageId - if (resolvedPageId) { - switchedIndex = liveEntries.findIndex(([tabId]) => tabId === resolvedPageId) - } - if (switchedIndex < 0 || switchedIndex >= liveEntries.length) { - const targetLabel = - resolvedPageId != null ? `Browser page ${resolvedPageId}` : `Tab index ${index}` - throw new BrowserError( - 'browser_tab_not_found', - `${targetLabel} out of range (0-${liveEntries.length - 1})` - ) - } - const [tabId, wcId] = liveEntries[switchedIndex] - this.activeWebContentsId = wcId - // Why: resolveActiveTab prefers the per-worktree map, so update it or later commands keep routing to the old tab. - const owningWorktreeId = worktreeId ?? this.browserManager.getWorktreeIdForTab(tabId) - // Why: `tab switch --page` may omit --worktree, so still update the owning worktree's active slot for later scoped commands. - if (owningWorktreeId) { - this.activeWebContentsPerWorktree.set(owningWorktreeId, wcId) - } - this.options.onTabsChanged?.(owningWorktreeId ?? undefined) - return { switched: switchedIndex, browserPageId: tabId } - }) - } - - // ── Core commands (typed) ── - - async snapshot(worktreeId?: string, browserPageId?: string): Promise { - // Why: snapshot creates fresh refs so it must bypass the stale-ref guard - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName, target) => { - const result = (await this.execAgentBrowser(sessionName, [ - 'snapshot' - ])) as BrowserSnapshotResult - return { - ...result, - browserPageId: target.browserPageId - } - }) - } - - async click( - element: string, - worktreeId?: string, - browserPageId?: string - ): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - return (await this.execAgentBrowser(sessionName, ['click', element])) as BrowserClickResult - }) - } - - async dblclick( - element: string, - worktreeId?: string, - browserPageId?: string - ): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - return (await this.execAgentBrowser(sessionName, ['dblclick', element])) as BrowserClickResult - }) - } - - async goto(url: string, worktreeId?: string, browserPageId?: string): Promise { - return this.enqueueTargetedCommand( - worktreeId, - browserPageId, - async (_sessionName, target) => { - const wc = this.requireTargetWebContents(target) - const navigationUrl = normalizeBrowserNavigationUrl(url) - if (!navigationUrl) { - throw new BrowserError('invalid_argument', `Unsupported browser URL: ${url}`) - } - const navigationState: { preventUnloadEvent: Electron.Event | null } = { - preventUnloadEvent: null - } - const onWillPreventUnload = (event: Electron.Event): void => { - navigationState.preventUnloadEvent = event - } - wc.on('will-prevent-unload', onWillPreventUnload) - let navigationAborted = false - const navigationDeadline = Date.now() + EMBEDDED_NAVIGATION_TIMEOUT_MS - let navigationTimeout: ReturnType | null = null - try { - await Promise.race([ - wc.loadURL(navigationUrl), - new Promise((_resolve, reject) => { - navigationTimeout = setTimeout( - () => - reject( - new Error( - `Browser navigation timed out after ${EMBEDDED_NAVIGATION_TIMEOUT_MS}ms` - ) - ), - EMBEDDED_NAVIGATION_TIMEOUT_MS - ) - navigationTimeout.unref?.() - }) - ]) - } catch (error) { - if (navigationTimeout) { - clearTimeout(navigationTimeout) - navigationTimeout = null - } - if (!this.getWebContents(target.webContentsId)) { - throw this.createPageUnavailableError( - `${ORCA_TAB_SESSION_PREFIX}${target.browserPageId}` - ) - } - // Why: ERR_ABORTED also covers a page vetoing unload; that navigation did not succeed. - if ( - !isAbortedNavigationError(error) || - (navigationState.preventUnloadEvent !== null && - !navigationState.preventUnloadEvent.defaultPrevented) - ) { - throw new BrowserError( - 'browser_error', - `Failed to navigate browser page ${target.browserPageId}: ${error instanceof Error ? error.message : String(error)}` - ) - } - navigationAborted = true - // Why: a superseding navigation rejects the first load before its replacement has landed. - await waitForAbortedNavigationReplacement( - wc, - target.browserPageId, - Math.max(0, navigationDeadline - Date.now()) - ) - } finally { - wc.removeListener('will-prevent-unload', onWillPreventUnload) - if (navigationTimeout) { - clearTimeout(navigationTimeout) - } - } - - // Why: cross-process navigation can replace the guest while retaining the same authoritative page id. - const navigatedTarget = this.resolveCommandTarget(worktreeId, target.browserPageId) - const navigatedWebContents = this.requireTargetWebContents(navigatedTarget) - const loadError = navigationAborted - ? this.browserManager.getBrowserPageLoadError(target.browserPageId) - : null - if (loadError) { - throw new BrowserError( - 'browser_error', - `Failed to navigate browser page ${target.browserPageId}: ${loadError.description} (${loadError.code})` - ) - } - return { url: navigatedWebContents.getURL(), title: navigatedWebContents.getTitle() } - }, - { ensureSession: false } - ) - } - - async fill( - element: string, - value: string, - worktreeId?: string, - browserPageId?: string - ): Promise { - await assertClipboardTextWriteWithinLimitWithYield(value) - // Why: agent-browser's CDP text insertion loses focus in Electron guests; edit through the browser's input pipeline instead. - return this.enqueueTargetedCommand( - worktreeId, - browserPageId, - async (sessionName) => { - if (!(await this.isExplicitContentEditableTarget(sessionName, element))) { - await this.execAgentBrowser(sessionName, ['focus', element]) - await this.execAgentBrowser(sessionName, [ - 'eval', - focusedValueSetExpression(JSON.stringify('')) - ]) - for (const chunk of iterateBrowserTextInsertionChunks( - value, - AGENT_BROWSER_TEXT_ARGUMENT_MAX_BYTES - )) { - await this.execAgentBrowser(sessionName, [ - 'eval', - focusedValueSetExpression(JSON.stringify(chunk), { append: true }) - ]) - } - await this.execAgentBrowser(sessionName, [ - 'eval', - focusedValueSetExpression(JSON.stringify(''), { append: true, dispatchEvents: true }) - ]) - return { filled: element } as BrowserFillResult - } - - await this.fillExplicitContentEditable(sessionName, element, value) - return { filled: element } as BrowserFillResult - }, - { requireScopedTarget: true } - ) - } - - async type( - input: string, - worktreeId?: string, - browserPageId?: string - ): Promise { - await assertClipboardTextWriteWithinLimitWithYield(input) - return this.enqueueTargetedCommand( - worktreeId, - browserPageId, - async (sessionName) => { - for (const chunk of iterateBrowserTextInsertionChunks( - input, - AGENT_BROWSER_TEXT_ARGUMENT_MAX_BYTES - )) { - await this.execAgentBrowser(sessionName, ['keyboard', 'type', chunk]) - } - return { typed: true } as BrowserTypeResult - }, - { requireScopedTarget: true } - ) - } - - async select( - element: string, - value: string, - worktreeId?: string, - browserPageId?: string - ): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - return (await this.execAgentBrowser(sessionName, [ - 'select', - element, - value - ])) as BrowserSelectResult - }) - } - - async scroll( - direction: string, - amount?: number, - worktreeId?: string, - browserPageId?: string - ): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - const args = ['scroll', direction] - if (amount != null) { - args.push(String(amount)) - } - return (await this.execAgentBrowser(sessionName, args)) as BrowserScrollResult - }) - } - - async scrollIntoView( - element: string, - worktreeId?: string, - browserPageId?: string - ): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - return await this.execAgentBrowser(sessionName, ['scrollintoview', element]) - }) - } - - async get( - what: string, - selector?: string, - worktreeId?: string, - browserPageId?: string - ): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - const args = ['get', what] - if (selector) { - args.push(selector) - } - return await this.execAgentBrowser(sessionName, args) - }) - } - - async is( - what: string, - selector: string, - worktreeId?: string, - browserPageId?: string - ): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - return await this.execAgentBrowser(sessionName, ['is', what, selector]) - }) - } - - // ── Keyboard commands ── - - async keyboardInsertText( - text: string, - worktreeId?: string, - browserPageId?: string - ): Promise { - await assertClipboardTextWriteWithinLimitWithYield(text) - return this.enqueueTargetedCommand( - worktreeId, - browserPageId, - async (sessionName) => { - let result: unknown = { inserted: true } - for (const chunk of iterateBrowserTextInsertionChunks( - text, - AGENT_BROWSER_TEXT_ARGUMENT_MAX_BYTES - )) { - result = await this.execAgentBrowser(sessionName, ['keyboard', 'inserttext', chunk]) - } - return result - }, - { requireScopedTarget: true } - ) - } - - // ── Mouse commands ── - - async mouseMove( - x: number, - y: number, - worktreeId?: string, - browserPageId?: string - ): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - return await this.execAgentBrowser(sessionName, ['mouse', 'move', String(x), String(y)]) - }) - } - - async mouseDown(button?: string, worktreeId?: string, browserPageId?: string): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - const args = ['mouse', 'down'] - if (button) { - args.push(button) - } - return await this.execAgentBrowser(sessionName, args) - }) - } - - async mouseClick( - x: number, - y: number, - button?: string, - worktreeId?: string, - browserPageId?: string, - radius?: number, - modifiers?: BrowserMouseModifier[] - ): Promise { - return this.enqueueTargetedCommand( - worktreeId, - browserPageId, - async (_sessionName, target) => { - const wc = this.getWebContents(target.webContentsId) - if (!wc || wc.isDestroyed()) { - throw new BrowserError( - 'browser_tab_not_found', - `Browser page ${target.browserPageId} is no longer available` - ) - } - const cdpButton = normalizeCdpMouseButton(button) - const buttons = cdpMouseButtonMask(cdpButton) - const cdpModifiers = cdpMouseModifierMask(modifiers) - const lease = acquireElectronDebugger(wc) - try { - wc.focus() - const point = - cdpButton === 'left' - ? // Why: DOM activation can't carry Cmd/Ctrl/Alt/Shift, so modifier clicks use the adjusted point and let CDP dispatch the event. - await resolveMobileTouchClickPoint(wc.debugger, x, y, radius, cdpModifiers === 0) - : { x, y, adjusted: false, handled: false } - // Why: land the tap as one atomic op — separate move/down/up CLI calls visibly hover and can miss small controls. - // Why: mobile-emulated BrowserViews can ignore CDP mouse clicks, so the runtime may already have activated DOM controls. - if (!point.handled) { - await wc.debugger.sendCommand('Input.dispatchMouseEvent', { - type: 'mousePressed', - x: point.x, - y: point.y, - button: cdpButton, - buttons, - modifiers: cdpModifiers, - clickCount: 1 - }) - await wc.debugger.sendCommand('Input.dispatchMouseEvent', { - type: 'mouseReleased', - x: point.x, - y: point.y, - button: cdpButton, - buttons: 0, - modifiers: cdpModifiers, - clickCount: 1 - }) - } - return { - clicked: { - x: point.x, - y: point.y, - button: cdpButton, - adjusted: point.adjusted, - handled: point.handled - } - } - } finally { - lease.release() - } - }, - { ensureSession: false } - ) - } - - async mouseUp(button?: string, worktreeId?: string, browserPageId?: string): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - const args = ['mouse', 'up'] - if (button) { - args.push(button) - } - return await this.execAgentBrowser(sessionName, args) - }) - } - - async mouseWheel( - dy: number, - dx?: number, - worktreeId?: string, - browserPageId?: string - ): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - const args = ['mouse', 'wheel', String(dy)] - if (dx != null) { - args.push(String(dx)) - } - return await this.execAgentBrowser(sessionName, args) - }) - } - - // ── Find (semantic locators) ── - - async find( - locator: string, - value: string, - action: string, - text?: string, - worktreeId?: string, - browserPageId?: string - ): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - const args = ['find', locator, value, action] - if (text) { - args.push(text) - } - return await this.execAgentBrowser(sessionName, args) - }) - } - - // ── Set commands ── - - async setDevice(name: string, worktreeId?: string, browserPageId?: string): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - return await this.execAgentBrowser(sessionName, ['set', 'device', name]) - }) - } - - async setOffline(state?: string, worktreeId?: string, browserPageId?: string): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - const args = ['set', 'offline'] - if (state) { - args.push(state) - } - return await this.execAgentBrowser(sessionName, args) - }) - } - - async setHeaders( - headersJson: string, - worktreeId?: string, - browserPageId?: string - ): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - return await this.execAgentBrowser(sessionName, ['set', 'headers', headersJson]) - }) - } - - async setCredentials( - user: string, - pass: string, - worktreeId?: string, - browserPageId?: string - ): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - return await this.execAgentBrowser(sessionName, ['set', 'credentials', user, pass]) - }) - } - - async setMedia( - colorScheme?: string, - reducedMotion?: string, - worktreeId?: string, - browserPageId?: string - ): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - const args = ['set', 'media'] - if (colorScheme) { - args.push(colorScheme) - } - if (reducedMotion) { - args.push(reducedMotion) - } - return await this.execAgentBrowser(sessionName, args) - }) - } - - // ── Clipboard commands ── - - async clipboardRead(worktreeId?: string, browserPageId?: string): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - return await this.execAgentBrowser(sessionName, ['clipboard', 'read']) - }) - } - - async clipboardWrite( - text: string, - worktreeId?: string, - browserPageId?: string - ): Promise { - await assertClipboardTextWriteWithinLimitWithYield(text, { - maxBytes: AGENT_BROWSER_CLIPBOARD_WRITE_MAX_BYTES - }) - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - return await this.execAgentBrowser(sessionName, ['clipboard', 'write', text]) - }) - } - - // ── Dialog commands ── - - async dialogAccept(text?: string, worktreeId?: string, browserPageId?: string): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - const args = ['dialog', 'accept'] - if (text) { - args.push(text) - } - return await this.execAgentBrowser(sessionName, args) - }) - } - - async dialogDismiss(worktreeId?: string, browserPageId?: string): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - return await this.execAgentBrowser(sessionName, ['dialog', 'dismiss']) - }) - } - - // ── Storage commands ── - - async storageLocalGet( - key: string, - worktreeId?: string, - browserPageId?: string - ): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - return await this.execAgentBrowser(sessionName, ['storage', 'local', 'get', key]) - }) - } - - async storageLocalSet( - key: string, - value: string, - worktreeId?: string, - browserPageId?: string - ): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - return await this.execAgentBrowser(sessionName, ['storage', 'local', 'set', key, value]) - }) - } - - async storageLocalClear(worktreeId?: string, browserPageId?: string): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - return await this.execAgentBrowser(sessionName, ['storage', 'local', 'clear']) - }) - } - - async storageSessionGet( - key: string, - worktreeId?: string, - browserPageId?: string - ): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - return await this.execAgentBrowser(sessionName, ['storage', 'session', 'get', key]) - }) - } - - async storageSessionSet( - key: string, - value: string, - worktreeId?: string, - browserPageId?: string - ): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - return await this.execAgentBrowser(sessionName, ['storage', 'session', 'set', key, value]) - }) - } - - async storageSessionClear(worktreeId?: string, browserPageId?: string): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - return await this.execAgentBrowser(sessionName, ['storage', 'session', 'clear']) - }) - } - - // ── Download command ── - - async download( - selector: string, - path: string, - worktreeId?: string, - browserPageId?: string - ): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - return await this.execAgentBrowser(sessionName, ['download', selector, path]) - }) - } - - // ── Highlight command ── - - async highlight(selector: string, worktreeId?: string, browserPageId?: string): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - return await this.execAgentBrowser(sessionName, ['highlight', selector]) - }) - } - - async back(worktreeId?: string, browserPageId?: string): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - return (await this.execAgentBrowser(sessionName, ['back'])) as BrowserBackResult - }) - } - - async forward(worktreeId?: string, browserPageId?: string): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - return (await this.execAgentBrowser(sessionName, ['forward'])) as BrowserBackResult - }) - } - - async reload(worktreeId?: string, browserPageId?: string): Promise { - // Why: reload can trigger an Electron process swap that destroys the session mid-command — reload via webContents directly instead. - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (_sessionName, target) => { - const wc = this.getWebContents(target.webContentsId) - if (!wc) { - throw new BrowserError('browser_no_tab', 'Tab is no longer available') - } - wc.reload() - await new Promise((resolve) => { - let settled = false - let fallbackTimer: ReturnType | null = null - - const finish = (): void => { - if (settled) { - return - } - settled = true - wc.removeListener('did-finish-load', onFinish) - wc.removeListener('did-fail-load', onFail) - if (fallbackTimer) { - clearTimeout(fallbackTimer) - fallbackTimer = null - } - resolve() - } - const onFinish = (): void => finish() - const onFail = (): void => finish() - - wc.on('did-finish-load', onFinish) - wc.on('did-fail-load', onFail) - // Why: clear the fallback timer on load; otherwise each reload leaks the webContents + listeners until the 10s timeout. - fallbackTimer = setTimeout(finish, 10_000) - if (typeof fallbackTimer.unref === 'function') { - fallbackTimer.unref() - } - }) - return { url: wc.getURL(), title: wc.getTitle() } - }) - } - - async screenshot( - format?: string, - worktreeId?: string, - browserPageId?: string - ): Promise { - // Why: agent-browser writes the screenshot to a temp file and returns its path; read it and return base64. - return this.enqueueTargetedCommand( - worktreeId, - browserPageId, - async (sessionName) => { - return this.captureScreenshotCommand(sessionName, ['screenshot'], 300, format) - }, - { ensureVisible: false } - ) - } - - async fullPageScreenshot( - format?: string, - worktreeId?: string, - browserPageId?: string - ): Promise { - return this.enqueueTargetedCommand( - worktreeId, - browserPageId, - async (sessionName, target) => { - return this.captureFullPageScreenshotCommand( - sessionName, - target.webContentsId, - 500, - format === 'jpeg' ? 'jpeg' : 'png' - ) - }, - { ensureVisible: false } - ) - } - - private readScreenshotFromResult(raw: unknown, format?: string): BrowserScreenshotResult { - const parsed = raw as { path?: string } | undefined - if (!parsed?.path) { - throw new BrowserError('browser_error', 'Screenshot returned no file path') - } - if (!existsSync(parsed.path)) { - throw new BrowserError('browser_error', `Screenshot file not found: ${parsed.path}`) - } - const data = readFileSync(parsed.path).toString('base64') - return { data, format: format === 'jpeg' ? 'jpeg' : 'png' } as BrowserScreenshotResult - } - - private async captureScreenshotCommand( - sessionName: string, - commandArgs: string[], - settleMs: number, - format?: string - ): Promise { - return this.withSerializedScreenshotAccess(async () => { - const session = this.sessions.get(sessionName) - const restore = session - ? await this.browserManager.acquireAutomationVisibility(session.webContentsId) - : () => {} - try { - // Why: let the compositor settle to a painted frame after the lease, inside the screenshot lock so another tab can't change lease state first. - await new Promise((r) => setTimeout(r, settleMs)) - const raw = await this.execAgentBrowser(sessionName, commandArgs) - return this.readScreenshotFromResult(raw, format) - } finally { - restore() - } - }) - } - - private async captureFullPageScreenshotCommand( - sessionName: string, - webContentsId: number, - settleMs: number, - format: 'png' | 'jpeg' - ): Promise { - return this.withSerializedScreenshotAccess(async () => { - const session = this.sessions.get(sessionName) - const restore = session - ? await this.browserManager.acquireAutomationVisibility(session.webContentsId) - : () => {} - try { - // Why: the guest compositor needs a beat to paint a fresh frame after becoming paintable, or CDP captures a stale surface. - await new Promise((r) => setTimeout(r, settleMs)) - const wc = this.getWebContents(webContentsId) - if (!wc) { - throw new BrowserError('browser_tab_not_found', 'Tab is no longer available') - } - return await captureFullPageScreenshot(wc, format) - } catch (error) { - throw new BrowserError('browser_error', (error as Error).message) - } finally { - restore() - } - }) - } - - private async withSerializedScreenshotAccess(execute: () => Promise): Promise { - const previousTurn = this.screenshotTurn.catch(() => {}) - let releaseTurn!: () => void - this.screenshotTurn = new Promise((resolve) => { - releaseTurn = resolve - }) - await previousTurn - try { - return await execute() - } finally { - releaseTurn() - } - } - - async evaluate( - expression: string, - worktreeId?: string, - browserPageId?: string - ): Promise { - return this.enqueueTargetedCommand( - worktreeId, - browserPageId, - async (_sessionName, target) => { - const wc = this.requireTargetWebContents(target) - let releaseDebugger = (): void => {} - try { - releaseDebugger = acquireElectronDebugger(wc).release - const { result, exceptionDetails } = (await wc.debugger.sendCommand('Runtime.evaluate', { - expression, - returnByValue: true, - awaitPromise: true - })) as { - result: { value?: unknown; description?: string } - exceptionDetails?: { text: string; exception?: { description?: string } } - } - if (exceptionDetails) { - throw new BrowserError( - 'browser_eval_error', - exceptionDetails.exception?.description ?? exceptionDetails.text - ) - } - - const currentTarget = this.resolveCommandTarget(worktreeId, target.browserPageId) - if (currentTarget.webContentsId !== target.webContentsId) { - throw new BrowserError( - 'browser_tab_changed', - `Browser page ${target.browserPageId} changed while evaluating; retry the command` - ) - } - return { - result: - result.value !== undefined - ? typeof result.value === 'object' && result.value !== null - ? JSON.stringify(result.value) - : String(result.value) - : (result.description ?? ''), - origin: wc.getURL() - } - } catch (error) { - if (error instanceof BrowserError) { - throw error - } - if (!this.getWebContents(target.webContentsId)) { - throw this.createPageUnavailableError( - `${ORCA_TAB_SESSION_PREFIX}${target.browserPageId}` - ) - } - throw new BrowserError( - 'browser_error', - `Failed to evaluate in browser page ${target.browserPageId}: ${error instanceof Error ? error.message : String(error)}` - ) - } finally { - releaseDebugger() - } - }, - { ensureSession: false } - ) - } - - async hover( - element: string, - worktreeId?: string, - browserPageId?: string - ): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - return (await this.execAgentBrowser(sessionName, ['hover', element])) as BrowserHoverResult - }) - } - - async drag( - from: string, - to: string, - worktreeId?: string, - browserPageId?: string - ): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - return (await this.execAgentBrowser(sessionName, ['drag', from, to])) as BrowserDragResult - }) - } - - async upload( - element: string, - filePaths: string[], - worktreeId?: string, - browserPageId?: string - ): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - return (await this.execAgentBrowser(sessionName, [ - 'upload', - element, - ...filePaths - ])) as BrowserUploadResult - }) - } - - async wait( - options?: { - selector?: string - timeout?: number - text?: string - url?: string - load?: string - fn?: string - state?: string - }, - worktreeId?: string, - browserPageId?: string - ): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - const args = ['wait'] - const hasCondition = - !!options?.selector || !!options?.text || !!options?.url || !!options?.load || !!options?.fn - if (options?.selector) { - args.push(options.selector) - } else if (options?.timeout != null && !hasCondition) { - args.push(String(options.timeout)) - } - if (options?.text) { - args.push('--text', options.text) - } - if (options?.url) { - args.push('--url', options.url) - } - if (options?.load) { - args.push('--load', options.load) - } - if (options?.fn) { - args.push('--fn', options.fn) - } - const normalizedState = options?.state === 'visible' ? undefined : options?.state - if (normalizedState) { - args.push('--state', normalizedState) - } - // Why: agent-browser's selector wait lacks a per-command timeout — enforce it here so a missing selector fails as browser_timeout, not a hang. - return (await this.execAgentBrowser(sessionName, args, { - timeoutMs: - options?.timeout != null && hasCondition - ? options.timeout + WAIT_PROCESS_TIMEOUT_GRACE_MS - : undefined, - timeoutError: - options?.timeout != null && hasCondition - ? new BrowserError( - 'browser_timeout', - `Timed out waiting for browser condition after ${options.timeout}ms.` - ) - : undefined - })) as BrowserWaitResult - }) - } - - async check( - element: string, - checked: boolean, - worktreeId?: string, - browserPageId?: string - ): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - const args = checked ? ['check', element] : ['uncheck', element] - return (await this.execAgentBrowser(sessionName, args)) as BrowserCheckResult - }) - } - - async focus( - element: string, - worktreeId?: string, - browserPageId?: string - ): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - return (await this.execAgentBrowser(sessionName, ['focus', element])) as BrowserFocusResult - }) - } - - async clear( - element: string, - worktreeId?: string, - browserPageId?: string - ): Promise { - return this.enqueueTargetedCommand( - worktreeId, - browserPageId, - async (sessionName) => { - if (!(await this.isExplicitContentEditableTarget(sessionName, element))) { - // Why: agent-browser resolves the ref directly, preserving iframe/shadow-root/unfocusable semantics for ordinary fields. - await this.execAgentBrowser(sessionName, ['fill', element, '']) - return { cleared: element } - } - - await this.fillExplicitContentEditable(sessionName, element, '') - return { cleared: element } - }, - { requireScopedTarget: true } - ) - } - - async selectAll( - element: string, - worktreeId?: string, - browserPageId?: string - ): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - // Why: agent-browser has no select-all command — implement as focus + Ctrl+A - await this.execAgentBrowser(sessionName, ['focus', element]) - return (await this.execAgentBrowser(sessionName, [ - 'press', - 'Control+a' - ])) as BrowserSelectAllResult - }) - } - - async keypress( - key: string, - worktreeId?: string, - browserPageId?: string - ): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - return (await this.execAgentBrowser(sessionName, ['press', key])) as BrowserKeypressResult - }) - } - - async pdf(worktreeId?: string, browserPageId?: string): Promise { - // Why: agent-browser's CDP printToPDF hangs in Electron webviews — use the native webContents.printToPDF(). - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (_sessionName, target) => { - const wc = this.getWebContents(target.webContentsId) - if (!wc) { - throw new BrowserError('browser_no_tab', 'Tab is no longer available') - } - const buffer = await wc.printToPDF({ - printBackground: true, - preferCSSPageSize: true - }) - return { data: buffer.toString('base64') } - }) - } - - // ── Cookie commands ── - - async cookieGet( - _url?: string, - worktreeId?: string, - browserPageId?: string - ): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - return (await this.execAgentBrowser(sessionName, [ - 'cookies', - 'get' - ])) as BrowserCookieGetResult - }) - } - - async cookieSet( - cookie: Partial, - worktreeId?: string, - browserPageId?: string - ): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - const args = ['cookies', 'set', cookie.name ?? '', cookie.value ?? ''] - if (cookie.domain) { - args.push('--domain', cookie.domain) - } - if (cookie.path) { - args.push('--path', cookie.path) - } - if (cookie.secure) { - args.push('--secure') - } - if (cookie.httpOnly) { - args.push('--httpOnly') - } - if (cookie.sameSite) { - args.push('--sameSite', cookie.sameSite) - } - if (cookie.expires != null) { - args.push('--expires', String(cookie.expires)) - } - return (await this.execAgentBrowser(sessionName, args)) as BrowserCookieSetResult - }) - } - - async cookieDelete( - name?: string, - domain?: string, - _url?: string, - worktreeId?: string, - browserPageId?: string - ): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - const args = ['cookies', 'clear'] - if (name) { - args.push('--name', name) - } - if (domain) { - args.push('--domain', domain) - } - return (await this.execAgentBrowser(sessionName, args)) as BrowserCookieDeleteResult - }) - } - - // ── Viewport / emulation commands ── - - async setViewport( - width: number, - height: number, - scale = 1, - mobile = false, - worktreeId?: string, - browserPageId?: string - ): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (_sessionName, target) => { - const wc = this.getWebContents(target.webContentsId) - if (!wc) { - throw new BrowserError('browser_tab_not_found', 'Tab is no longer available') - } - const dbg = wc.debugger - if (!dbg.isAttached()) { - throw new BrowserError('browser_error', 'Debugger not attached') - } - - // Why: agent-browser's `set viewport` has no `mobile` flag, so apply the emulation directly via CDP to honor Orca's --mobile. - await dbg.sendCommand('Emulation.setDeviceMetricsOverride', { - width, - height, - deviceScaleFactor: scale, - mobile - }) - // Why: BrowserView's compositor can keep the old host size after a metrics-only resize, cropping remote screencast clients. - await Promise.resolve(dbg.sendCommand('Emulation.setVisibleSize', { width, height })).catch( - () => {} - ) - - return { - width, - height, - deviceScaleFactor: scale, - mobile - } - }) - } - - async setGeolocation( - lat: number, - lon: number, - _accuracy?: number, - worktreeId?: string, - browserPageId?: string - ): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - return (await this.execAgentBrowser(sessionName, [ - 'set', - 'geo', - String(lat), - String(lon) - ])) as BrowserGeolocationResult - }) - } - - // ── Network interception commands ── - - async interceptEnable( - patterns?: string[], - worktreeId?: string, - browserPageId?: string - ): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - // Why: agent-browser uses "network route " to intercept. Route each pattern individually. - const urlPattern = patterns?.[0] ?? '**/*' - const args = ['network', 'route', urlPattern] - const result = (await this.execAgentBrowser( - sessionName, - args - )) as BrowserInterceptEnableResult - const session = this.sessions.get(sessionName) - if (session) { - this.pendingInterceptRestore.delete(sessionName) - session.activeInterceptPatterns = patterns ?? ['*'] - } - return result - }) - } - - async interceptDisable( - worktreeId?: string, - browserPageId?: string - ): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - const result = (await this.execAgentBrowser(sessionName, [ - 'network', - 'unroute' - ])) as BrowserInterceptDisableResult - const session = this.sessions.get(sessionName) - if (session) { - this.pendingInterceptRestore.delete(sessionName) - session.activeInterceptPatterns = [] - } - return result - }) - } - - async interceptList( - worktreeId?: string, - browserPageId?: string - ): Promise<{ requests: unknown[] }> { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - return (await this.execAgentBrowser(sessionName, ['network', 'requests'])) as { - requests: unknown[] - } - }) - } - - // TODO: Add interceptContinue/interceptBlock once agent-browser supports per-request decisions, not just URL-pattern routing. - - // ── Capture commands ── - - async captureStart( - worktreeId?: string, - browserPageId?: string - ): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - const result = (await this.execAgentBrowser(sessionName, [ - 'network', - 'har', - 'start' - ])) as BrowserCaptureStartResult - const session = this.sessions.get(sessionName) - if (session) { - session.activeCapture = true - } - return result - }) - } - - async captureStop( - worktreeId?: string, - browserPageId?: string - ): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - const result = (await this.execAgentBrowser(sessionName, [ - 'network', - 'har', - 'stop' - ])) as BrowserCaptureStopResult - const session = this.sessions.get(sessionName) - if (session) { - session.activeCapture = false - } - return result - }) - } - - async consoleLog( - _limit?: number, - worktreeId?: string, - browserPageId?: string - ): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - return (await this.execAgentBrowser(sessionName, ['console'])) as BrowserConsoleResult - }) - } - - async networkLog( - _limit?: number, - worktreeId?: string, - browserPageId?: string - ): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - return (await this.execAgentBrowser(sessionName, [ - 'network', - 'requests' - ])) as BrowserNetworkLogResult - }) - } - - // ── Generic passthrough ── - - async exec(command: string, worktreeId?: string, browserPageId?: string): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - // Why: strip target/session flags from passthrough so a caller can't override Orca's selected page or CDP proxy. - const args = stripAgentBrowserTargetArgs(parseShellArgs(command.trim())) - return await this.execAgentBrowser(sessionName, args) - }) - } - - // ── Session lifecycle ── - - // Why: a previous run that crashed or was SIGKILL'd left one daemon per open tab with - // nobody holding its name — closeStaleAgentBrowserSession only resets a name being reused. - async sweepOrphanedSessions(): Promise { - return sweepOrphanedAgentBrowserSessions({ - binaryPath: this.agentBrowserBin, - env: this.agentBrowserEnv, - ownsSocketDirectory: this.ownsAgentBrowserSocketDirectory, - isSessionLive: (sessionName) => - this.sessions.has(sessionName) || this.pendingSessionCreation.has(sessionName) - }) - } - - async destroyAllSessions(options?: AgentBrowserCleanupOptions): Promise { - this.shutdownStarted = true - // Why the union: a session still being created has already spawned its daemon but is not in - // `sessions` yet, so closing only `sessions` lets that daemon outlive the quit (#16367). - const sessionNames = new Set([ - ...this.sessions.keys(), - ...this.pendingSessionCreation.keys(), - ...this.pendingSessionDestruction.keys() - ]) - await mapSettledWithConcurrency( - [...sessionNames], - AGENT_BROWSER_CLEANUP_CONCURRENCY, - (sessionName) => this.destroySession(sessionName, options) - ) - this.pendingInterceptRestore.clear() - } - - // ── Internal ── - - private async enqueueCommand( - worktreeId: string | undefined, - execute: (sessionName: string) => Promise - ): Promise { - return this.enqueueTargetedCommand( - worktreeId, - undefined, - async (sessionName) => execute(sessionName), - { ensureVisible: false } - ) - } - - private async enqueueTargetedCommand( - worktreeId: string | undefined, - browserPageId: string | undefined, - execute: (sessionName: string, target: ResolvedBrowserCommandTarget) => Promise, - options: EnqueueTargetedCommandOptions = {} - ): Promise { - this.assertCommandAdmission() - const target = this.resolveCommandTarget(worktreeId, browserPageId, options.requireScopedTarget) - const sessionName = `${ORCA_TAB_SESSION_PREFIX}${target.browserPageId}` - - if (options.ensureSession !== false) { - await this.ensureSession(sessionName, target.browserPageId, target.webContentsId) - } - this.assertCommandAdmission() - - return new Promise((resolve, reject) => { - let queue = this.commandQueues.get(sessionName) - if (!queue) { - queue = [] - this.commandQueues.set(sessionName, queue) - } - queue.push({ - execute: (() => - this.executeWithVisibleTarget( - sessionName, - worktreeId, - target, - execute, - options - )) as () => Promise, - resolve: resolve as (value: unknown) => void, - reject - }) - this.processQueue(sessionName) - }) - } - - private async executeWithVisibleTarget( - sessionName: string, - worktreeId: string | undefined, - target: ResolvedBrowserCommandTarget, - execute: (sessionName: string, target: ResolvedBrowserCommandTarget) => Promise, - options: EnqueueTargetedCommandOptions - ): Promise { - if (options.ensureVisible === false) { - return execute(sessionName, target) - } - - // Why: inactive panes are display:none; the automation lease makes only this target paintable without selecting it. - const restore = await this.browserManager.acquireAutomationVisibility(target.webContentsId) - try { - const visibleTarget = await this.refreshTargetAfterAutomationVisibility( - sessionName, - worktreeId, - target, - options - ) - return await execute(sessionName, visibleTarget) - } finally { - restore() - } - } - - private async refreshTargetAfterAutomationVisibility( - sessionName: string, - worktreeId: string | undefined, - target: ResolvedBrowserCommandTarget, - options: EnqueueTargetedCommandOptions - ): Promise { - const visibleTarget = this.resolveCommandTarget(worktreeId, target.browserPageId) - if (visibleTarget.webContentsId === target.webContentsId) { - return visibleTarget - } - - if (this.activeWebContentsId === target.webContentsId) { - this.activeWebContentsId = visibleTarget.webContentsId - } - if (worktreeId && this.activeWebContentsPerWorktree.get(worktreeId) === target.webContentsId) { - this.activeWebContentsPerWorktree.set(worktreeId, visibleTarget.webContentsId) - } - - // Why: making a parked webview paintable can re-register the page with a new guest webContents; tear down the stale session. - await this.restartSessionForTarget( - sessionName, - visibleTarget.browserPageId, - visibleTarget.webContentsId, - { recreate: options.ensureSession !== false } - ) - - return visibleTarget - } - - private async processQueue(sessionName: string): Promise { - if (this.processingQueues.has(sessionName)) { - return - } - this.processingQueues.add(sessionName) - - const queue = this.commandQueues.get(sessionName) - while (queue && queue.length > 0) { - const cmd = queue.shift()! - try { - const result = await cmd.execute() - cmd.resolve(result) - } catch (error) { - cmd.reject(error) - } - } - - if (queue && queue.length === 0 && this.commandQueues.get(sessionName) === queue) { - this.commandQueues.delete(sessionName) - } - this.processingQueues.delete(sessionName) - } - - getActivePageId(worktreeId?: string, browserPageId?: string): string | null { - try { - return this.resolveCommandTarget(worktreeId, browserPageId).browserPageId - } catch { - return null - } - } - - private resolveCommandTarget( - worktreeId?: string, - browserPageId?: string, - requireScopedTarget = false - ): ResolvedBrowserCommandTarget { - if (!browserPageId) { - return requireScopedTarget - ? this.resolveScopedActiveTab(worktreeId) - : this.resolveActiveTab(worktreeId) - } - - const tabs = this.getRegisteredTabs(worktreeId) - const webContentsId = tabs.get(browserPageId) - if (webContentsId == null) { - const scope = worktreeId ? ' in this worktree' : '' - throw new BrowserError( - 'browser_tab_not_found', - `Browser page ${browserPageId} was not found${scope}` - ) - } - - if (!this.getWebContents(webContentsId)) { - this.browserManager.unregisterGuest(browserPageId) - throw new BrowserError( - 'browser_tab_not_found', - `Browser page ${browserPageId} is no longer available` - ) - } - - return { browserPageId, webContentsId } - } - - private resolveActiveTab(worktreeId?: string): ResolvedBrowserCommandTarget { - const tabs = this.getRegisteredTabs(worktreeId) - - if (tabs.size === 0) { - throw new BrowserError('browser_no_tab', 'No browser tab open in this worktree') - } - - // Why: prefer per-worktree active tab to avoid cross-worktree interference; fall back to global for callers without worktreeId. - const preferredWcId = - (worktreeId && this.activeWebContentsPerWorktree.get(worktreeId)) ?? this.activeWebContentsId - - if (preferredWcId != null) { - for (const [tabId, wcId] of tabs) { - if (wcId === preferredWcId && this.getWebContents(wcId)) { - return { browserPageId: tabId, webContentsId: wcId } - } - if (wcId === preferredWcId) { - this.browserManager.unregisterGuest(tabId) - if (this.activeWebContentsId === wcId) { - this.activeWebContentsId = null - } - if (worktreeId && this.activeWebContentsPerWorktree.get(worktreeId) === wcId) { - this.activeWebContentsPerWorktree.delete(worktreeId) - } - } - } - } - - // Why: persisted state can leave ghost tabs (dead webContents); skip them and activate the first live tab for consistency. - for (const [tabId, wcId] of tabs) { - if (this.getWebContents(wcId)) { - this.activeWebContentsId = wcId - if (worktreeId) { - this.activeWebContentsPerWorktree.set(worktreeId, wcId) - } - return { browserPageId: tabId, webContentsId: wcId } - } - this.browserManager.unregisterGuest(tabId) - } - - throw new BrowserError( - 'browser_no_tab', - 'No live browser tab available — all registered tabs have been destroyed' - ) - } - - // Why: don't fall back to the global tab for text mutation — it could inject into another worktree's foreground webview and steal focus. - private resolveScopedActiveTab(worktreeId?: string): ResolvedBrowserCommandTarget { - if (worktreeId) { - return this.resolveActiveTab(worktreeId) - } - - const worktreesWithLiveTabs = new Set() - for (const [tabId, wcId] of this.getRegisteredTabs(undefined)) { - if (this.getWebContents(wcId)) { - worktreesWithLiveTabs.add(this.browserManager.getWorktreeIdForTab(tabId)) - } - } - - if (worktreesWithLiveTabs.size === 0) { - throw new BrowserError('browser_no_tab', 'No browser tab open in this worktree') - } - if (worktreesWithLiveTabs.size > 1) { - throw new BrowserError( - 'browser_target_ambiguous', - 'Multiple worktrees have browser tabs open; pass --worktree to target text insertion safely' - ) - } - - const [onlyWorktreeId] = worktreesWithLiveTabs - return this.resolveActiveTab(onlyWorktreeId) - } - - private async ensureSession( - sessionName: string, - browserPageId: string, - webContentsId: number - ): Promise { - const pendingDestruction = this.pendingSessionDestruction.get(sessionName) - if (pendingDestruction) { - await pendingDestruction - } - this.assertCommandAdmission() - - if (this.sessions.has(sessionName)) { - return - } - - // Why: without this lock, two concurrent calls both create proxies and the second leaks the first's server/debugger. - const pending = this.pendingSessionCreation.get(sessionName) - if (pending) { - await pending - this.assertCommandAdmission() - return - } - - const createSession = async (): Promise => { - const wc = this.getWebContents(webContentsId) - if (!wc) { - // Why: the webview can be destroyed between target resolution and session creation — keep the same closed-tab error shape. - throw new BrowserError( - 'browser_tab_not_found', - `Browser page ${browserPageId} is no longer available` - ) - } - - // Why: the daemon persists sessions (incl. CDP port) across restarts; close the stale one first or it ignores --cdp and hits the dead port. - await this.closeStaleAgentBrowserSession(sessionName) - - const proxy = new CdpWsProxy(wc) - const cdpEndpoint = await proxy.start() - - this.sessions.set(sessionName, { - proxy, - cdpEndpoint, - initialized: false, - consecutiveTimeouts: 0, - activeInterceptPatterns: [], - activeCapture: false, - lastCommandAt: Date.now(), - webContentsId, - activeProcess: null - }) - } - - const promise = createSession() - this.pendingSessionCreation.set(sessionName, promise) - try { - await promise - } finally { - this.pendingSessionCreation.delete(sessionName) - } - } - - private async restartSessionForTarget( - sessionName: string, - browserPageId: string, - webContentsId: number, - options: { recreate: boolean } = { recreate: true } - ): Promise { - const pendingCreation = this.pendingSessionCreation.get(sessionName) - if (pendingCreation) { - await pendingCreation.catch(() => {}) - } - - const session = this.sessions.get(sessionName) - if (session) { - if (session.activeInterceptPatterns.length > 0) { - this.pendingInterceptRestore.set(sessionName, [...session.activeInterceptPatterns]) - } - this.sessions.delete(sessionName) - this.pendingSessionCreation.delete(sessionName) - if (session.activeProcess) { - this.cancelledProcesses.add(session.activeProcess) - try { - session.activeProcess.kill() - } catch { - // Process may already be exiting. - } - session.activeProcess = null - } - - const destroy = (async (): Promise => { - try { - await this.runAgentBrowserRaw(sessionName, ['--session', sessionName, 'close'], { - timeoutMs: AGENT_BROWSER_CLEANUP_TIMEOUT_MS - }) - } catch { - // Session may already be dead. - } - await session.proxy.stop() - })() - this.pendingSessionDestruction.set(sessionName, destroy) - try { - await destroy - } finally { - this.pendingSessionDestruction.delete(sessionName) - } - } - - if (options.recreate) { - await this.ensureSession(sessionName, browserPageId, webContentsId) - } - } - - private async destroySession( - sessionName: string, - options: AgentBrowserCleanupOptions = { closeTimeoutMs: AGENT_BROWSER_CLEANUP_TIMEOUT_MS } - ): Promise { - const pendingDestruction = this.pendingSessionDestruction.get(sessionName) - if (pendingDestruction) { - await pendingDestruction - return - } - - const pendingCreation = this.pendingSessionCreation.get(sessionName) - if (pendingCreation) { - // Why: tab close can race session creation before sessions.set(); await it so no late proxy survives the close. - try { - await pendingCreation - } catch { - // Creation failures are handled by the original caller; teardown still rejects queued work below. - } - } - - const session = this.sessions.get(sessionName) - if (!session) { - this.rejectQueuedCommandsForClosedSession(sessionName) - return - } - - this.sessions.delete(sessionName) - this.pendingSessionCreation.delete(sessionName) - - // Why: queued commands would hang forever if we just delete the queue — drain and reject them. - this.rejectQueuedCommandsForClosedSession(sessionName) - - if (session.activeProcess) { - // Why: rejecting the queue isn't enough for an in-flight command — kill the process so callers don't wait out the exec timeout. - this.cancelledProcesses.add(session.activeProcess) - try { - session.activeProcess.kill() - } catch { - // Process may already be exiting. - } - session.activeProcess = null - } - - const destroy = (async (): Promise => { - try { - // Why: each tab has its own named session — close without --session leaves this tab's daemon running. - // Why bounded: this runs inside the 20s will-quit barrier, so it cannot inherit the 90s exec timeout. - await this.runAgentBrowserRaw( - sessionName, - ['--session', sessionName, 'close'], - options.closeTimeoutMs === undefined ? undefined : { timeoutMs: options.closeTimeoutMs } - ) - } catch { - // Session may already be dead - } - - await session.proxy.stop() - })() - this.pendingSessionDestruction.set(sessionName, destroy) - try { - await destroy - } finally { - this.pendingSessionDestruction.delete(sessionName) - } - } - - private rejectQueuedCommandsForClosedSession(sessionName: string): void { - const queue = this.commandQueues.get(sessionName) - this.commandQueues.delete(sessionName) - this.processingQueues.delete(sessionName) - if (queue) { - const err = new BrowserError( - 'browser_tab_closed', - 'Tab was closed while commands were queued' - ) - for (const cmd of queue) { - cmd.reject(err) - } - queue.length = 0 - } - } - - /** - * Notice that the daemon retired itself between two commands. - * - * A replacement daemon still serves the page (every call reasserts `--cdp`) - * but carries none of the session's network routes, so without this the - * interception the caller configured is silently gone (#16367). - */ - private reinitializeIfDaemonIdledOut(sessionName: string, session: SessionState): void { - if ( - this.agentBrowserIdleTimeoutMs === null || - Date.now() - session.lastCommandAt < this.agentBrowserIdleTimeoutMs - ) { - return - } - session.initialized = false - if (session.activeInterceptPatterns.length > 0) { - this.pendingInterceptRestore.set(sessionName, [...session.activeInterceptPatterns]) - } - } - - private assertCommandAdmission(): void { - if (this.shutdownStarted) { - throw new BrowserError('browser_owner_unavailable', 'Browser runtime is shutting down') - } - } - - private async execAgentBrowser( - sessionName: string, - commandArgs: string[], - execOptions?: AgentBrowserExecOptions - ): Promise { - const session = this.sessions.get(sessionName) - if (!session) { - // Why: a queued command can run after a concurrent close deleted the session — surface a tab-lifecycle error, not an opaque failure. - throw this.createPageUnavailableError(sessionName) - } - - // Why: the webContents can be destroyed during queue delay — check here to avoid cryptic Electron debugger errors. - if (!this.getWebContents(session.webContentsId)) { - await this.destroySession(sessionName) - throw this.createPageUnavailableError(sessionName) - } - - this.reinitializeIfDaemonIdledOut(sessionName, session) - session.lastCommandAt = Date.now() - - const args = ['--session', sessionName] - const managesInterceptRoutes = - commandArgs[0] === 'network' && (commandArgs[1] === 'route' || commandArgs[1] === 'unroute') - - const needsInit = !session.initialized - // Why: a restarted named daemon auto-launches Chrome unless every invocation reasserts Orca's CDP owner. - args.push('--cdp', String(session.proxy.getPort())) - - // Why: exec passthrough can produce a large argv; spreading into push risks V8 argument limits. - for (const commandArg of commandArgs) { - args.push(commandArg) - } - args.push('--json') - - const stdout = await this.runAgentBrowserRaw(sessionName, args, execOptions) - const translated = translateResult(stdout) - - if (!translated.ok) { - throw this.createCommandError( - sessionName, - translated.error.message, - translated.error.code, - session.webContentsId - ) - } - - // Why: mark initialized only after success, so a failed first --cdp connection retries with --cdp. - if (needsInit) { - session.initialized = true - - // Why: a process swap loses intercept patterns — restore them now unless the caller's first command reconfigured routing. - const pendingPatterns = managesInterceptRoutes - ? undefined - : this.pendingInterceptRestore.get(sessionName) - if (pendingPatterns && pendingPatterns.length > 0) { - this.pendingInterceptRestore.delete(sessionName) - try { - const urlPattern = pendingPatterns[0] ?? '**/*' - await this.runAgentBrowserRaw(sessionName, [ - '--session', - sessionName, - '--cdp', - String(session.proxy.getPort()), - 'network', - 'route', - urlPattern, - '--json' - ]) - session.activeInterceptPatterns = pendingPatterns - } catch { - // Why: intercept restore is best-effort — don't fail the user's command if the new page can't support it. - } - } - } - - return translated.result - } - - private async isExplicitContentEditableTarget( - sessionName: string, - element: string - ): Promise { - const result = await this.execAgentBrowser(sessionName, [ - 'get', - 'attr', - element, - 'contenteditable' - ]) - return isExplicitContentEditableResult(result) - } - - private async fillExplicitContentEditable( - sessionName: string, - element: string, - value: string - ): Promise { - await this.execAgentBrowser(sessionName, ['focus', element]) - // Why: stdin avoids argv limits and keeps replacement atomic; chunked edits can move focus and split a fill across controls. - await this.execAgentBrowser(sessionName, ['eval', '--stdin'], { - stdinText: focusedRichTextEditExpression(JSON.stringify(value), { selectAll: true }) - }) - } - - private createPageUnavailableError(sessionName: string): BrowserError { - return new BrowserError('browser_tab_not_found', pageUnavailableMessageForSession(sessionName)) - } - - private closeStaleAgentBrowserSession(sessionName: string): Promise { - return new Promise((resolve, reject) => { - let child: ReturnType | null = null - let settled = false - - const finish = (error?: Error): void => { - if (settled) { - return - } - settled = true - clearTimeout(timeout) - if (error) { - reject(error) - } else { - resolve() - } - } - - // Why: proceeding after an unverified close can reuse a daemon that owns an unrelated browser. - const timeout = setTimeout(() => { - child?.kill() - finish( - new BrowserError( - 'browser_owner_unavailable', - `Could not reset stale helper session ${sessionName}; retry after agent-browser exits` - ) - ) - }, STALE_SESSION_CLOSE_TIMEOUT_MS) - - try { - child = execFile( - this.agentBrowserBin, - ['--session', sessionName, 'close'], - // Why windowsHide: agent-browser is console-subsystem and Orca's main - // process owns no console, so each spawn gets a fresh visible conhost - // that takes foreground -- keystrokes typed into a terminal at that - // moment land in the black box (#14543). - { - env: this.agentBrowserEnv, - timeout: STALE_SESSION_CLOSE_TIMEOUT_MS, - windowsHide: true - }, - (error) => - finish( - error - ? new BrowserError( - 'browser_owner_unavailable', - `Could not reset stale helper session ${sessionName}: ${error.message}` - ) - : undefined - ) - ) - } catch (error) { - finish( - new BrowserError( - 'browser_owner_unavailable', - `Could not reset stale helper session ${sessionName}: ${error instanceof Error ? error.message : String(error)}` - ) - ) - } - }) - } - - private createCommandError( - sessionName: string, - message: string, - fallbackCode: string, - webContentsId?: number - ): BrowserError { - // Why: CDP "connection refused" can also mean a real proxy failure — only map to closed-page when the target is confirmed gone. - if ( - fallbackCode === 'browser_error' && - isTabClosedTransportError(message) && - this.isSessionTargetClosed(sessionName, webContentsId) - ) { - return this.createPageUnavailableError(sessionName) - } - return new BrowserError(fallbackCode, message) - } - - private isSessionTargetClosed(sessionName: string, webContentsId?: number): boolean { - const session = this.sessions.get(sessionName) - if (!session) { - return true - } - const targetWebContentsId = webContentsId ?? session.webContentsId - return !this.getWebContents(targetWebContentsId) - } - - private runAgentBrowserRaw( - sessionName: string, - args: string[], - execOptions?: AgentBrowserExecOptions - ): Promise { - return new Promise((resolve, reject) => { - const session = this.sessions.get(sessionName) - let child: ChildProcess | null = null - child = execFile( - this.agentBrowserBin, - args, - // Why: screenshots return large base64 that exceeds Node's default 1MB maxBuffer (ENOBUFS). - { - timeout: execOptions?.timeoutMs ?? EXEC_TIMEOUT_MS, - maxBuffer: 50 * 1024 * 1024, - // Why windowsHide: see the stale-session close above -- every - // agent-browser invocation would otherwise flash a console (#14543). - windowsHide: true, - env: execOptions?.envOverrides - ? { ...this.agentBrowserEnv, ...execOptions.envOverrides } - : this.agentBrowserEnv - }, - (error, stdout, stderr) => { - if (session && session.activeProcess === child) { - session.activeProcess = null - } - if (child && this.cancelledProcesses.has(child)) { - this.cancelledProcesses.delete(child) - reject( - new BrowserError('browser_tab_closed', 'Tab was closed while command was running') - ) - return - } - - const liveSession = this.sessions.get(sessionName) - - if (error && (error as NodeJS.ErrnoException & { killed?: boolean }).killed) { - if (execOptions?.timeoutError) { - reject(execOptions.timeoutError) - return - } - if (liveSession) { - liveSession.consecutiveTimeouts++ - if (liveSession.consecutiveTimeouts >= CONSECUTIVE_TIMEOUT_LIMIT) { - // Why: 3 consecutive timeouts means the daemon is likely stuck — destroy and recreate - this.destroySession(sessionName) - } - } - reject(new BrowserError('browser_error', 'Browser command timed out')) - return - } - - if (liveSession) { - liveSession.consecutiveTimeouts = 0 - } - - if (error) { - // Why: agent-browser exits non-zero on failure but still writes structured JSON to stdout — parse it for the real error. - if (stdout) { - try { - const parsed = JSON.parse(stdout) - if (parsed.error) { - const code = classifyErrorCode(parsed.error) - reject( - this.createCommandError(sessionName, parsed.error, code, session?.webContentsId) - ) - return - } - } catch { - // stdout not valid JSON — fall through to stderr/error.message - } - } - const message = stderr || error.message - const code = classifyErrorCode(message) - reject(this.createCommandError(sessionName, message, code, session?.webContentsId)) - return - } - - resolve(stdout) - } - ) - if (session) { - session.activeProcess = child - } - if (execOptions?.stdinText !== undefined && child?.stdin) { - // Why: eval --stdin keeps paste-sized scripts out of argv on every platform. - child.stdin.on('error', () => {}) - child.stdin.end(execOptions.stdinText) - } - }) - } - - private resolveTabIdSafe(webContentsId: number): string | null { - return this.browserManager.getTabIdForWebContentsId(webContentsId) - } - - private requireTargetWebContents(target: ResolvedBrowserCommandTarget): WebContents { - const wc = this.getWebContents(target.webContentsId) - if (!wc || wc.isDestroyed()) { - throw this.createPageUnavailableError(`${ORCA_TAB_SESSION_PREFIX}${target.browserPageId}`) - } - return wc - } - - private getWebContents(webContentsId: number): Electron.WebContents | null { - try { - const { webContents } = require('electron') - const target = webContents.fromId(webContentsId) - return target && !target.isDestroyed() ? target : null - } catch { - return null - } - } -} +import { AgentBrowserBridgeStateCommands } from './agent-browser-bridge-state-commands' + +export { + AGENT_BROWSER_TEXT_ARGUMENT_MAX_BYTES, + AGENT_BROWSER_CLIPBOARD_WRITE_MAX_BYTES +} from './agent-browser-bridge-types' +export type { + BrowserMouseModifier, + AgentBrowserCleanupOptions, + AgentBrowserBridgeOptions +} from './agent-browser-bridge-types' + +/** + * Routes automation commands to the browser guest registered for the selected tab. + * + * The implementation is layered by responsibility (tab targeting, command queue, + * session lifecycle, and command families) so each layer stays independently + * reviewable while preserving this public facade. + */ +export class AgentBrowserBridge extends AgentBrowserBridgeStateCommands {} diff --git a/src/main/browser/browser-cookie-chromium-finalize.ts b/src/main/browser/browser-cookie-chromium-finalize.ts new file mode 100644 index 00000000000..ba630a3c0fa --- /dev/null +++ b/src/main/browser/browser-cookie-chromium-finalize.ts @@ -0,0 +1,166 @@ +import type { + BrowserCookieImportResult, + BrowserCookieImportSummary +} from '../../shared/browser-workspace-types' +import { browserSessionRegistry } from './browser-session-registry' +import { removeTransplantableCookies } from './browser-cookie-import-clear' +import { openCookieClearStore } from './browser-cookie-clear-store' +import { writeImportedCookies, type SourceCookieToWrite } from './browser-cookie-import-write' +import { deriveUrl } from './browser-cookie-validation' +import { diag } from './browser-cookie-import-diagnostics' +import type { ChromiumImportContext } from './browser-cookie-chromium-types' + +export async function finalizeChromiumCookieImport( + context: ChromiumImportContext +): Promise { + if (context.decryptedCookies.length === 0) { + const zeroPathWarning = context.undecryptableWarning + context.closeStagingDb() + context.discardStagingFile() + return { + ok: true, + profileId: '', + summary: { + totalCookies: context.sourceRows.length, + importedCookies: 0, + skippedCookies: + context.skipped + context.integritySkipped + context.nonTransplantableSkipped, + ...(context.googleCookiesSkipped > 0 + ? { googleCookiesSkipped: context.googleCookiesSkipped } + : {}), + // Why: partition skips are a breakdown of skippedCookies, never an addition to it, so + // totalCookies === importedCookies + skippedCookies keeps holding on this path too. + ...(context.partitionSkipped > 0 + ? { partitionSkippedCookies: context.partitionSkipped } + : {}), + domains: [], + // Why: a profile whose rows cannot be decrypted returns here, and without this it is + // reported as a successful empty import. + ...(zeroPathWarning ? { warning: zeroPathWarning } : {}) + } + } + } + + if (context.stagingDb) { + try { + context.stagingDb.exec('COMMIT') + context.closeStagingDb() + diag( + ` SQLite staging complete: ${context.imported} cookies, ${context.domainSet.size} domains` + ) + } catch (err) { + context.disableStaging(String(err)) + } + } else { + diag(` staging skipped: ${context.imported} cookies will load in-memory only`) + } + + // Why: clear stale cookies for the domains being imported first; mixing them with the imported + // set makes sites reject the session. Non-transplantable families are exempt — nothing was + // imported for them, and their live session is the only one that works. + // Why (STA-4797): every other site in the partition is exempt too. The rationale above reaches + // only as far as the domains this import writes; beyond them a clear has nothing to reconcile + // and only signs the user out of sessions the import was never about. + // Why (STA-4300): one store spans the clear and the writes, so both halves of the import speak + // the same CDP identities — cookies.set() cannot express the partition either one reads. + const cookieClearStore = openCookieClearStore(context.targetSession) + try { + // Why (STA-4601): the outer lock spans the clear and the writes that repopulate the jar, so a + // second import cannot clear between them and write on top of a newer import's jar. + await removeTransplantableCookies( + { + cookies: cookieClearStore, + snapshotClearIdentities: (cookies) => cookieClearStore.snapshotClearIdentities(cookies), + restoreClearIdentities: (identities) => cookieClearStore.restoreClearIdentities(identities) + }, + // Why (STA-4300): the families this import declined to write must not be removed either. + // Passing them here keeps their coordinates out of the removal plan AND out of the CDP + // snapshot taken from it, so they are never submitted to any mutation. + context.nativePlan.skippedFamilies, + context.importScope + ) + diag( + ` cleared existing cookies for ${context.domainSet.size} imported domains before loading ${context.decryptedCookies.length} imported cookies` + ) + + const writable: SourceCookieToWrite[] = [] + for (const cookie of context.decryptedCookies) { + const url = deriveUrl(cookie.domain, cookie.secure) + if (!url) { + context.memoryFailed++ + continue + } + writable.push({ ...cookie, url }) + } + // Why: a rejected cookie here falls back to the staged cold-start replay rather than + // unwinding the import, so one failure must not stop the rest from loading. + const phase = await writeImportedCookies(cookieClearStore, writable, { + stopOnFailure: false, + log: diag + }) + context.memoryLoaded = phase.importedCount + context.memoryFailed += phase.writeRejected + } finally { + cookieClearStore.dispose() + } + + diag( + ` memory load: ${context.memoryLoaded} OK, ${context.memoryFailed} failed, ${context.partitionSkipped} partition-unreadable` + ) + + let warning: BrowserCookieImportSummary['warning'] + if (context.memoryFailed > 0 && context.stagingAvailable) { + // Why: keep the staging DB so the failed cookies load from SQLite on next cold start, where CookieMonster skips validation. + browserSessionRegistry.setPendingCookieImport( + context.targetPartition, + context.stagingCookiesPath + ) + diag( + ` staged at ${context.stagingCookiesPath} for ${context.memoryFailed} cookies that need restart` + ) + } else if (context.memoryFailed > 0) { + // Why: never register a path that was never written or can never be replayed — cold start + // would replay a missing or partial DB over the live partition. + browserSessionRegistry.clearPendingCookieImport(context.targetPartition) + context.discardStagingFile() + diag(` ${context.memoryFailed} cookies need a restart but staging is unavailable — skipped`) + // Why: the jar was already cleared, so silence here would report a lossy import as a clean success. + warning = { + code: 'restart-fallback-unavailable', + loadedCookies: context.memoryLoaded, + failedCookies: context.memoryFailed + } + } else { + // Why: this import already rewrote the live session, so an older staged DB must not replay over it. + browserSessionRegistry.clearPendingCookieImport(context.targetPartition) + context.discardStagingFile() + diag(' all cookies loaded in-memory — no restart needed') + } + + // Why: the session keeps the UA the registry set at startup (clean or native). + // Imports must not impersonate the source browser — the synthesized UA read a + // fork's marketing version as a Chromium version (STA-3514), and Google binds + // sessions to the re-import, not the UA (#12884), so it bought nothing. + // Google-bound integrity cookies are already excluded by + // isGoogleSourceBoundCookie, which is what actually prevents CookieMismatch. + + // Why: a partial import still drops every undecryptable row, so silence here would report it + // as an unqualified success. The restart-fallback warning describes a lossier outcome and + // keeps precedence. + if (!warning && context.undecryptableWarning) { + warning = context.undecryptableWarning + } + + const summary: BrowserCookieImportSummary = { + totalCookies: context.sourceRows.length, + importedCookies: context.imported, + skippedCookies: context.skipped + context.integritySkipped + context.nonTransplantableSkipped, + ...(context.googleCookiesSkipped > 0 + ? { googleCookiesSkipped: context.googleCookiesSkipped } + : {}), + ...(context.partitionSkipped > 0 ? { partitionSkippedCookies: context.partitionSkipped } : {}), + domains: [...context.domainSet].sort(), + ...(warning ? { warning } : {}) + } + return { ok: true, profileId: '', summary } +} diff --git a/src/main/browser/browser-cookie-chromium-import.ts b/src/main/browser/browser-cookie-chromium-import.ts new file mode 100644 index 00000000000..7066952c58b --- /dev/null +++ b/src/main/browser/browser-cookie-chromium-import.ts @@ -0,0 +1,83 @@ +import { session } from 'electron' +import { existsSync } from 'node:fs' +import type { BrowserCookieImportResult } from '../../shared/browser-workspace-types' +import { withCookieMutationLock } from './browser-cookie-import-clear' +import { + diag, + reasonWithDiagLog, + summarizeCookieImportError +} from './browser-cookie-import-diagnostics' +import type { DetectedBrowser } from './browser-cookie-detection-types' +import type { CookieImportOptions } from './browser-cookie-import-pipeline' +import { prepareChromiumCookieImport } from './browser-cookie-chromium-prepare' +import { scanChromiumCookieRows } from './browser-cookie-chromium-scan' +import { finalizeChromiumCookieImport } from './browser-cookie-chromium-finalize' +import type { ChromiumImportContext } from './browser-cookie-chromium-types' + +export async function importChromiumCookies( + browser: DetectedBrowser, + targetPartition: string, + options: CookieImportOptions = {} +): Promise { + diag(`importCookiesFromBrowser: browser=${browser.family} partition="${targetPartition}"`) + if (!existsSync(browser.cookiesPath)) { + diag(` cookies DB not found: ${browser.cookiesPath}`) + return { ok: false, reason: `${browser.label} cookies database not found.` } + } + + // Why: cookies.set() rejects many valid values (bytes > 0x7F); instead write plaintext to the `value` column, which CookieMonster reads raw when `encrypted_value` is empty and re-encrypts on flush in packaged builds. + + // Why: CookieMonster can reject otherwise valid imported bytes, so stage a populated copy whose + // imported-domain rows can be merged into the live DB on the next cold start. + const targetSession = session.fromPartition(targetPartition) + // Why (STA-4601): native imports mutate the live jar and their staged image before the old + // clear/write lock was reached. Hold the per-partition lock from the first flush through staging, + // live replacement, pending-image bookkeeping, and cleanup so an older image cannot race a newer + // import on the same partition. + return withCookieMutationLock(targetSession, async () => { + let context: ChromiumImportContext | null = null + try { + const preparation = await prepareChromiumCookieImport( + browser, + targetPartition, + options, + targetSession + ) + if ('result' in preparation) { + return preparation.result + } + context = preparation.context + const scanResult = scanChromiumCookieRows(context) + if (scanResult) { + return scanResult + } + return await finalizeChromiumCookieImport(context) + } catch (err) { + if (context) { + try { + context.sourceDb?.close() + } catch { + /* may already be closed */ + } + context.closeStagingDb() + // Why: drop the staging DB so a stale staged import isn't applied on the next cold start. + context.discardStagingFile() + } + diag(` SQLite import failed: ${String(err)}`) + return { + ok: false, + reason: reasonWithDiagLog( + `Could not import cookies from ${browser.label}: ${summarizeCookieImportError(err)}.` + ) + } + } finally { + if (context) { + try { + context.sourceSnapshot.cleanup() + } catch (err) { + diag(` Chromium snapshot cleanup failed: ${String(err)}`) + } + } + } + }) +} diff --git a/src/main/browser/browser-cookie-chromium-prepare.ts b/src/main/browser/browser-cookie-chromium-prepare.ts new file mode 100644 index 00000000000..8b71b102f13 --- /dev/null +++ b/src/main/browser/browser-cookie-chromium-prepare.ts @@ -0,0 +1,326 @@ +import { app } from 'electron' +import { randomUUID } from 'node:crypto' +import { mkdirSync, unlinkSync } from 'node:fs' +import { DatabaseSync } from 'node:sqlite' +import { join } from 'node:path' +import type { BrowserCookieImportResult } from '../../shared/browser-workspace-types' +import { supportsPendingBrowserCookieImportReplay } from './browser-session-cookie-staging' +import { + isGoogleSourceBoundCookie, + isNonTransplantableCookieDomain +} from './browser-cookie-import-policy' +import { createChromiumCookieSnapshot } from './chromium-cookie-snapshot' +import { resolveChromiumCookiesPath } from './chromium-cookie-path' +import { copyFileWithWindowsRetry } from '../codex-accounts/fs-utils' +import { planImportWrites } from './browser-cookie-import-write' +import { readChromiumRowPartition } from './browser-cookie-source-partition' +import { diag } from './browser-cookie-import-diagnostics' +import type { DetectedBrowser } from './browser-cookie-detection-types' +import type { CookieImportOptions } from './browser-cookie-import-pipeline' +import type { ChromiumCookieColumnInfo } from './browser-cookie-sqlite' +import type { ChromiumImportContext } from './browser-cookie-chromium-types' +import type { Session } from 'electron' +import { getEncryptionKey } from './browser-cookie-key' + +export type ChromiumImportPreparation = + | { context: ChromiumImportContext } + | { result: BrowserCookieImportResult } + +export async function prepareChromiumCookieImport( + browser: DetectedBrowser, + targetPartition: string, + options: CookieImportOptions, + targetSession: Session +): Promise { + await targetSession.cookies.flushStore() + // Why (STA-4300): ask the Session where its own storage lives instead of rebuilding the path from + // the caller's partition string. String surgery on a caller-supplied name is what let a value like + // "persist:../.." resolve a Cookies DB outside the Partitions directory and stage a replacement + // over it; it also drifts whenever Chromium changes how a partition name maps to a directory. + const partitionDir = targetSession.getStoragePath() + if (!partitionDir) { + return { + result: { ok: false, reason: 'Target cookie database not found. Open a browser tab first.' } + } + } + + const partitionName = targetPartition.replace('persist:', '') + let liveCookiesPath = resolveChromiumCookiesPath(partitionDir) + // Why: Electron creates the Cookies file only after a cookie is stored; a throwaway set/remove forces DB init for unused profiles. + // Why (STA-4601): this probe MUTATES the live jar, so it runs under the same per-partition lock as + // the import itself. An earlier revision left it outside on the argument that no import writes + // https://localhost/__init — that was wrong. normalizeCookieImportDomain accepts `localhost`, + // cookie names are unrestricted, and deriveUrl produces exactly this URL, so an import CAN write + // that coordinate. Unlocked, this probe's remove() would delete a cookie a concurrent import had + // just written and reported as imported. The cost is negligible: the probe only runs for a + // partition that has never stored a cookie, so it is at most a one-time wait per profile. + if (!liveCookiesPath) { + try { + await targetSession.cookies.set({ url: 'https://localhost', name: '__init', value: '1' }) + await targetSession.cookies.remove('https://localhost', '__init') + await targetSession.cookies.flushStore() + } catch { + // ignore — the set/remove may fail but flushStore should still create the file + } + liveCookiesPath = resolveChromiumCookiesPath(partitionDir) + } + if (!liveCookiesPath) { + return { + result: { ok: false, reason: 'Target cookie database not found. Open a browser tab first.' } + } + } + + const stagingDir = join(app.getPath('userData'), 'cookie-import-staging') + const partitionSegment = partitionName.replace(/[^a-zA-Z0-9_-]/g, '_') + const stagingCookiesPath = join( + stagingDir, + `Cookies-${partitionSegment}-${Date.now()}-${randomUUID()}` + ) + // Why: #9355 — staging only backs the cold-restart replay for cookies the in-memory + // import rejects, so losing it must degrade that fallback rather than abort the import. + let stagingAvailable = false + // Why: a client-hosted route partition is derived at runtime and never reaches the startup + // replay, so staging it would only leave a plaintext cookie DB nothing ever consumes. + if (!supportsPendingBrowserCookieImportReplay(targetPartition)) { + diag(` restart fallback unsupported for partition "${targetPartition}" — not staging cookies`) + } else { + try { + mkdirSync(stagingDir, { recursive: true }) + copyFileWithWindowsRetry(liveCookiesPath, stagingCookiesPath) + stagingAvailable = true + } catch (err) { + const fsErr = err as NodeJS.ErrnoException + diag( + ` staging copy unavailable: code=${fsErr.code ?? 'unknown'} errno=${fsErr.errno ?? 'unknown'} syscall=${fsErr.syscall ?? 'unknown'} path=${liveCookiesPath} destination=${stagingCookiesPath}` + ) + // Why: copyFile is non-atomic and can leave a partial DB; delete it so failed imports retain no cookie data. + try { + unlinkSync(stagingCookiesPath) + } catch { + /* best-effort */ + } + } + } + + let sourceSnapshot: ReturnType + try { + // Why: an open browser may hold cookies in WAL only; snapshot retries avoid pairing the main DB with a racing WAL. + sourceSnapshot = createChromiumCookieSnapshot(browser.cookiesPath) + } catch (err) { + try { + unlinkSync(stagingCookiesPath) + } catch { + /* best-effort */ + } + diag(` Chromium snapshot failed: ${String(err)}`) + return { + result: { + ok: false, + reason: `Could not copy ${browser.label} cookies database. Try closing ${browser.label} first.` + } + } + } + + let sourceDb: InstanceType | null = null + let stagingDb: InstanceType | null = null + const closeStagingDb = (): void => { + try { + stagingDb?.close() + } catch { + /* best-effort */ + } + stagingDb = null + } + const discardStagingFile = (): void => { + // Why: the staged copy holds plaintext cookie values, and SQLite may have left sidecars beside it. + for (const suffix of ['', '-wal', '-shm']) { + try { + unlinkSync(stagingCookiesPath + suffix) + } catch { + /* best-effort */ + } + } + } + + // Why: Chromium timestamps (µs since 1601) can exceed Number.MAX_SAFE_INTEGER; readBigInts avoids precision loss. + sourceDb = new DatabaseSync(sourceSnapshot.databasePath, { readOnly: true, readBigInts: true }) + let targetColumnInfo: ChromiumCookieColumnInfo[] | null = null + let colList: string | null = null + let placeholders: string | null = null + if (stagingAvailable) { + // Why: the staged file is Orca's own partition DB, also named "Cookies", so the same + // transient AV handle can make opening it throw — degrade instead of killing the import. + try { + stagingDb = new DatabaseSync(stagingCookiesPath) + // Why (STA-4797): a new-format stage must be one self-contained file. Otherwise a lost WAL + // can erase its scope marker and make cold-start replay mistake it for a legacy whole-image + // import, restoring the unrelated-cookie data loss this format is meant to prevent. + stagingDb.exec('PRAGMA journal_mode = DELETE') + targetColumnInfo = stagingDb + .prepare('PRAGMA table_info(cookies)') + .all() as ChromiumCookieColumnInfo[] + const targetCols = targetColumnInfo.map((row) => row.name) + colList = targetCols.join(', ') + placeholders = targetCols.map(() => '?').join(', ') + } catch (err) { + diag(` staging database unusable, restart fallback disabled: ${String(err)}`) + stagingAvailable = false + targetColumnInfo = null + colList = null + placeholders = null + closeStagingDb() + // Why: the copy holds real partition cookies; discard it now rather than at the exit branches. + discardStagingFile() + } + } + + // Why (STA-4300): the partition columns drift across Chromium versions, so read the source + // schema rather than assuming a row's missing column means "unpartitioned". + const sourceColumns = new Set( + (sourceDb.prepare('PRAGMA table_info(cookies)').all() as ChromiumCookieColumnInfo[]).map( + (column) => column.name + ) + ) + const sourceRows = sourceDb.prepare('SELECT * FROM cookies ORDER BY rowid').all() as Record< + string, + unknown + >[] + sourceDb.close() + sourceDb = null + diag(` source has ${sourceRows.length} cookies`) + if (sourceRows.length === 0) { + closeStagingDb() + discardStagingFile() + return { result: { ok: false, reason: `No cookies found in ${browser.label}.` } } + } + + // Why (STA-4300): partition fidelity is a property of the source row, even when its value + // cannot be decrypted. Plan first so decryption failure cannot discard a family's skip. + const partitionCandidates = sourceRows.flatMap((sourceRow) => { + const domain = sourceRow.host_key as string + const name = sourceRow.name as string + return isGoogleSourceBoundCookie(name, domain) || isNonTransplantableCookieDomain(domain) + ? [] + : [{ sourceRow, domain, partition: readChromiumRowPartition(sourceRow, sourceColumns) }] + }) + const nativePlan = planImportWrites(partitionCandidates) + const plannedSourceRows = new Set(nativePlan.writes.map((candidate) => candidate.sourceRow)) + const partitionBySourceRow = new Map( + partitionCandidates.map((candidate) => [candidate.sourceRow, candidate.partition]) + ) + // Why (§4.3c): a family we cannot name is one we cannot exclude from the clear, and clearing a + // family we cannot protect is the P0. Refuse before the jar is touched. + if (nativePlan.hasUnrepresentableSkip) { + closeStagingDb() + discardStagingFile() + return { + result: { + ok: false, + reason: + 'Could not import: a cookie with an unreadable site partition has no registrable domain, so its existing session cannot be protected.' + } + } + } + + const needsSourceKey = sourceRows.some((sourceRow) => { + const encrypted = sourceRow.encrypted_value + if (!(encrypted instanceof Uint8Array) || encrypted.length === 0) { + return false + } + return ( + !isGoogleSourceBoundCookie(sourceRow.name as string, sourceRow.host_key as string) && + !isNonTransplantableCookieDomain(sourceRow.host_key as string) + ) + }) + const sourceKey = needsSourceKey + ? getEncryptionKey(browser.keychainService!, browser.keychainAccount!, browser) + : null + if (needsSourceKey && !sourceKey) { + closeStagingDb() + // Why: key denial happens after staging, so clean up the target DB copy or retries pile up. + discardStagingFile() + return { + result: { + ok: false, + reason: `Could not access ${browser.label} encryption key. The OS may have denied access.` + } + } + } + + // Why: staging only backs the cold-restart replay, so any failure writing it disables that + // fallback instead of aborting an import whose in-memory half still works. + let insertStmt: ChromiumImportContext['insertStmt'] = null + const context: ChromiumImportContext = { + browser, + targetPartition, + options, + targetSession, + stagingCookiesPath, + stagingAvailable, + sourceSnapshot, + sourceDb, + stagingDb, + targetColumnInfo, + colList, + placeholders, + sourceColumns, + sourceRows, + nativePlan, + plannedSourceRows, + partitionBySourceRow, + sourceKey, + imported: 0, + skipped: 0, + decryptFailed: 0, + appBoundFailed: 0, + keyringUnavailableFailed: 0, + integritySkipped: 0, + nonTransplantableSkipped: 0, + partitionSkipped: nativePlan.skips.length, + googleCookiesSkipped: 0, + memoryLoaded: 0, + memoryFailed: 0, + domainSet: new Set(), + decryptedCookies: [], + // Why: the staging insert needs the RAW source row, so each scanned candidate carries it. + // A plan record holding only the derived fields compiles fine and then cannot stage. + scanned: [], + sourceDomainValidity: new Map(), + insertStmt, + importScope: { + exact: new Set(), + ancestors: new Set(), + descendantRoots: new Set() + }, + closeStagingDb, + discardStagingFile, + disableStaging: (reason: string): void => { + diag(` staging disabled, restart fallback unavailable: ${reason}`) + context.stagingAvailable = false + context.insertStmt = null + context.closeStagingDb() + context.discardStagingFile() + } + } satisfies ChromiumImportContext + + if (context.stagingDb && context.colList && context.placeholders) { + try { + context.insertStmt = context.stagingDb.prepare( + `INSERT OR REPLACE INTO cookies (${context.colList}) VALUES (${context.placeholders})` + ) + context.stagingDb.exec('BEGIN TRANSACTION') + } catch (err) { + context.disableStaging(String(err)) + } + } else if (context.stagingAvailable) { + context.disableStaging('staged database exposed no cookies columns') + } + // Why: keep the existing conservative fallback boundary for family-level omissions. Expanding + // partial-import restart behavior is separate from narrowing what a staged replay may replace. + if (context.nativePlan.skippedFamilies.size > 0) { + context.disableStaging( + `${context.nativePlan.skippedFamilies.size} preserved cookie families cannot be represented in a staged image` + ) + } + return { context } +} diff --git a/src/main/browser/browser-cookie-chromium-scan.ts b/src/main/browser/browser-cookie-chromium-scan.ts new file mode 100644 index 00000000000..e3bdc6a6409 --- /dev/null +++ b/src/main/browser/browser-cookie-chromium-scan.ts @@ -0,0 +1,189 @@ +import type { BrowserCookieImportResult } from '../../shared/browser-workspace-types' +import { + isGoogleSourceBoundCookie, + isNonTransplantableCookieDomain, + normalizeCookieImportDomain, + importedDomainScope +} from './browser-cookie-import-policy' +import { prepareStagedCookiesForImport } from './browser-cookie-staged-import' +import { chromiumTimestampToUnix, buildChromiumCookieInsertParams } from './browser-cookie-sqlite' +import { chromiumSameSite } from './browser-cookie-validation' +import { + buildUndecryptableWarning, + cookieEncryptionVersion, + decryptCookieValueRaw +} from './browser-cookie-decryption' +import { diag } from './browser-cookie-import-diagnostics' +import type { ChromiumImportContext } from './browser-cookie-chromium-types' + +/** + * Decrypts and validates source rows without touching the target cookie jar. + * Keeping this pass separate makes the write scope derive from one complete plan. + */ +export function scanChromiumCookieRows( + context: ChromiumImportContext +): BrowserCookieImportResult | null { + const { sourceRows, sourceKey, plannedSourceRows, partitionBySourceRow, targetColumnInfo } = + context + + for (const sourceRow of sourceRows) { + const domain = sourceRow.host_key as string + const name = sourceRow.name as string + + if (isGoogleSourceBoundCookie(name, domain)) { + context.integritySkipped++ + continue + } + // Why: transplanting these replaces a working sign-in with a session the site rejects. + if (isNonTransplantableCookieDomain(domain)) { + context.nonTransplantableSkipped++ + continue + } + + const encRaw = sourceRow.encrypted_value + // Why: node:sqlite returns BLOBs as Uint8Array; treat any other type as missing, not an empty buffer that would silently blank the cookie value. + const encBuf = encRaw instanceof Uint8Array ? Buffer.from(encRaw) : null + const plainRaw = sourceRow.value + let decryptedValue: Buffer + if (encBuf && encBuf.length > 0) { + const version = cookieEncryptionVersion(encBuf) + const appBoundIneligible = version === 'v20' + const keyringIneligible = + version === 'v11' && + sourceKey?.mode === 'aes-128-cbc' && + sourceKey.keyringUnavailable === true + const raw = + sourceKey && !appBoundIneligible && !keyringIneligible + ? decryptCookieValueRaw(encBuf, sourceKey) + : null + if (!raw) { + // Why: once decrypt returns null every failure looks identical, so attribute the cause + // here while the version prefix is still in hand. Without this an undecryptable profile + // is indistinguishable from an empty one and reports success. + context.decryptFailed++ + if (appBoundIneligible) { + context.appBoundFailed++ + } else if (keyringIneligible) { + context.keyringUnavailableFailed++ + } + context.skipped++ + continue + } + decryptedValue = raw + } else if (plainRaw instanceof Uint8Array) { + decryptedValue = Buffer.from(plainRaw) + } else if (typeof plainRaw === 'string') { + decryptedValue = Buffer.from(plainRaw, 'latin1') + } else { + decryptedValue = Buffer.alloc(0) + } + + let validDomain = context.sourceDomainValidity.get(domain) + if (validDomain === undefined) { + validDomain = normalizeCookieImportDomain(domain) !== null + context.sourceDomainValidity.set(domain, validDomain) + } + if (!validDomain) { + context.skipped++ + continue + } + // Decryption failures are already counted above. Every other row suppressed by the + // pre-decryption family plan is counted once here, keeping partitionSkipped a breakdown. + if (!plannedSourceRows.has(sourceRow)) { + context.skipped++ + continue + } + + const path = sourceRow.path as string + const secure = sourceRow.is_secure === 1n + const httpOnly = sourceRow.is_httponly === 1n + const sameSite = chromiumSameSite(Number(sourceRow.samesite ?? 0)) + const expiresUtc = chromiumTimestampToUnix(sourceRow.expires_utc as bigint) + const partition = partitionBySourceRow.get(sourceRow)! + // Why: cookie values are raw bytes, not UTF-8; latin1 preserves 0x00–0xFF without lossy replacement. + const value = decryptedValue.toString('latin1') + // Why (STA-4300 I1): SCAN only. Nothing is emitted here — not decryptedCookies, not + // domainSet, not a staging row, not the imported count. bf6dc6fcba pushed the cookie and + // THEN applied the unreadable guard, so an unreadable row discovered late could not retract + // a sibling already emitted, and the jar-wide clear then removed more than was written back. + context.scanned.push({ + entry: { + decryptedValue, + value, + domain, + name, + path, + secure, + httpOnly, + sameSite, + expirationDate: expiresUtc > 0 ? expiresUtc : undefined, + partition + }, + sourceRow + }) + } + + for (const { entry } of context.scanned) { + context.domainSet.add(entry.domain.startsWith('.') ? entry.domain.slice(1) : entry.domain) + } + // Why (STA-4797): the import may only destroy what it is replacing. Naming the scope from the + // plan — the same rows the writes come from — is what keeps the removal set from drifting past + // the write set, and it is derived here rather than at the clear because the staged image below + // has to be cleared to the identical scope. + context.importScope = importedDomainScope([...context.domainSet]) + + // Why (STA-4797): the staged image must carry the same imported-domain scope as the live clear. + // Cold-start replay uses it to replace only those rows and preserve newer unrelated sessions. + if (context.stagingDb && context.insertStmt) { + try { + prepareStagedCookiesForImport(context.stagingDb, context.importScope) + } catch (err) { + context.disableStaging(String(err)) + } + } + + // EMIT: everything downstream derives from the plan, so there is no second place a row can + // leak in. + for (const { entry, sourceRow } of context.scanned) { + context.decryptedCookies.push(entry) + if (context.insertStmt && targetColumnInfo) { + try { + const params = buildChromiumCookieInsertParams( + targetColumnInfo, + sourceRow, + entry.decryptedValue + ) + context.insertStmt.run(...params) + } catch (err) { + context.disableStaging(String(err)) + } + } + // Why: counts importable cookies, not staged rows — the summary must stay truthful when + // the optional staging DB is unavailable. + context.imported++ + } + + diag( + ` skipped ${context.integritySkipped} Google integrity cookies (SIDCC/STRP/AEC) and ${context.nonTransplantableSkipped} non-transplantable-domain cookies` + ) + context.googleCookiesSkipped = context.integritySkipped + context.nonTransplantableSkipped + context.undecryptableWarning = buildUndecryptableWarning({ + decryptFailed: context.decryptFailed, + appBoundFailed: context.appBoundFailed, + keyringUnavailableFailed: context.keyringUnavailableFailed + }) + + // Why: an older remote client ignores the new counter and would present this loss as success. + // Placed before the early return and before any jar mutation, so a client that cannot render + // the skip fails the import outright rather than reporting a partial import as complete. + if (context.partitionSkipped > 0 && context.options.canReportPartitionSkippedCookies === false) { + context.closeStagingDb() + context.discardStagingFile() + return { + ok: false, + reason: + 'This Orca client cannot report cookies skipped for an unreadable site partition. Update Orca on this device and try again.' + } + } + return null +} diff --git a/src/main/browser/browser-cookie-chromium-types.ts b/src/main/browser/browser-cookie-chromium-types.ts new file mode 100644 index 00000000000..9bf792de01c --- /dev/null +++ b/src/main/browser/browser-cookie-chromium-types.ts @@ -0,0 +1,83 @@ +import type { Session } from 'electron' +import type { DatabaseSync } from 'node:sqlite' +import type { + BrowserCookieImportResult, + BrowserCookieImportSummary +} from '../../shared/browser-workspace-types' +import type { DetectedBrowser } from './browser-cookie-detection-types' +import type { CookieImportOptions } from './browser-cookie-import-pipeline' +import type { + ImportedCookieFields, + ImportWritePhase, + SourceCookieToWrite +} from './browser-cookie-import-write' +import type { SourcePartitionRead } from './browser-cookie-source-partition' +import type { ImportedDomainScope } from './browser-cookie-import-policy' +import type { ChromiumCookieSnapshot } from './chromium-cookie-snapshot' +import type { ChromiumCookieColumnInfo, EncryptionKeyResult } from './browser-cookie-sqlite' + +export type ChromiumSourceRow = Record + +export type DecryptedCookie = Omit & { + decryptedValue: Buffer + sameSite: 'unspecified' | 'no_restriction' | 'lax' | 'strict' + partition: SourcePartitionRead +} + +export type ScannedChromiumCookie = { + entry: DecryptedCookie + sourceRow: ChromiumSourceRow +} + +export type ChromiumImportPlan = { + writes: { sourceRow: ChromiumSourceRow; domain: string; partition: SourcePartitionRead }[] + skips: unknown[] + skippedFamilies: Set + hasUnrepresentableSkip: boolean +} + +export type ChromiumImportContext = { + browser: DetectedBrowser + targetPartition: string + options: CookieImportOptions + targetSession: Session + stagingCookiesPath: string + stagingAvailable: boolean + sourceSnapshot: ChromiumCookieSnapshot + sourceDb: InstanceType | null + stagingDb: InstanceType | null + targetColumnInfo: ChromiumCookieColumnInfo[] | null + colList: string | null + placeholders: string | null + sourceColumns: Set + sourceRows: ChromiumSourceRow[] + nativePlan: ChromiumImportPlan + plannedSourceRows: Set + partitionBySourceRow: Map + sourceKey: EncryptionKeyResult | null + imported: number + skipped: number + decryptFailed: number + appBoundFailed: number + keyringUnavailableFailed: number + integritySkipped: number + nonTransplantableSkipped: number + partitionSkipped: number + googleCookiesSkipped: number + memoryLoaded: number + memoryFailed: number + domainSet: Set + decryptedCookies: DecryptedCookie[] + scanned: ScannedChromiumCookie[] + sourceDomainValidity: Map + insertStmt: ReturnType['prepare']> | null + importScope: ImportedDomainScope + closeStagingDb: () => void + discardStagingFile: () => void + disableStaging: (reason: string) => void + undecryptableWarning?: BrowserCookieImportSummary['warning'] + warning?: BrowserCookieImportSummary['warning'] + writePhase?: ImportWritePhase + writable?: SourceCookieToWrite[] + result?: BrowserCookieImportResult +} diff --git a/src/main/browser/browser-cookie-decryption.ts b/src/main/browser/browser-cookie-decryption.ts new file mode 100644 index 00000000000..04fb002f7f4 --- /dev/null +++ b/src/main/browser/browser-cookie-decryption.ts @@ -0,0 +1,126 @@ +import { createDecipheriv } from 'node:crypto' +import type { BrowserCookieImportSummary } from '../../shared/browser-workspace-types' +import type { EncryptionKeyResult } from './browser-cookie-sqlite' + +// Why: Chromium 127+ prepends a 32-byte HMAC before the value; a hash is ~half non-printable, so ≥8 non-printable of the first 32 bytes flags the prefix. +const CHROMIUM_COOKIE_HMAC_LEN = 32 + +function hasHmacPrefix(buf: Buffer): boolean { + if (buf.length <= CHROMIUM_COOKIE_HMAC_LEN) { + return false + } + let nonPrintable = 0 + for (let i = 0; i < CHROMIUM_COOKIE_HMAC_LEN; i++) { + if (buf[i] < 0x20 || buf[i] > 0x7e) { + nonPrintable++ + } + } + return nonPrintable >= 8 +} + +function stripHmac(buf: Buffer): Buffer { + return hasHmacPrefix(buf) ? buf.subarray(CHROMIUM_COOKIE_HMAC_LEN) : buf +} + +// Why: the version prefix is the only thing that survives a failed decrypt, so read it once and +// share it between the decrypt path and the failure attribution. +export function cookieEncryptionVersion(encryptedBuffer: Buffer): string | null { + if (encryptedBuffer.length < 3) { + return null + } + const version = encryptedBuffer.subarray(0, 3).toString('utf-8') + return /^v\d\d$/.test(version) ? version : null +} + +// Why: Chrome/Edge 140+ on Windows prefix every cookie with `v20` (app-bound encryption), which +// only the writing browser can unwrap. Classify it before decrypt so it is not folded into corruption. +export function isAppBoundEncryptedCookie(encryptedBuffer: Buffer): boolean { + return cookieEncryptionVersion(encryptedBuffer) === 'v20' +} + +// Why: a named cause must carry only its exact count; tied causes fall back to unknown. +export function buildUndecryptableWarning(counts: { + decryptFailed: number + appBoundFailed: number + keyringUnavailableFailed: number +}): BrowserCookieImportSummary['warning'] { + if (counts.decryptFailed === 0) { + return undefined + } + const unknownFailed = + counts.decryptFailed - counts.appBoundFailed - counts.keyringUnavailableFailed + const rankedCauses = [ + { reason: 'app-bound-encryption' as const, count: counts.appBoundFailed }, + { reason: 'linux-keyring-unavailable' as const, count: counts.keyringUnavailableFailed }, + { reason: 'unknown' as const, count: unknownFailed } + ].sort((left, right) => right.count - left.count) + const [dominant, runnerUp] = rankedCauses + + if (dominant.reason === 'unknown' || dominant.count === runnerUp.count) { + return { code: 'cookies-undecryptable', failedCookies: counts.decryptFailed, reason: 'unknown' } + } + + const otherFailedCookies = counts.decryptFailed - dominant.count + return { + code: 'cookies-undecryptable', + failedCookies: dominant.count, + reason: dominant.reason, + ...(otherFailedCookies > 0 ? { otherFailedCookies } : {}) + } +} + +export function decryptCookieValueRaw( + encryptedBuffer: Buffer, + keyResult: EncryptionKeyResult +): Buffer | null { + if (!encryptedBuffer || encryptedBuffer.length === 0) { + return null + } + const version = encryptedBuffer.subarray(0, 3).toString('utf-8') + if (!/^v\d\d$/.test(version)) { + return null + } + + if (keyResult.mode === 'aes-256-gcm') { + return decryptAes256Gcm(encryptedBuffer.subarray(3), keyResult.key) + } + + // AES-128-CBC (macOS and Linux) + const key = version === 'v10' || version === 'v11' ? keyResult.keysByVersion[version] : undefined + if (!key) { + return null + } + + const ciphertext = encryptedBuffer.subarray(3) + if (!ciphertext.length) { + return null + } + + try { + const iv = Buffer.alloc(16, ' ') + const decipher = createDecipheriv('aes-128-cbc', key, iv) + decipher.setAutoPadding(true) + const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]) + return stripHmac(decrypted) + } catch { + return null + } +} + +function decryptAes256Gcm(payload: Buffer, key: Buffer): Buffer | null { + // Why: Windows AES-256-GCM layout is: [12-byte nonce][ciphertext][16-byte auth tag] + if (payload.length < 12 + 16) { + return null + } + const nonce = payload.subarray(0, 12) + const authTag = payload.subarray(-16) + const ciphertext = payload.subarray(12, -16) + try { + const decipher = createDecipheriv('aes-256-gcm', key, nonce) + decipher.setAuthTag(authTag) + const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]) + return stripHmac(decrypted) + } catch { + return null + } +} diff --git a/src/main/browser/browser-cookie-detection-types.ts b/src/main/browser/browser-cookie-detection-types.ts new file mode 100644 index 00000000000..bac46f6d750 --- /dev/null +++ b/src/main/browser/browser-cookie-detection-types.ts @@ -0,0 +1,224 @@ +import { existsSync, readFileSync, readdirSync } from 'node:fs' +import { join } from 'node:path' +import type { BrowserSessionProfileSource } from '../../shared/browser-workspace-types' + +export type BrowserProfile = { + name: string + directory: string +} + +export type DetectedBrowser = { + family: BrowserSessionProfileSource['browserFamily'] + label: string + cookiesPath: string + keychainService?: string + keychainAccount?: string + profiles: BrowserProfile[] + selectedProfile: string +} + +export type ChromiumBrowserDef = { + family: BrowserSessionProfileSource['browserFamily'] + label: string + keychainService: string + keychainAccount: string + // Per-platform data-dir roots, resolved at detection time via browserRootPath(). + macRoot?: string + winRoot?: string + linuxRoot?: string +} + +export const CHROMIUM_BROWSERS: ChromiumBrowserDef[] = [ + { + family: 'chrome', + label: 'Google Chrome', + keychainService: 'Chrome Safe Storage', + keychainAccount: 'Chrome', + macRoot: 'Google/Chrome', + winRoot: 'Google/Chrome/User Data', + linuxRoot: 'google-chrome' + }, + { + family: 'edge', + label: 'Microsoft Edge', + keychainService: 'Microsoft Edge Safe Storage', + keychainAccount: 'Microsoft Edge', + macRoot: 'Microsoft Edge', + winRoot: 'Microsoft/Edge/User Data', + linuxRoot: 'microsoft-edge' + }, + { + family: 'arc', + label: 'Arc', + keychainService: 'Arc Safe Storage', + keychainAccount: 'Arc', + macRoot: 'Arc/User Data' + }, + { + family: 'chromium', + label: 'Brave', + keychainService: 'Brave Safe Storage', + keychainAccount: 'Brave', + macRoot: 'BraveSoftware/Brave-Browser', + winRoot: 'BraveSoftware/Brave-Browser/User Data', + linuxRoot: 'BraveSoftware/Brave-Browser' + }, + { + family: 'comet', + label: 'Comet', + keychainService: 'Comet Safe Storage', + keychainAccount: 'Comet', + macRoot: 'Comet', + winRoot: 'Comet/User Data' + // linuxRoot intentionally omitted — Comet does not ship a Linux build as of 2026-05-15 + }, + { + family: 'helium', + // Why: Helium breaks the ' Safe Storage' convention — its Keychain service is literally 'Helium Storage Key'. + label: 'Helium', + keychainService: 'Helium Storage Key', + keychainAccount: 'Helium', + macRoot: 'net.imput.helium' + // winRoot/linuxRoot intentionally omitted — only the macOS install is verified + } +] + +export function browserRootPath(def: ChromiumBrowserDef): string | null { + if (process.platform === 'darwin') { + if (!def.macRoot) { + return null + } + const home = process.env.HOME ?? '' + return join(home, 'Library', 'Application Support', def.macRoot) + } + if (process.platform === 'win32') { + if (!def.winRoot) { + return null + } + const localAppData = process.env.LOCALAPPDATA ?? '' + if (!localAppData) { + return null + } + return join(localAppData, def.winRoot) + } + // Linux + if (!def.linuxRoot) { + return null + } + const configHome = process.env.XDG_CONFIG_HOME ?? join(process.env.HOME ?? '', '.config') + return join(configHome, def.linuxRoot) +} + +export function isSafeBrowserProfileDirectory(directory: string): boolean { + return ( + directory.length > 0 && + directory !== '.' && + !directory.includes('\0') && + !directory.includes('/') && + !directory.includes('\\') && + !directory.includes('..') + ) +} + +// Why: Chrome's Local State profile.info_cache maps profile dirs to display names for the picker. +export function discoverProfiles(browserRoot: string): BrowserProfile[] { + try { + const localStatePath = join(browserRoot, 'Local State') + if (!existsSync(localStatePath)) { + return [{ name: 'Default', directory: 'Default' }] + } + const raw = readFileSync(localStatePath, 'utf-8') + const localState = JSON.parse(raw) + const infoCache = localState?.profile?.info_cache + if (!infoCache || typeof infoCache !== 'object') { + return [{ name: 'Default', directory: 'Default' }] + } + const profiles: BrowserProfile[] = [] + for (const [dir, info] of Object.entries(infoCache)) { + // Why: Local State is external metadata, but profile dirs become path segments. + if (!isSafeBrowserProfileDirectory(dir)) { + continue + } + const profileName = (info as { name?: string })?.name ?? dir + profiles.push({ name: profileName, directory: dir }) + } + return profiles.length > 0 ? profiles : [{ name: 'Default', directory: 'Default' }] + } catch { + return [{ name: 'Default', directory: 'Default' }] + } +} + +// --------------------------------------------------------------------------- +// Firefox detection +// --------------------------------------------------------------------------- + +export function firefoxProfilesRoot(): string | null { + if (process.platform === 'darwin') { + const home = process.env.HOME ?? '' + return join(home, 'Library', 'Application Support', 'Firefox', 'Profiles') + } + if (process.platform === 'win32') { + const appData = process.env.APPDATA ?? '' + return appData ? join(appData, 'Mozilla', 'Firefox', 'Profiles') : null + } + const home = process.env.HOME ?? '' + return join(home, '.mozilla', 'firefox') +} + +export function discoverFirefoxProfiles(): BrowserProfile[] { + const profilesRoot = firefoxProfilesRoot() + if (!profilesRoot) { + return [] + } + try { + if (!existsSync(profilesRoot)) { + return [] + } + const entries = readdirSync(profilesRoot, { withFileTypes: true }) + .filter((e) => e.isDirectory()) + .map((e) => e.name) + // Why: Firefox dirs are named .; prefer 'default-release' as the primary profile on most installs. + const sorted = entries.sort((a, b) => { + if (a.includes('default-release')) { + return -1 + } + if (b.includes('default-release')) { + return 1 + } + if (a.includes('default')) { + return -1 + } + if (b.includes('default')) { + return 1 + } + return 0 + }) + return sorted.map((dir) => { + const label = dir.includes('.') ? dir.split('.').slice(1).join('.') : dir + return { name: label, directory: dir } + }) + } catch { + return [] + } +} + +export function detectFirefox(): DetectedBrowser | null { + const profilesRoot = firefoxProfilesRoot() + if (!profilesRoot) { + return null + } + const profiles = discoverFirefoxProfiles() + for (const profile of profiles) { + const cookiesPath = join(profilesRoot, profile.directory, 'cookies.sqlite') + if (existsSync(cookiesPath)) { + return { + family: 'firefox', + label: 'Firefox', + cookiesPath, + profiles, + selectedProfile: profile.directory + } + } + } + return null +} diff --git a/src/main/browser/browser-cookie-detection.ts b/src/main/browser/browser-cookie-detection.ts new file mode 100644 index 00000000000..78c5d7c0ed8 --- /dev/null +++ b/src/main/browser/browser-cookie-detection.ts @@ -0,0 +1,127 @@ +import { existsSync } from 'node:fs' +import { join } from 'node:path' +import { resolveChromiumCookiesPath } from './chromium-cookie-path' +import { + CHROMIUM_BROWSERS, + browserRootPath, + discoverProfiles, + detectFirefox, + firefoxProfilesRoot, + isSafeBrowserProfileDirectory, + type DetectedBrowser +} from './browser-cookie-detection-types' + +// --------------------------------------------------------------------------- +// Safari detection +// --------------------------------------------------------------------------- + +export function detectSafari(): DetectedBrowser | null { + if (process.platform !== 'darwin') { + return null + } + const home = process.env.HOME ?? '' + const candidates = [ + join(home, 'Library', 'Cookies', 'Cookies.binarycookies'), + join( + home, + 'Library', + 'Containers', + 'com.apple.Safari', + 'Data', + 'Library', + 'Cookies', + 'Cookies.binarycookies' + ) + ] + for (const candidate of candidates) { + if (existsSync(candidate)) { + return { + family: 'safari', + label: 'Safari', + cookiesPath: candidate, + profiles: [{ name: 'Default', directory: 'Default' }], + selectedProfile: 'Default' + } + } + } + return null +} + +export function detectInstalledBrowsers(): DetectedBrowser[] { + const detected: DetectedBrowser[] = [] + for (const browser of CHROMIUM_BROWSERS) { + const root = browserRootPath(browser) + if (!root) { + continue + } + const profiles = discoverProfiles(root) + // Why: a browser counts as detected once a profile has a cookies DB; use the first such profile as default. + for (const profile of profiles) { + const profileDir = join(root, profile.directory) + const cookiesPath = resolveChromiumCookiesPath(profileDir) + if (cookiesPath) { + detected.push({ + family: browser.family, + label: browser.label, + keychainService: browser.keychainService, + keychainAccount: browser.keychainAccount, + cookiesPath, + profiles, + selectedProfile: profile.directory + }) + break + } + } + } + + const firefox = detectFirefox() + if (firefox) { + detected.push(firefox) + } + + const safari = detectSafari() + if (safari) { + detected.push(safari) + } + + return detected +} + +export function selectBrowserProfile( + browser: DetectedBrowser, + profileDirectory: string +): DetectedBrowser | null { + if (!isSafeBrowserProfileDirectory(profileDirectory)) { + return null + } + if (browser.family === 'firefox') { + const profilesRoot = firefoxProfilesRoot() + if (!profilesRoot) { + return null + } + const cookiesPath = join(profilesRoot, profileDirectory, 'cookies.sqlite') + if (!existsSync(cookiesPath)) { + return null + } + return { ...browser, cookiesPath, selectedProfile: profileDirectory } + } + + const browserDef = CHROMIUM_BROWSERS.find((b) => b.family === browser.family) + if (!browserDef) { + return null + } + const root = browserRootPath(browserDef) + if (!root) { + return null + } + const profileDir = join(root, profileDirectory) + const cookiesPath = resolveChromiumCookiesPath(profileDir) + if (!cookiesPath) { + return null + } + return { + ...browser, + cookiesPath, + selectedProfile: profileDirectory + } +} diff --git a/src/main/browser/browser-cookie-firefox-import.ts b/src/main/browser/browser-cookie-firefox-import.ts new file mode 100644 index 00000000000..60482b30e31 --- /dev/null +++ b/src/main/browser/browser-cookie-firefox-import.ts @@ -0,0 +1,138 @@ +import { DatabaseSync } from 'node:sqlite' +import { copyFileSync, existsSync, mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import type { BrowserCookieImportResult } from '../../shared/browser-workspace-types' +import { readFirefoxRowPartition } from './browser-cookie-source-partition' +import { + importValidatedCookies, + cookieImportTarget, + type CookieImportOptions +} from './browser-cookie-import-pipeline' +import { deriveUrl, firefoxSameSite, type ValidatedCookie } from './browser-cookie-validation' +import type { DetectedBrowser } from './browser-cookie-detection-types' +import { diag } from './browser-cookie-import-diagnostics' + +// --------------------------------------------------------------------------- +// Firefox import +// --------------------------------------------------------------------------- + +export async function importCookiesFromFirefox( + browser: DetectedBrowser, + targetPartition: string, + options: CookieImportOptions +): Promise { + diag(`importCookiesFromFirefox: partition="${targetPartition}"`) + + const tmpDir = mkdtempSync(join(tmpdir(), 'orca-cookie-import-')) + const tmpCookiesPath = join(tmpDir, 'cookies.sqlite') + + try { + copyFileSync(browser.cookiesPath, tmpCookiesPath) + for (const suffix of ['-wal', '-shm'] as const) { + const sidecar = browser.cookiesPath + suffix + if (existsSync(sidecar)) { + try { + copyFileSync(sidecar, tmpCookiesPath + suffix) + } catch { + /* best-effort */ + } + } + } + } catch { + rmSync(tmpDir, { recursive: true, force: true }) + return { + ok: false, + reason: 'Could not copy Firefox cookies database. Try closing Firefox first.' + } + } + + try { + const db = new DatabaseSync(tmpCookiesPath, { readOnly: true }) + type FirefoxRow = Record & { + name: string + value: string + host: string + path: string + expiry: number + isSecure: number + isHttpOnly: number + sameSite: number + isPartitionedAttributeSet?: number + } + // Why: selecting a column an older moz_cookies schema lacks fails the whole import. A schema + // without the server-declared partition flag predates that cookie identity. + const firefoxColumns = new Set( + (db.prepare('PRAGMA table_info(moz_cookies)').all() as { name: string }[]).map( + (column) => column.name + ) + ) + const partitionColumn = firefoxColumns.has('isPartitionedAttributeSet') + ? ', isPartitionedAttributeSet' + : '' + const rows = db + .prepare( + `SELECT name, value, host, path, expiry, isSecure, isHttpOnly, sameSite${partitionColumn} FROM moz_cookies` + ) + .all() as FirefoxRow[] + db.close() + + diag(` Firefox source has ${rows.length} cookies`) + if (rows.length === 0) { + rmSync(tmpDir, { recursive: true, force: true }) + return { ok: false, reason: 'No cookies found in Firefox.' } + } + + const now = Math.floor(Date.now() / 1000) + const validated: ValidatedCookie[] = [] + for (const row of rows) { + if (!row.name || !row.host) { + continue + } + if (row.expiry > 0 && row.expiry < now) { + continue + } + + const domain = row.host + const secure = row.isSecure === 1 + const url = deriveUrl(domain, secure) + if (!url) { + continue + } + + validated.push({ + url, + name: row.name, + value: row.value ?? '', + domain, + path: row.path || '/', + secure, + httpOnly: row.isHttpOnly === 1, + sameSite: firefoxSameSite(row.sameSite), + expirationDate: row.expiry > 0 ? row.expiry : undefined, + partition: readFirefoxRowPartition(row, firefoxColumns) + }) + } + + rmSync(tmpDir, { recursive: true, force: true }) + + if (validated.length === 0) { + return { ok: false, reason: 'No valid cookies found in Firefox.' } + } + + return importValidatedCookies( + validated, + rows.length, + cookieImportTarget(targetPartition), + 'replace-imported-domains', + options + ) + } catch (err) { + rmSync(tmpDir, { recursive: true, force: true }) + diag(` Firefox import failed: ${String(err)}`) + return { + ok: false, + reason: 'Could not import cookies from Firefox. Try closing Firefox first.' + } + } +} diff --git a/src/main/browser/browser-cookie-import-diagnostics.ts b/src/main/browser/browser-cookie-import-diagnostics.ts new file mode 100644 index 00000000000..f80850fc58e --- /dev/null +++ b/src/main/browser/browser-cookie-import-diagnostics.ts @@ -0,0 +1,55 @@ +import { app } from 'electron' +import { appendFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +// Why: write the diag log to userData, not world-readable /tmp, so only the current user can read it. +let _diagLog: string | null = null +export function getDiagLogPath(): string { + if (!_diagLog) { + try { + _diagLog = join(app.getPath('userData'), 'cookie-import-diag.log') + } catch { + _diagLog = join(tmpdir(), 'orca-cookie-import-diag.log') + } + } + return _diagLog +} +export function reasonWithDiagLog(reason: string): string { + return `${reason} Details were written to ${getDiagLogPath()}.` +} +const COOKIE_IMPORT_ERROR_SUMMARY_MAX_CHARS = 180 +const COOKIE_IMPORT_ERROR_SCAN_MAX_CHARS = 512 + +// Why: error messages can embed large pasted/file payloads; cap the scan since diagnostics only need a short preview. +export function summarizeCookieImportError(err: unknown): string { + const raw = err instanceof Error && err.message ? err.message : String(err) + let summary = '' + let previousWasWhitespace = false + const scanLimit = Math.min(raw.length, COOKIE_IMPORT_ERROR_SCAN_MAX_CHARS) + for (let index = 0; index < scanLimit; index += 1) { + const code = raw.charCodeAt(index) + if (code === 32 || (code >= 9 && code <= 13)) { + if (summary.length > 0 && !previousWasWhitespace) { + summary += ' ' + } + previousWasWhitespace = true + continue + } + summary += raw.charAt(index) + if (summary.length >= COOKIE_IMPORT_ERROR_SUMMARY_MAX_CHARS) { + return summary.slice(0, COOKIE_IMPORT_ERROR_SUMMARY_MAX_CHARS) + } + previousWasWhitespace = false + } + return summary +} +export function diag(msg: string): void { + const line = `[${new Date().toISOString()}] ${msg}\n` + try { + appendFileSync(getDiagLogPath(), line) + } catch { + /* best-effort */ + } + console.log('[cookie-import]', msg) +} diff --git a/src/main/browser/browser-cookie-import-pipeline.ts b/src/main/browser/browser-cookie-import-pipeline.ts new file mode 100644 index 00000000000..c1d5deb769b --- /dev/null +++ b/src/main/browser/browser-cookie-import-pipeline.ts @@ -0,0 +1,300 @@ +import { dialog, session, type BrowserWindow } from 'electron' +import type { + BrowserCookieImportResult, + BrowserCookieImportSummary +} from '../../shared/browser-workspace-types' +import { + isGoogleSourceBoundCookie, + isNonTransplantableCookieDomain, + normalizeCookieImportDomain, + replaceCookiesForImportedDomains, + type CookieImportMode, + type ReplacedImportedDomainCookies +} from './browser-cookie-import-policy' +import { + acquireCookieMutationLock, + type CookieClearStore, + type CookieImportWriteStore +} from './browser-cookie-import-clear' +import { openCookieClearStore } from './browser-cookie-clear-store' +import { + emptyImportWritePhase, + planImportWrites, + writeImportedCookies, + type ImportWritePhase +} from './browser-cookie-import-write' +import { readFile } from 'node:fs/promises' +import { + diag, + reasonWithDiagLog, + summarizeCookieImportError +} from './browser-cookie-import-diagnostics' +import { + validateCookieEntry, + type RawCookieEntry, + type ValidatedCookie +} from './browser-cookie-validation' + +// Why (STA-4300): the import writes get a store with no `set` on it and no Session behind it, so +// the partition-dropping write is not merely unused here — it cannot be reached. +export type CookieImportSessionStore = CookieClearStore & + CookieImportWriteStore & { dispose: () => void } + +export type CookieImportTarget = { + partition: string + // Why (STA-4601): the live-jar lock is keyed on an object, and this path no longer holds the + // Session that STA-4300 moved behind openWriteStore. session.fromPartition returns the SAME + // instance for one partition string, so carrying that instance here is what keeps this path's + // lock and the native path's lock on ONE key — a fresh object per call would serialise nothing. + mutationLockOwner: object + openWriteStore: () => CookieImportSessionStore +} + +export type CookieImportOptions = { + canReportPartitionSkippedCookies?: boolean +} + +export function cookieImportTarget(targetPartition: string): CookieImportTarget { + const targetSession = session.fromPartition(targetPartition) + return { + partition: targetPartition, + mutationLockOwner: targetSession, + openWriteStore: () => openCookieClearStore(targetSession) + } +} + +export async function importValidatedCookies( + cookies: ValidatedCookie[], + totalInput: number, + target: CookieImportTarget, + mode: CookieImportMode, + options: CookieImportOptions = {} +): Promise { + const targetPartition = target.partition + const importDomainCache = new Map() + const validDomainCookies = cookies.filter((cookie) => { + let valid = importDomainCache.get(cookie.domain) + if (valid === undefined) { + valid = normalizeCookieImportDomain(cookie.domain) !== null + importDomainCache.set(cookie.domain, valid) + } + return valid + }) + const sourceBoundFiltered = validDomainCookies.filter( + (cookie) => !isGoogleSourceBoundCookie(cookie.name, cookie.domain) + ) + // Why: dropping these before the replace scope is computed is what keeps the existing + // Google session intact — replaceCookiesForImportedDomains only clears domains we import. + const importableCookies = sourceBoundFiltered.filter( + (cookie) => !isNonTransplantableCookieDomain(cookie.domain) + ) + const integritySkipped = validDomainCookies.length - sourceBoundFiltered.length + const nonTransplantableSkipped = sourceBoundFiltered.length - importableCookies.length + const googleCookiesSkipped = integritySkipped + nonTransplantableSkipped + const invalidDomainSkipped = cookies.length - validDomainCookies.length + diag( + `importValidatedCookies: ${cookies.length} validated, ${invalidDomainSkipped} unsafe-domain skipped, ${integritySkipped} source-bound skipped, ${nonTransplantableSkipped} non-transplantable skipped of ${totalInput} total, partition="${targetPartition}"` + ) + // Why (STA-4300 I1): every cookie's fate is decided here, before the jar is opened. The plan is + // the single value the write set AND the removal scope both derive from, so they cannot drift + // apart the way they did in bf6dc6fcba. + const plan = planImportWrites(importableCookies) + + // Why (§4.3c): a family we cannot name is one we cannot exclude from the removal scope, and + // clearing a family we cannot protect is the P0. Refuse before touching anything. + if (plan.hasUnrepresentableSkip) { + return { + ok: false, + reason: + 'Could not import: a cookie with an unreadable site partition has no registrable domain, so its existing session cannot be protected.' + } + } + + // Why: an older remote client cannot surface this skip, so fail before opening the target jar. + if (options.canReportPartitionSkippedCookies === false && plan.skips.length > 0) { + return { + ok: false, + reason: + 'This Orca client cannot report cookies skipped for an unreadable site partition. Update Orca on this device and try again.' + } + } + // Why: a family-suppressed sibling is a partition skip too, so partitionSkippedCookies is a + // BREAKDOWN of skippedCookies and is added into it exactly once — never a separate addend, or + // totalCookies === importedCookies + skippedCookies silently stops holding. + const partitionSkipped = plan.skips.length + let skipped = totalInput - importableCookies.length + partitionSkipped + let phase: ImportWritePhase = emptyImportWritePhase() + // Why (STA-4097/STA-4300): both the rollback and the import writes need CDP identities — only + // they carry partitionKey. cookies.set drops it silently, on the success path as well. + const cookieClearStore = plan.writes.length > 0 ? target.openWriteStore() : null + + if (cookieClearStore) { + // Why (STA-4601): the replace, the writes, and the rollback are one live-jar transaction. + // Releasing after the replace lets a second import interleave, so this run's rollback could + // remove cookies the newer import already wrote and reported as imported. Taken AFTER the + // store is opened on purpose — openWriteStore only builds the adapter, it attaches no + // debugger, so holding it while queued cannot deadlock against the holder. + const releaseMutationLock = await acquireCookieMutationLock(target.mutationLockOwner) + let replaced: ReplacedImportedDomainCookies | null = null + try { + if (mode === 'replace-imported-domains') { + try { + // Why (STA-4300 I2 / §2b): the removal scope is the write set. Filtering per exact + // cookie is NOT enough — replaceCookiesForImportedDomains expands each imported domain + // into its descendant roots, so a readable apex cookie would drag a skipped subdomain's + // live session into the removal scope with nothing written back. plan.writes is already + // family-closed, and using the same array for both makes them impossible to diverge. + const replacementDomains = plan.writes.map((cookie) => cookie.domain) + replaced = await replaceCookiesForImportedDomains(cookieClearStore, replacementDomains) + diag(` removed ${replaced.removed.length} existing cookies in imported domain scopes`) + } catch (err) { + diag(` existing cookie replacement failed: ${summarizeCookieImportError(err)}`) + return { + ok: false, + reason: reasonWithDiagLog('Could not replace existing cookies for the imported sites.') + } + } + } + + // Why: Chromium rejects any non-printable-ASCII byte in a cookie value; strip as a safety net. + const stripNonPrintable = (s: string): string => s.replace(/[^\x20-\x7E]/g, '') + phase = await writeImportedCookies( + cookieClearStore, + plan.writes.map((cookie) => ({ ...cookie, value: stripNonPrintable(cookie.value) })), + { stopOnFailure: replaced !== null, log: diag } + ) + // Why: plan.skips holds every partition-driven skip — the unreadable rows AND the readable + // siblings suppressed by family closure. phase.partitionSkipped is 0 now that only planned + // writes reach the writer, so the count comes from the plan and is added exactly once. + skipped += phase.writeRejected + + if (phase.failure && replaced) { + const rollbackFailures: unknown[] = [] + for (const cookie of phase.attemptedKeys.toReversed()) { + try { + await cookieClearStore.remove(cookie.url, cookie.name) + } catch (err) { + rollbackFailures.push(err) + } + } + // Why: restoreClearIdentities attaches the debugger before it iterates, so an empty + // restore set would spin up a hidden BrowserWindow to put nothing back. + if (replaced.identities.length > 0) { + try { + await cookieClearStore.restoreClearIdentities(replaced.identities.toReversed()) + } catch (err) { + rollbackFailures.push(err) + } + } + if (rollbackFailures.length > 0) { + diag(` cookie replacement rollback failed: ${rollbackFailures.length} operation(s)`) + } + return { + ok: false, + reason: reasonWithDiagLog('Could not safely replace cookies for the imported sites.') + } + } + } finally { + try { + cookieClearStore.dispose() + } finally { + releaseMutationLock() + } + } + } + + diag( + `importValidatedCookies result: imported=${phase.importedCount} skipped=${skipped} partition-unreadable=${partitionSkipped} domains=${phase.domains.size}` + ) + + const summary: BrowserCookieImportSummary = { + totalCookies: totalInput, + importedCookies: phase.importedCount, + skippedCookies: skipped, + ...(googleCookiesSkipped > 0 ? { googleCookiesSkipped } : {}), + ...(partitionSkipped > 0 ? { partitionSkippedCookies: partitionSkipped } : {}), + domains: [...phase.domains].sort() + } + + return { ok: true, profileId: '', summary } +} + +// --------------------------------------------------------------------------- +// Import from JSON file +// --------------------------------------------------------------------------- + +// Why: use a main-owned native dialog so a compromised renderer can't turn import into arbitrary file reads. +export async function pickCookieFile(parentWindow: BrowserWindow | null): Promise { + const opts = { + title: 'Import Cookies', + filters: [ + { name: 'Cookie Files', extensions: ['json'] }, + { name: 'All Files', extensions: ['*'] } + ], + properties: ['openFile' as const] + } + const result = parentWindow + ? await dialog.showOpenDialog(parentWindow, opts) + : await dialog.showOpenDialog(opts) + + if (result.canceled || result.filePaths.length === 0) { + return null + } + return result.filePaths[0] +} + +export async function importCookiesFromFile( + filePath: string, + targetPartition: string +): Promise { + let rawContent: string + try { + rawContent = await readFile(filePath, 'utf-8') + } catch { + return { ok: false, reason: 'Could not read the selected file.' } + } + + let parsed: unknown + try { + parsed = JSON.parse(rawContent) + } catch { + return { ok: false, reason: 'File is not valid JSON.' } + } + + if (!Array.isArray(parsed)) { + return { ok: false, reason: 'Expected a JSON array of cookie objects.' } + } + + if (parsed.length === 0) { + return { ok: false, reason: 'Cookie file is empty.' } + } + + const validated: ValidatedCookie[] = [] + let skipped = 0 + for (const entry of parsed) { + if (typeof entry !== 'object' || entry === null) { + skipped++ + continue + } + const cookie = validateCookieEntry(entry as RawCookieEntry) + if (cookie) { + validated.push(cookie) + } else { + skipped++ + } + } + + if (validated.length === 0) { + return { + ok: false, + reason: `No valid cookies found. ${skipped} entries were skipped due to missing or invalid fields.` + } + } + + return importValidatedCookies( + validated, + parsed.length, + cookieImportTarget(targetPartition), + 'replace-imported-domains' + ) +} diff --git a/src/main/browser/browser-cookie-import.test.ts b/src/main/browser/browser-cookie-import.test.ts index f36efb3fc4f..52662b2366d 100644 --- a/src/main/browser/browser-cookie-import.test.ts +++ b/src/main/browser/browser-cookie-import.test.ts @@ -4,7 +4,7 @@ import type * as NodeFs from 'node:fs' const { appGetPathMock, copyFileSyncMock, - execFileSyncMock, + runProcessSyncMock, sessionFromPartitionMock, dialogShowOpenDialogMock, setPendingCookieImportMock, @@ -13,7 +13,7 @@ const { } = vi.hoisted(() => ({ appGetPathMock: vi.fn(), copyFileSyncMock: vi.fn(), - execFileSyncMock: vi.fn(), + runProcessSyncMock: vi.fn(), sessionFromPartitionMock: vi.fn(), dialogShowOpenDialogMock: vi.fn(), setPendingCookieImportMock: vi.fn(), @@ -28,7 +28,11 @@ vi.mock('./browser-session-registry', () => ({ } })) -vi.mock('node:child_process', () => ({ execFileSync: execFileSyncMock })) +// Why mock the chokepoint: command timeouts and hidden-console handling belong to +// runProcessSync, while this suite only needs to control the credential output. +vi.mock('../../shared/child-process/run-process', () => ({ + runProcessSync: runProcessSyncMock +})) vi.mock('node:fs', async (importOriginal) => { const actual = await importOriginal() return { @@ -478,8 +482,8 @@ describe('importCookiesFromBrowser Chromium', () => { copyFileSyncMock.mockClear() setPendingCookieImportMock.mockClear() clearPendingCookieImportMock.mockClear() - execFileSyncMock.mockReset() - execFileSyncMock.mockImplementation(() => { + runProcessSyncMock.mockReset() + runProcessSyncMock.mockImplementation(() => { throw new Error('OS credential commands are unavailable in this test') }) sessionFromPartitionMock.mockReset() @@ -514,12 +518,6 @@ describe('importCookiesFromBrowser Chromium', () => { ]).close() const platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin') - execFileSyncMock.mockImplementation((command: string) => { - if (command === 'defaults') { - return '120.0.6099.71\n' - } - throw new Error(`Unexpected command: ${command}`) - }) try { expect(existsSync(`${sourceCookiesPath}-wal`)).toBe(true) const sourceFilesBefore = ['', '-wal', '-shm'].map((suffix) => @@ -539,8 +537,7 @@ describe('importCookiesFromBrowser Chromium', () => { value: 'source-value' }) ) - expect(execFileSyncMock.mock.calls.some(([command]) => command === 'security')).toBe(false) - expect(execFileSyncMock.mock.calls.some(([command]) => command === 'defaults')).toBe(false) + expect(runProcessSyncMock).not.toHaveBeenCalled() expect(copyFileSyncMock.mock.calls.some(([source]) => source === sourceCookiesPath)).toBe( true ) @@ -573,11 +570,12 @@ describe('importCookiesFromBrowser Chromium', () => { } ]).close() createChromiumCookieTestDatabase(targetCookiesPath, []).close() - execFileSyncMock.mockImplementation((command: string) => { - if (command === 'security') { - return `${password}\n` - } - throw new Error(`Unexpected command: ${command}`) + runProcessSyncMock.mockReturnValue({ + code: 0, + signal: null, + stdout: `${password}\n`, + stderr: '', + timedOut: false }) const platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin') @@ -588,11 +586,11 @@ describe('importCookiesFromBrowser Chromium', () => { ) expect(result.ok).toBe(true) - expect(execFileSyncMock).toHaveBeenCalledWith( - 'security', - expect.any(Array), - expect.any(Object) - ) + expect(runProcessSyncMock).toHaveBeenCalledWith({ + program: 'security', + args: ['find-generic-password', '-s', 'Chrome Safe Storage', '-a', 'Chrome', '-w'], + timeoutMs: 30_000 + }) expect(cookieWriteMock).toHaveBeenCalledWith( expect.objectContaining({ name: 'sid', value: 'encrypted-value' }) ) diff --git a/src/main/browser/browser-cookie-import.ts b/src/main/browser/browser-cookie-import.ts index 752940c9c83..10929761731 100644 --- a/src/main/browser/browser-cookie-import.ts +++ b/src/main/browser/browser-cookie-import.ts @@ -1,968 +1,48 @@ -/* eslint-disable max-lines -- Why: cookie import is one pipeline (detect → decrypt → stage → swap) that must stay together to keep encryption/schema/staging in sync. */ -import { app, type BrowserWindow, dialog, session } from 'electron' -import { execFileSync } from 'node:child_process' -import { runProcessSync } from '../../shared/child-process/run-process' -import { windowsPowerShellPath } from '../../shared/child-process/windows-system-binary' -import { createDecipheriv, pbkdf2Sync, randomUUID } from 'node:crypto' +import type { BrowserWindow } from 'electron' +import type { BrowserCookieImportResult } from '../../shared/browser-workspace-types' import { - appendFileSync, - copyFileSync, - existsSync, - mkdtempSync, - mkdirSync, - readFileSync, - readdirSync, - rmSync, - unlinkSync -} from 'node:fs' -import { readFile } from 'node:fs/promises' -import { DatabaseSync } from 'node:sqlite' -import { tmpdir } from 'node:os' -import { join } from 'node:path' - -// Why: write the diag log to userData, not world-readable /tmp, so only the current user can read it. -let _diagLog: string | null = null -function getDiagLogPath(): string { - if (!_diagLog) { - try { - _diagLog = join(app.getPath('userData'), 'cookie-import-diag.log') - } catch { - _diagLog = join(tmpdir(), 'orca-cookie-import-diag.log') - } - } - return _diagLog -} -function reasonWithDiagLog(reason: string): string { - return `${reason} Details were written to ${getDiagLogPath()}.` -} -const COOKIE_IMPORT_ERROR_SUMMARY_MAX_CHARS = 180 -const COOKIE_IMPORT_ERROR_SCAN_MAX_CHARS = 512 - -// Why: error messages can embed large pasted/file payloads; cap the scan since diagnostics only need a short preview. -export function summarizeCookieImportError(err: unknown): string { - const raw = err instanceof Error && err.message ? err.message : String(err) - let summary = '' - let previousWasWhitespace = false - const scanLimit = Math.min(raw.length, COOKIE_IMPORT_ERROR_SCAN_MAX_CHARS) - for (let index = 0; index < scanLimit; index += 1) { - const code = raw.charCodeAt(index) - if (code === 32 || (code >= 9 && code <= 13)) { - if (summary.length > 0 && !previousWasWhitespace) { - summary += ' ' - } - previousWasWhitespace = true - continue - } - summary += raw.charAt(index) - if (summary.length >= COOKIE_IMPORT_ERROR_SUMMARY_MAX_CHARS) { - return summary.slice(0, COOKIE_IMPORT_ERROR_SUMMARY_MAX_CHARS) - } - previousWasWhitespace = false - } - return summary -} -function diag(msg: string): void { - const line = `[${new Date().toISOString()}] ${msg}\n` - try { - appendFileSync(getDiagLogPath(), line) - } catch { - /* best-effort */ - } - console.log('[cookie-import]', msg) -} -import type { - BrowserCookieImportResult, - BrowserCookieImportSummary, - BrowserSessionProfileSource -} from '../../shared/browser-workspace-types' -import { browserSessionRegistry } from './browser-session-registry' -import { supportsPendingBrowserCookieImportReplay } from './browser-session-cookie-staging' + detectInstalledBrowsers as detectBrowsers, + selectBrowserProfile as selectProfile +} from './browser-cookie-detection' +import type { BrowserProfile, DetectedBrowser } from './browser-cookie-detection-types' import { - isGoogleSourceBoundCookie, - isNonTransplantableCookieDomain, - normalizeCookieDomain, - normalizeCookieImportDomain, - importedDomainScope, - replaceCookiesForImportedDomains, - type CookieImportMode, - type ReplacedImportedDomainCookies -} from './browser-cookie-import-policy' + pickCookieFile as pickFile, + importCookiesFromFile as importFile, + type CookieImportOptions +} from './browser-cookie-import-pipeline' +import { importChromiumCookies } from './browser-cookie-chromium-import' +import { importCookiesFromFirefox } from './browser-cookie-firefox-import' +import { importCookiesFromSafari } from './browser-cookie-safari-import' import { - acquireCookieMutationLock, - removeTransplantableCookies, - withCookieMutationLock, - type CookieClearStore, - type CookieImportWriteStore -} from './browser-cookie-import-clear' -import { openCookieClearStore } from './browser-cookie-clear-store' -import { - readChromiumRowPartition, - readFirefoxRowPartition, - readJsonCookiePartition, - type SourcePartitionRead -} from './browser-cookie-source-partition' -import { - emptyImportWritePhase, - writeImportedCookies, - type ImportedCookieFields, - type ImportWritePhase, - type SourceCookieToWrite, - planImportWrites -} from './browser-cookie-import-write' -import { - createChromiumCookieSnapshot, - type ChromiumCookieSnapshot -} from './chromium-cookie-snapshot' -import { resolveChromiumCookiesPath } from './chromium-cookie-path' -import { prepareStagedCookiesForImport } from './browser-cookie-staged-import' -import { copyFileWithWindowsRetry } from '../codex-accounts/fs-utils' + buildChromiumCookieInsertParams as buildInsertParams, + type ChromiumCookieColumnInfo +} from './browser-cookie-sqlite' +import { isAppBoundEncryptedCookie as isAppBoundCookie } from './browser-cookie-decryption' +import { summarizeCookieImportError as summarizeError } from './browser-cookie-import-diagnostics' -// --------------------------------------------------------------------------- -// Browser detection -// --------------------------------------------------------------------------- - -export type BrowserProfile = { - name: string - directory: string -} - -export type DetectedBrowser = { - family: BrowserSessionProfileSource['browserFamily'] - label: string - cookiesPath: string - keychainService?: string - keychainAccount?: string - profiles: BrowserProfile[] - selectedProfile: string -} - -type ChromiumBrowserDef = { - family: BrowserSessionProfileSource['browserFamily'] - label: string - keychainService: string - keychainAccount: string - // Per-platform data-dir roots, resolved at detection time via browserRootPath(). - macRoot?: string - winRoot?: string - linuxRoot?: string -} - -const CHROMIUM_BROWSERS: ChromiumBrowserDef[] = [ - { - family: 'chrome', - label: 'Google Chrome', - keychainService: 'Chrome Safe Storage', - keychainAccount: 'Chrome', - macRoot: 'Google/Chrome', - winRoot: 'Google/Chrome/User Data', - linuxRoot: 'google-chrome' - }, - { - family: 'edge', - label: 'Microsoft Edge', - keychainService: 'Microsoft Edge Safe Storage', - keychainAccount: 'Microsoft Edge', - macRoot: 'Microsoft Edge', - winRoot: 'Microsoft/Edge/User Data', - linuxRoot: 'microsoft-edge' - }, - { - family: 'arc', - label: 'Arc', - keychainService: 'Arc Safe Storage', - keychainAccount: 'Arc', - macRoot: 'Arc/User Data' - }, - { - family: 'chromium', - label: 'Brave', - keychainService: 'Brave Safe Storage', - keychainAccount: 'Brave', - macRoot: 'BraveSoftware/Brave-Browser', - winRoot: 'BraveSoftware/Brave-Browser/User Data', - linuxRoot: 'BraveSoftware/Brave-Browser' - }, - { - family: 'comet', - label: 'Comet', - keychainService: 'Comet Safe Storage', - keychainAccount: 'Comet', - macRoot: 'Comet', - winRoot: 'Comet/User Data' - // linuxRoot intentionally omitted — Comet does not ship a Linux build as of 2026-05-15 - }, - { - family: 'helium', - // Why: Helium breaks the ' Safe Storage' convention — its Keychain service is literally 'Helium Storage Key'. - label: 'Helium', - keychainService: 'Helium Storage Key', - keychainAccount: 'Helium', - macRoot: 'net.imput.helium' - // winRoot/linuxRoot intentionally omitted — only the macOS install is verified - } -] - -function browserRootPath(def: ChromiumBrowserDef): string | null { - if (process.platform === 'darwin') { - if (!def.macRoot) { - return null - } - const home = process.env.HOME ?? '' - return join(home, 'Library', 'Application Support', def.macRoot) - } - if (process.platform === 'win32') { - if (!def.winRoot) { - return null - } - const localAppData = process.env.LOCALAPPDATA ?? '' - if (!localAppData) { - return null - } - return join(localAppData, def.winRoot) - } - // Linux - if (!def.linuxRoot) { - return null - } - const configHome = process.env.XDG_CONFIG_HOME ?? join(process.env.HOME ?? '', '.config') - return join(configHome, def.linuxRoot) -} - -function isSafeBrowserProfileDirectory(directory: string): boolean { - return ( - directory.length > 0 && - directory !== '.' && - !directory.includes('\0') && - !directory.includes('/') && - !directory.includes('\\') && - !directory.includes('..') - ) -} - -// Why: Chrome's Local State profile.info_cache maps profile dirs to display names for the picker. -function discoverProfiles(browserRoot: string): BrowserProfile[] { - try { - const localStatePath = join(browserRoot, 'Local State') - if (!existsSync(localStatePath)) { - return [{ name: 'Default', directory: 'Default' }] - } - const raw = readFileSync(localStatePath, 'utf-8') - const localState = JSON.parse(raw) - const infoCache = localState?.profile?.info_cache - if (!infoCache || typeof infoCache !== 'object') { - return [{ name: 'Default', directory: 'Default' }] - } - const profiles: BrowserProfile[] = [] - for (const [dir, info] of Object.entries(infoCache)) { - // Why: Local State is external metadata, but profile dirs become path segments. - if (!isSafeBrowserProfileDirectory(dir)) { - continue - } - const profileName = (info as { name?: string })?.name ?? dir - profiles.push({ name: profileName, directory: dir }) - } - return profiles.length > 0 ? profiles : [{ name: 'Default', directory: 'Default' }] - } catch { - return [{ name: 'Default', directory: 'Default' }] - } -} - -// --------------------------------------------------------------------------- -// Firefox detection -// --------------------------------------------------------------------------- - -function firefoxProfilesRoot(): string | null { - if (process.platform === 'darwin') { - const home = process.env.HOME ?? '' - return join(home, 'Library', 'Application Support', 'Firefox', 'Profiles') - } - if (process.platform === 'win32') { - const appData = process.env.APPDATA ?? '' - return appData ? join(appData, 'Mozilla', 'Firefox', 'Profiles') : null - } - const home = process.env.HOME ?? '' - return join(home, '.mozilla', 'firefox') -} - -function discoverFirefoxProfiles(): BrowserProfile[] { - const profilesRoot = firefoxProfilesRoot() - if (!profilesRoot) { - return [] - } - try { - if (!existsSync(profilesRoot)) { - return [] - } - const entries = readdirSync(profilesRoot, { withFileTypes: true }) - .filter((e) => e.isDirectory()) - .map((e) => e.name) - // Why: Firefox dirs are named .; prefer 'default-release' as the primary profile on most installs. - const sorted = entries.sort((a, b) => { - if (a.includes('default-release')) { - return -1 - } - if (b.includes('default-release')) { - return 1 - } - if (a.includes('default')) { - return -1 - } - if (b.includes('default')) { - return 1 - } - return 0 - }) - return sorted.map((dir) => { - const label = dir.includes('.') ? dir.split('.').slice(1).join('.') : dir - return { name: label, directory: dir } - }) - } catch { - return [] - } -} - -function detectFirefox(): DetectedBrowser | null { - const profilesRoot = firefoxProfilesRoot() - if (!profilesRoot) { - return null - } - const profiles = discoverFirefoxProfiles() - for (const profile of profiles) { - const cookiesPath = join(profilesRoot, profile.directory, 'cookies.sqlite') - if (existsSync(cookiesPath)) { - return { - family: 'firefox', - label: 'Firefox', - cookiesPath, - profiles, - selectedProfile: profile.directory - } - } - } - return null -} - -// --------------------------------------------------------------------------- -// Safari detection -// --------------------------------------------------------------------------- - -const MAC_EPOCH_DELTA = 978_307_200 - -function detectSafari(): DetectedBrowser | null { - if (process.platform !== 'darwin') { - return null - } - const home = process.env.HOME ?? '' - const candidates = [ - join(home, 'Library', 'Cookies', 'Cookies.binarycookies'), - join( - home, - 'Library', - 'Containers', - 'com.apple.Safari', - 'Data', - 'Library', - 'Cookies', - 'Cookies.binarycookies' - ) - ] - for (const candidate of candidates) { - if (existsSync(candidate)) { - return { - family: 'safari', - label: 'Safari', - cookiesPath: candidate, - profiles: [{ name: 'Default', directory: 'Default' }], - selectedProfile: 'Default' - } - } - } - return null -} +export type { BrowserProfile, DetectedBrowser, CookieImportOptions, ChromiumCookieColumnInfo } +export { summarizeError as summarizeCookieImportError } export function detectInstalledBrowsers(): DetectedBrowser[] { - const detected: DetectedBrowser[] = [] - for (const browser of CHROMIUM_BROWSERS) { - const root = browserRootPath(browser) - if (!root) { - continue - } - const profiles = discoverProfiles(root) - // Why: a browser counts as detected once a profile has a cookies DB; use the first such profile as default. - for (const profile of profiles) { - const profileDir = join(root, profile.directory) - const cookiesPath = resolveChromiumCookiesPath(profileDir) - if (cookiesPath) { - detected.push({ - family: browser.family, - label: browser.label, - keychainService: browser.keychainService, - keychainAccount: browser.keychainAccount, - cookiesPath, - profiles, - selectedProfile: profile.directory - }) - break - } - } - } - - const firefox = detectFirefox() - if (firefox) { - detected.push(firefox) - } - - const safari = detectSafari() - if (safari) { - detected.push(safari) - } - - return detected + return detectBrowsers() } export function selectBrowserProfile( browser: DetectedBrowser, profileDirectory: string ): DetectedBrowser | null { - if (!isSafeBrowserProfileDirectory(profileDirectory)) { - return null - } - if (browser.family === 'firefox') { - const profilesRoot = firefoxProfilesRoot() - if (!profilesRoot) { - return null - } - const cookiesPath = join(profilesRoot, profileDirectory, 'cookies.sqlite') - if (!existsSync(cookiesPath)) { - return null - } - return { ...browser, cookiesPath, selectedProfile: profileDirectory } - } - - const browserDef = CHROMIUM_BROWSERS.find((b) => b.family === browser.family) - if (!browserDef) { - return null - } - const root = browserRootPath(browserDef) - if (!root) { - return null - } - const profileDir = join(root, profileDirectory) - const cookiesPath = resolveChromiumCookiesPath(profileDir) - if (!cookiesPath) { - return null - } - return { - ...browser, - cookiesPath, - selectedProfile: profileDirectory - } + return selectProfile(browser, profileDirectory) } -// --------------------------------------------------------------------------- -// Cookie validation (shared between file import and direct import) -// --------------------------------------------------------------------------- - -type RawCookieEntry = { - domain?: unknown - name?: unknown - value?: unknown - path?: unknown - secure?: unknown - httpOnly?: unknown - sameSite?: unknown - expirationDate?: unknown - partitionKey?: unknown - partitionKeyOpaque?: unknown -} - -// Why (STA-4300): `partition` is required, not optional, so every source that builds a cookie has to -// state what it read. An optional field would let a new source silently default to unpartitioned. -type ValidatedCookie = ImportedCookieFields & { - sameSite: 'unspecified' | 'no_restriction' | 'lax' | 'strict' - partition: SourcePartitionRead -} - -// Why: Chromium's CookieSameSiteForStorage enum (0=Unspecified,1=None,2=Lax,3=Strict) differs from Firefox's numbering. -function chromiumSameSite(raw: number): 'unspecified' | 'no_restriction' | 'lax' | 'strict' { - switch (raw) { - case 1: - return 'no_restriction' - case 2: - return 'lax' - case 3: - return 'strict' - default: - return 'unspecified' - } -} - -function firefoxSameSite(raw: number): 'unspecified' | 'no_restriction' | 'lax' | 'strict' { - switch (raw) { - case 0: - return 'no_restriction' - case 1: - return 'lax' - case 2: - return 'strict' - default: - return 'unspecified' - } -} - -function normalizeSameSite(raw: unknown): 'unspecified' | 'no_restriction' | 'lax' | 'strict' { - if (typeof raw === 'number') { - return chromiumSameSite(raw) - } - if (typeof raw !== 'string') { - return 'unspecified' - } - const lower = raw.toLowerCase() - if (lower === 'lax') { - return 'lax' - } - if (lower === 'strict') { - return 'strict' - } - if (lower === 'none' || lower === 'no_restriction') { - return 'no_restriction' - } - return 'unspecified' -} - -// Why: a cookie identity needs a url to scope it; derive it from domain + secure flag. -function deriveUrl(domain: string, secure: boolean): string | null { - const normalizedDomain = normalizeCookieDomain(domain) - if (!normalizedDomain) { - return null - } - const protocol = secure ? 'https' : 'http' - try { - const url = new URL(`${protocol}://${normalizedDomain}/`) - return url.toString() - } catch { - return null - } -} - -function validateCookieEntry(raw: RawCookieEntry): ValidatedCookie | null { - if (typeof raw.domain !== 'string' || raw.domain.trim().length === 0) { - return null - } - if (typeof raw.name !== 'string' || raw.name.trim().length === 0) { - return null - } - if (typeof raw.value !== 'string') { - return null - } - - const domain = raw.domain.trim() - const secure = raw.secure === true || raw.secure === 1 - const url = deriveUrl(domain, secure) - if (!url) { - return null - } - - const expirationDate = - typeof raw.expirationDate === 'number' && raw.expirationDate > 0 - ? raw.expirationDate - : undefined - - return { - url, - name: raw.name.trim(), - value: raw.value, - domain, - path: typeof raw.path === 'string' ? raw.path : '/', - secure, - httpOnly: raw.httpOnly === true || raw.httpOnly === 1, - sameSite: normalizeSameSite(raw.sameSite), - expirationDate, - partition: readJsonCookiePartition(raw.partitionKey, raw.partitionKeyOpaque) - } -} - -// Why (STA-4300): the import writes get a store with no `set` on it and no Session behind it, so -// the partition-dropping write is not merely unused here — it cannot be reached. -type CookieImportSessionStore = CookieClearStore & CookieImportWriteStore & { dispose: () => void } - -type CookieImportTarget = { - partition: string - // Why (STA-4601): the live-jar lock is keyed on an object, and this path no longer holds the - // Session that STA-4300 moved behind openWriteStore. session.fromPartition returns the SAME - // instance for one partition string, so carrying that instance here is what keeps this path's - // lock and the native path's lock on ONE key — a fresh object per call would serialise nothing. - mutationLockOwner: object - openWriteStore: () => CookieImportSessionStore -} - -type CookieImportOptions = { - canReportPartitionSkippedCookies?: boolean -} - -function cookieImportTarget(targetPartition: string): CookieImportTarget { - const targetSession = session.fromPartition(targetPartition) - return { - partition: targetPartition, - mutationLockOwner: targetSession, - openWriteStore: () => openCookieClearStore(targetSession) - } -} - -async function importValidatedCookies( - cookies: ValidatedCookie[], - totalInput: number, - target: CookieImportTarget, - mode: CookieImportMode, - options: CookieImportOptions = {} -): Promise { - const targetPartition = target.partition - const importDomainCache = new Map() - const validDomainCookies = cookies.filter((cookie) => { - let valid = importDomainCache.get(cookie.domain) - if (valid === undefined) { - valid = normalizeCookieImportDomain(cookie.domain) !== null - importDomainCache.set(cookie.domain, valid) - } - return valid - }) - const sourceBoundFiltered = validDomainCookies.filter( - (cookie) => !isGoogleSourceBoundCookie(cookie.name, cookie.domain) - ) - // Why: dropping these before the replace scope is computed is what keeps the existing - // Google session intact — replaceCookiesForImportedDomains only clears domains we import. - const importableCookies = sourceBoundFiltered.filter( - (cookie) => !isNonTransplantableCookieDomain(cookie.domain) - ) - const integritySkipped = validDomainCookies.length - sourceBoundFiltered.length - const nonTransplantableSkipped = sourceBoundFiltered.length - importableCookies.length - const googleCookiesSkipped = integritySkipped + nonTransplantableSkipped - const invalidDomainSkipped = cookies.length - validDomainCookies.length - diag( - `importValidatedCookies: ${cookies.length} validated, ${invalidDomainSkipped} unsafe-domain skipped, ${integritySkipped} source-bound skipped, ${nonTransplantableSkipped} non-transplantable skipped of ${totalInput} total, partition="${targetPartition}"` - ) - // Why (STA-4300 I1): every cookie's fate is decided here, before the jar is opened. The plan is - // the single value the write set AND the removal scope both derive from, so they cannot drift - // apart the way they did in bf6dc6fcba. - const plan = planImportWrites(importableCookies) - - // Why (§4.3c): a family we cannot name is one we cannot exclude from the removal scope, and - // clearing a family we cannot protect is the P0. Refuse before touching anything. - if (plan.hasUnrepresentableSkip) { - return { - ok: false, - reason: - 'Could not import: a cookie with an unreadable site partition has no registrable domain, so its existing session cannot be protected.' - } - } - - // Why: an older remote client cannot surface this skip, so fail before opening the target jar. - if (options.canReportPartitionSkippedCookies === false && plan.skips.length > 0) { - return { - ok: false, - reason: - 'This Orca client cannot report cookies skipped for an unreadable site partition. Update Orca on this device and try again.' - } - } - // Why: a family-suppressed sibling is a partition skip too, so partitionSkippedCookies is a - // BREAKDOWN of skippedCookies and is added into it exactly once — never a separate addend, or - // totalCookies === importedCookies + skippedCookies silently stops holding. - const partitionSkipped = plan.skips.length - let skipped = totalInput - importableCookies.length + partitionSkipped - let phase: ImportWritePhase = emptyImportWritePhase() - // Why (STA-4097/STA-4300): both the rollback and the import writes need CDP identities — only - // they carry partitionKey. cookies.set drops it silently, on the success path as well. - const cookieClearStore = plan.writes.length > 0 ? target.openWriteStore() : null - - if (cookieClearStore) { - // Why (STA-4601): the replace, the writes, and the rollback are one live-jar transaction. - // Releasing after the replace lets a second import interleave, so this run's rollback could - // remove cookies the newer import already wrote and reported as imported. Taken AFTER the - // store is opened on purpose — openWriteStore only builds the adapter, it attaches no - // debugger, so holding it while queued cannot deadlock against the holder. - const releaseMutationLock = await acquireCookieMutationLock(target.mutationLockOwner) - let replaced: ReplacedImportedDomainCookies | null = null - try { - if (mode === 'replace-imported-domains') { - try { - // Why (STA-4300 I2 / §2b): the removal scope is the write set. Filtering per exact - // cookie is NOT enough — replaceCookiesForImportedDomains expands each imported domain - // into its descendant roots, so a readable apex cookie would drag a skipped subdomain's - // live session into the removal scope with nothing written back. plan.writes is already - // family-closed, and using the same array for both makes them impossible to diverge. - const replacementDomains = plan.writes.map((cookie) => cookie.domain) - replaced = await replaceCookiesForImportedDomains(cookieClearStore, replacementDomains) - diag(` removed ${replaced.removed.length} existing cookies in imported domain scopes`) - } catch (err) { - diag(` existing cookie replacement failed: ${summarizeCookieImportError(err)}`) - return { - ok: false, - reason: reasonWithDiagLog('Could not replace existing cookies for the imported sites.') - } - } - } - - // Why: Chromium rejects any non-printable-ASCII byte in a cookie value; strip as a safety net. - const stripNonPrintable = (s: string): string => s.replace(/[^\x20-\x7E]/g, '') - phase = await writeImportedCookies( - cookieClearStore, - plan.writes.map((cookie) => ({ ...cookie, value: stripNonPrintable(cookie.value) })), - { stopOnFailure: replaced !== null, log: diag } - ) - // Why: plan.skips holds every partition-driven skip — the unreadable rows AND the readable - // siblings suppressed by family closure. phase.partitionSkipped is 0 now that only planned - // writes reach the writer, so the count comes from the plan and is added exactly once. - skipped += phase.writeRejected - - if (phase.failure && replaced) { - const rollbackFailures: unknown[] = [] - for (const cookie of phase.attemptedKeys.toReversed()) { - try { - await cookieClearStore.remove(cookie.url, cookie.name) - } catch (err) { - rollbackFailures.push(err) - } - } - // Why: restoreClearIdentities attaches the debugger before it iterates, so an empty - // restore set would spin up a hidden BrowserWindow to put nothing back. - if (replaced.identities.length > 0) { - try { - await cookieClearStore.restoreClearIdentities(replaced.identities.toReversed()) - } catch (err) { - rollbackFailures.push(err) - } - } - if (rollbackFailures.length > 0) { - diag(` cookie replacement rollback failed: ${rollbackFailures.length} operation(s)`) - } - return { - ok: false, - reason: reasonWithDiagLog('Could not safely replace cookies for the imported sites.') - } - } - } finally { - try { - cookieClearStore.dispose() - } finally { - releaseMutationLock() - } - } - } - - diag( - `importValidatedCookies result: imported=${phase.importedCount} skipped=${skipped} partition-unreadable=${partitionSkipped} domains=${phase.domains.size}` - ) - - const summary: BrowserCookieImportSummary = { - totalCookies: totalInput, - importedCookies: phase.importedCount, - skippedCookies: skipped, - ...(googleCookiesSkipped > 0 ? { googleCookiesSkipped } : {}), - ...(partitionSkipped > 0 ? { partitionSkippedCookies: partitionSkipped } : {}), - domains: [...phase.domains].sort() - } - - return { ok: true, profileId: '', summary } -} - -// --------------------------------------------------------------------------- -// Import from JSON file -// --------------------------------------------------------------------------- - -// Why: use a main-owned native dialog so a compromised renderer can't turn import into arbitrary file reads. export async function pickCookieFile(parentWindow: BrowserWindow | null): Promise { - const opts = { - title: 'Import Cookies', - filters: [ - { name: 'Cookie Files', extensions: ['json'] }, - { name: 'All Files', extensions: ['*'] } - ], - properties: ['openFile' as const] - } - const result = parentWindow - ? await dialog.showOpenDialog(parentWindow, opts) - : await dialog.showOpenDialog(opts) - - if (result.canceled || result.filePaths.length === 0) { - return null - } - return result.filePaths[0] + return pickFile(parentWindow) } export async function importCookiesFromFile( filePath: string, targetPartition: string ): Promise { - let rawContent: string - try { - rawContent = await readFile(filePath, 'utf-8') - } catch { - return { ok: false, reason: 'Could not read the selected file.' } - } - - let parsed: unknown - try { - parsed = JSON.parse(rawContent) - } catch { - return { ok: false, reason: 'File is not valid JSON.' } - } - - if (!Array.isArray(parsed)) { - return { ok: false, reason: 'Expected a JSON array of cookie objects.' } - } - - if (parsed.length === 0) { - return { ok: false, reason: 'Cookie file is empty.' } - } - - const validated: ValidatedCookie[] = [] - let skipped = 0 - for (const entry of parsed) { - if (typeof entry !== 'object' || entry === null) { - skipped++ - continue - } - const cookie = validateCookieEntry(entry as RawCookieEntry) - if (cookie) { - validated.push(cookie) - } else { - skipped++ - } - } - - if (validated.length === 0) { - return { - ok: false, - reason: `No valid cookies found. ${skipped} entries were skipped due to missing or invalid fields.` - } - } - - return importValidatedCookies( - validated, - parsed.length, - cookieImportTarget(targetPartition), - 'replace-imported-domains' - ) -} - -const PBKDF2_ITERATIONS = 1003 -const PBKDF2_KEY_LENGTH = 16 -const PBKDF2_SALT = 'saltysalt' - -const CHROMIUM_EPOCH_OFFSET = 11644473600n - -function chromiumTimestampToUnix(chromiumTs: bigint | number | string): number { - if (!chromiumTs || chromiumTs === 0n || chromiumTs === 0 || chromiumTs === '0') { - return 0 - } - try { - const ts = - typeof chromiumTs === 'bigint' - ? chromiumTs - : BigInt(typeof chromiumTs === 'number' ? Math.round(chromiumTs) : chromiumTs) - if (ts === 0n) { - return 0 - } - return Math.max(Number(ts / 1000000n - CHROMIUM_EPOCH_OFFSET), 0) - } catch { - return 0 - } -} - -// Why: each platform protects the Chromium key differently: macOS/Linux PBKDF2→AES-128-CBC, Windows DPAPI→AES-256-GCM. - -type EncryptionKeyResult = - | { - mode: 'aes-128-cbc' - keysByVersion: Partial> - keyringUnavailable?: boolean - } - | { mode: 'aes-256-gcm'; key: Buffer } - -export type ChromiumCookieColumnInfo = { - name: string - type?: string - notnull?: number | bigint - dflt_value?: unknown -} - -function parseSqliteDefaultValue(raw: unknown, type: string): string | number | Buffer | null { - if (raw === null || raw === undefined) { - return null - } - if (typeof raw !== 'string') { - return typeof raw === 'number' || typeof raw === 'bigint' ? Number(raw) : String(raw) - } - - const trimmed = raw.trim() - if (!trimmed || trimmed.toUpperCase() === 'NULL') { - return null - } - if (/^X''$/i.test(trimmed) || type.includes('BLOB')) { - return Buffer.alloc(0) - } - if ( - (trimmed.startsWith("'") && trimmed.endsWith("'")) || - (trimmed.startsWith('"') && trimmed.endsWith('"')) - ) { - return trimmed.slice(1, -1).replaceAll("''", "'") - } - if (type.includes('INT')) { - const numeric = Number(trimmed) - return Number.isFinite(numeric) ? numeric : 0 - } - return trimmed -} - -function normalizeSqliteCookieValue(value: unknown): string | number | bigint | Buffer | null { - if (value instanceof Uint8Array) { - return Buffer.from(value) - } - if (value === undefined || value === null) { - return null - } - if (typeof value === 'number' || typeof value === 'bigint' || typeof value === 'string') { - return value - } - return String(value) -} - -function isSqliteNotNull(column: ChromiumCookieColumnInfo): boolean { - return Number(column.notnull ?? 0) !== 0 -} - -function fallbackChromiumCookieColumnValue( - column: ChromiumCookieColumnInfo, - sourceRow: Record -): string | number | bigint | Buffer | null { - const type = (column.type ?? '').toUpperCase() - const defaultValue = parseSqliteDefaultValue(column.dflt_value, type) - if (defaultValue !== null) { - return defaultValue - } - if (!isSqliteNotNull(column)) { - return null - } - - switch (column.name) { - case 'value': - case 'encrypted_value': - return Buffer.alloc(0) - case 'top_frame_site_key': - return '' - case 'source_port': - return -1 - case 'last_update_utc': - return normalizeSqliteCookieValue(sourceRow.creation_utc) ?? 0 - default: - if (type.includes('BLOB')) { - return Buffer.alloc(0) - } - if (type.includes('INT')) { - return 0 - } - return '' - } + return importFile(filePath, targetPartition) } export function buildChromiumCookieInsertParams( @@ -970,1247 +50,23 @@ export function buildChromiumCookieInsertParams( sourceRow: Record, decryptedValue: Buffer ): (string | number | bigint | Buffer | null)[] { - return targetColumns.map((column) => { - if (column.name === 'encrypted_value') { - return Buffer.alloc(0) - } - if (column.name === 'value') { - return decryptedValue - } - - const sourceHasColumn = Object.hasOwn(sourceRow, column.name) - const sourceValue = sourceHasColumn ? normalizeSqliteCookieValue(sourceRow[column.name]) : null - if (sourceValue !== null) { - return sourceValue - } - if (sourceHasColumn && !isSqliteNotNull(column)) { - return null - } - - // Why: cookie columns drift across Chrome/Electron versions; missing NOT NULL columns need Chromium defaults, not NULL. - return fallbackChromiumCookieColumnValue(column, sourceRow) - }) + return buildInsertParams(targetColumns, sourceRow, decryptedValue) } -function getEncryptionKey( - keychainService: string, - keychainAccount: string, - browser?: DetectedBrowser -): EncryptionKeyResult | null { - if (process.platform === 'darwin') { - return getMacEncryptionKey(keychainService, keychainAccount) - } - if (process.platform === 'linux') { - return getLinuxEncryptionKey(keychainService, keychainAccount) - } - if (process.platform === 'win32' && browser) { - return getWindowsEncryptionKey(browser) - } - return null -} - -function getMacEncryptionKey( - keychainService: string, - keychainAccount: string -): EncryptionKeyResult | null { - try { - const raw = execFileSync( - 'security', - ['find-generic-password', '-s', keychainService, '-a', keychainAccount, '-w'], - { encoding: 'utf-8', timeout: 30_000 } - ).trim() - return { - mode: 'aes-128-cbc', - keysByVersion: { - v10: pbkdf2Sync(raw, PBKDF2_SALT, PBKDF2_ITERATIONS, PBKDF2_KEY_LENGTH, 'sha1') - } - } - } catch { - return null - } -} - -function getLinuxEncryptionKey( - keychainService: string, - keychainAccount: string -): EncryptionKeyResult | null { - // Chromium uses v11 only with OS key storage; without it, Linux writes v10 with hardcoded - // "peanuts". Keep eligibility explicit because CBC cannot authenticate a wrong-key result. - const v10Key = pbkdf2Sync('peanuts', PBKDF2_SALT, 1, PBKDF2_KEY_LENGTH, 'sha1') - - let keyringPassword = '' - try { - // Why: GNOME keyring stores the Chrome Safe Storage password via secret-tool. - keyringPassword = execFileSync( - 'secret-tool', - ['lookup', 'service', keychainService, 'account', keychainAccount], - { encoding: 'utf-8', timeout: 5_000 } - ).trim() - } catch { - // Why: fall back to application-based lookup used by newer Chromium versions. - try { - const app = keychainAccount.toLowerCase().replaceAll(' ', '') - keyringPassword = execFileSync('secret-tool', ['lookup', 'application', app], { - encoding: 'utf-8', - timeout: 5_000 - }).trim() - } catch { - diag(' Linux keyring unavailable — v11 cookies cannot be decrypted') - } - } - - if (!keyringPassword) { - return { - mode: 'aes-128-cbc', - keysByVersion: { v10: v10Key }, - keyringUnavailable: true - } - } - - const v11Key = pbkdf2Sync(keyringPassword, PBKDF2_SALT, 1, PBKDF2_KEY_LENGTH, 'sha1') - return { mode: 'aes-128-cbc', keysByVersion: { v10: v10Key, v11: v11Key } } -} - -function getWindowsEncryptionKey(browser: DetectedBrowser): EncryptionKeyResult | null { - const browserDef = CHROMIUM_BROWSERS.find((b) => b.family === browser.family) - if (!browserDef) { - return null - } - const root = browserRootPath(browserDef) - if (!root) { - return null - } - - const localStatePath = join(root, 'Local State') - if (!existsSync(localStatePath)) { - return null - } - - try { - const raw = readFileSync(localStatePath, 'utf-8') - const localState = JSON.parse(raw) - const encryptedKeyB64 = localState?.os_crypt?.encrypted_key - if (typeof encryptedKeyB64 !== 'string') { - return null - } - - const encryptedKey = Buffer.from(encryptedKeyB64, 'base64') - const dpapiPrefix = Buffer.from('DPAPI', 'utf-8') - if (!encryptedKey.subarray(0, dpapiPrefix.length).equals(dpapiPrefix)) { - return null - } - - // Why: PowerShell DPAPI decrypt is the only native-addon-free path to the master key; pass via stdin to avoid injection. - const dpapiData = encryptedKey.subarray(dpapiPrefix.length).toString('base64') - const script = [ - 'try { Add-Type -AssemblyName System.Security.Cryptography.ProtectedData -ErrorAction Stop }', - 'catch { try { Add-Type -AssemblyName System.Security -ErrorAction Stop } catch {} };', - '$in=[Convert]::FromBase64String([Console]::In.ReadLine());', - '$out=[System.Security.Cryptography.ProtectedData]::Unprotect($in,$null,', - '[System.Security.Cryptography.DataProtectionScope]::CurrentUser);', - '[Convert]::ToBase64String($out)' - ].join('') - - // Why runProcessSync and an absolute path: a bare `powershell` spawn from a - // GUI-subsystem process opens a visible conhost that takes foreground, so - // keystrokes typed into an Orca terminal during a cookie import land in the - // black box (#14543), and PATH under Electron is not the user's (#11771). - const result = runProcessSync({ - program: windowsPowerShellPath(), - args: ['-NoProfile', '-NonInteractive', '-Command', script], - timeoutMs: 10_000, - input: dpapiData - }) - if (result.code !== 0 || result.timedOut) { - diag(' Windows DPAPI key extraction failed: PowerShell exited non-zero') - return null - } - - return { key: Buffer.from(result.stdout.trim(), 'base64'), mode: 'aes-256-gcm' } - } catch (err) { - diag(` Windows DPAPI key extraction failed: ${String(err)}`) - return null - } -} - -// Why: Chromium 127+ prepends a 32-byte HMAC before the value; a hash is ~half non-printable, so ≥8 non-printable of the first 32 bytes flags the prefix. -const CHROMIUM_COOKIE_HMAC_LEN = 32 - -function hasHmacPrefix(buf: Buffer): boolean { - if (buf.length <= CHROMIUM_COOKIE_HMAC_LEN) { - return false - } - let nonPrintable = 0 - for (let i = 0; i < CHROMIUM_COOKIE_HMAC_LEN; i++) { - if (buf[i] < 0x20 || buf[i] > 0x7e) { - nonPrintable++ - } - } - return nonPrintable >= 8 -} - -function stripHmac(buf: Buffer): Buffer { - return hasHmacPrefix(buf) ? buf.subarray(CHROMIUM_COOKIE_HMAC_LEN) : buf -} - -// Why: the version prefix is the only thing that survives a failed decrypt, so read it once and -// share it between the decrypt path and the failure attribution. -function cookieEncryptionVersion(encryptedBuffer: Buffer): string | null { - if (encryptedBuffer.length < 3) { - return null - } - const version = encryptedBuffer.subarray(0, 3).toString('utf-8') - return /^v\d\d$/.test(version) ? version : null -} - -// Why: Chrome/Edge 140+ on Windows prefix every cookie with `v20` (app-bound encryption), which -// only the writing browser can unwrap. Classify it before decrypt so it is not folded into corruption. export function isAppBoundEncryptedCookie(encryptedBuffer: Buffer): boolean { - return cookieEncryptionVersion(encryptedBuffer) === 'v20' + return isAppBoundCookie(encryptedBuffer) } -// Why: a named cause must carry only its exact count; tied causes fall back to unknown. -function buildUndecryptableWarning(counts: { - decryptFailed: number - appBoundFailed: number - keyringUnavailableFailed: number -}): BrowserCookieImportSummary['warning'] { - if (counts.decryptFailed === 0) { - return undefined - } - const unknownFailed = - counts.decryptFailed - counts.appBoundFailed - counts.keyringUnavailableFailed - const rankedCauses = [ - { reason: 'app-bound-encryption' as const, count: counts.appBoundFailed }, - { reason: 'linux-keyring-unavailable' as const, count: counts.keyringUnavailableFailed }, - { reason: 'unknown' as const, count: unknownFailed } - ].sort((left, right) => right.count - left.count) - const [dominant, runnerUp] = rankedCauses - - if (dominant.reason === 'unknown' || dominant.count === runnerUp.count) { - return { code: 'cookies-undecryptable', failedCookies: counts.decryptFailed, reason: 'unknown' } - } - - const otherFailedCookies = counts.decryptFailed - dominant.count - return { - code: 'cookies-undecryptable', - failedCookies: dominant.count, - reason: dominant.reason, - ...(otherFailedCookies > 0 ? { otherFailedCookies } : {}) - } -} - -function decryptCookieValueRaw( - encryptedBuffer: Buffer, - keyResult: EncryptionKeyResult -): Buffer | null { - if (!encryptedBuffer || encryptedBuffer.length === 0) { - return null - } - const version = encryptedBuffer.subarray(0, 3).toString('utf-8') - if (!/^v\d\d$/.test(version)) { - return null - } - - if (keyResult.mode === 'aes-256-gcm') { - return decryptAes256Gcm(encryptedBuffer.subarray(3), keyResult.key) - } - - // AES-128-CBC (macOS and Linux) - const key = version === 'v10' || version === 'v11' ? keyResult.keysByVersion[version] : undefined - if (!key) { - return null - } - - const ciphertext = encryptedBuffer.subarray(3) - if (!ciphertext.length) { - return null - } - - try { - const iv = Buffer.alloc(16, ' ') - const decipher = createDecipheriv('aes-128-cbc', key, iv) - decipher.setAutoPadding(true) - const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]) - return stripHmac(decrypted) - } catch { - return null - } -} - -function decryptAes256Gcm(payload: Buffer, key: Buffer): Buffer | null { - // Why: Windows AES-256-GCM layout is: [12-byte nonce][ciphertext][16-byte auth tag] - if (payload.length < 12 + 16) { - return null - } - const nonce = payload.subarray(0, 12) - const authTag = payload.subarray(-16) - const ciphertext = payload.subarray(12, -16) - try { - const decipher = createDecipheriv('aes-256-gcm', key, nonce) - decipher.setAuthTag(authTag) - const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]) - return stripHmac(decrypted) - } catch { - return null - } -} - -// --------------------------------------------------------------------------- -// Safari binary cookie parser -// --------------------------------------------------------------------------- - -function decodeSafariBinaryCookies(buffer: Buffer): ValidatedCookie[] { - if (buffer.length < 8) { - return [] - } - if (buffer.subarray(0, 4).toString('utf8') !== 'cook') { - return [] - } - - const pageCount = buffer.readUInt32BE(4) - let cursor = 8 - if (cursor + pageCount * 4 > buffer.length) { - return [] - } - const pageSizes: number[] = [] - for (let i = 0; i < pageCount; i++) { - pageSizes.push(buffer.readUInt32BE(cursor)) - cursor += 4 - } - - const cookies: ValidatedCookie[] = [] - for (const pageSize of pageSizes) { - const page = buffer.subarray(cursor, cursor + pageSize) - cursor += pageSize - appendSafariCookies(cookies, decodeSafariPage(page)) - } - return cookies -} - -function appendSafariCookies(target: ValidatedCookie[], cookies: readonly ValidatedCookie[]): void { - // Why: pages can hold large cookie lists; push per-item to avoid exceeding the spread argument limit. - for (const cookie of cookies) { - target.push(cookie) - } -} - -function decodeSafariPage(page: Buffer): ValidatedCookie[] { - if (page.length < 16) { - return [] - } - if (page.readUInt32BE(0) !== 0x00000100) { - return [] - } - - const cookieCount = page.readUInt32LE(4) - if (8 + cookieCount * 4 > page.length) { - return [] - } - const offsets: number[] = [] - let cursor = 8 - for (let i = 0; i < cookieCount; i++) { - offsets.push(page.readUInt32LE(cursor)) - cursor += 4 - } - - const cookies: ValidatedCookie[] = [] - for (const offset of offsets) { - const cookie = decodeSafariCookie(page.subarray(offset)) - if (cookie) { - cookies.push(cookie) - } - } - return cookies -} - -function decodeSafariCookie(buf: Buffer): ValidatedCookie | null { - if (buf.length < 48) { - return null - } - // Why: size comes from the file and could be attacker-controlled; clamp so readCString can't escape the subarray. - const size = Math.min(buf.readUInt32LE(0), buf.length) - if (size < 48) { - return null - } - - const flags = buf.readUInt32LE(8) - const secure = (flags & 1) !== 0 - const httpOnly = (flags & 4) !== 0 - - const urlOffset = buf.readUInt32LE(16) - const nameOffset = buf.readUInt32LE(20) - const pathOffset = buf.readUInt32LE(24) - const valueOffset = buf.readUInt32LE(28) - - // Why: Safari stores dates as Mac absolute time (seconds since 2001-01-01). - const expiration = buf.length >= 48 ? buf.readDoubleLE(40) : 0 - - const name = readCString(buf, nameOffset, size) - if (!name) { - return null - } - const value = readCString(buf, valueOffset, size) ?? '' - const path = readCString(buf, pathOffset, size) ?? '/' - const rawUrl = readCString(buf, urlOffset, size) ?? '' - - // Why: Safari stores the domain in the URL field, not as a separate domain column. - const domain = rawUrl.startsWith('.') ? rawUrl : rawUrl || null - if (!domain) { - return null - } - - const url = deriveUrl(domain, secure) - if (!url) { - return null - } - - const expirationDate = expiration > 0 ? Math.round(expiration + MAC_EPOCH_DELTA) : undefined - - return { - url, - name, - value, - domain, - path, - secure, - httpOnly, - sameSite: 'unspecified', - expirationDate, - // Why: Cookies.binarycookies has no partition field — Safari's format predates CHIPS, so every - // decoded cookie is genuinely unpartitioned rather than missing an identity. - partition: { status: 'unpartitioned' } - } -} - -function readCString(buf: Buffer, offset: number, end: number): string | null { - if (offset < 0 || offset >= end) { - return null - } - let cursor = offset - while (cursor < end && buf[cursor] !== 0) { - cursor++ - } - if (cursor >= end) { - return null - } - return buf.toString('utf8', offset, cursor) -} - -// --------------------------------------------------------------------------- -// Firefox import -// --------------------------------------------------------------------------- - -async function importCookiesFromFirefox( - browser: DetectedBrowser, - targetPartition: string, - options: CookieImportOptions -): Promise { - diag(`importCookiesFromFirefox: partition="${targetPartition}"`) - - const tmpDir = mkdtempSync(join(tmpdir(), 'orca-cookie-import-')) - const tmpCookiesPath = join(tmpDir, 'cookies.sqlite') - - try { - copyFileSync(browser.cookiesPath, tmpCookiesPath) - for (const suffix of ['-wal', '-shm'] as const) { - const sidecar = browser.cookiesPath + suffix - if (existsSync(sidecar)) { - try { - copyFileSync(sidecar, tmpCookiesPath + suffix) - } catch { - /* best-effort */ - } - } - } - } catch { - rmSync(tmpDir, { recursive: true, force: true }) - return { - ok: false, - reason: 'Could not copy Firefox cookies database. Try closing Firefox first.' - } - } - - try { - const db = new DatabaseSync(tmpCookiesPath, { readOnly: true }) - type FirefoxRow = Record & { - name: string - value: string - host: string - path: string - expiry: number - isSecure: number - isHttpOnly: number - sameSite: number - isPartitionedAttributeSet?: number - } - // Why: selecting a column an older moz_cookies schema lacks fails the whole import. A schema - // without the server-declared partition flag predates that cookie identity. - const firefoxColumns = new Set( - (db.prepare('PRAGMA table_info(moz_cookies)').all() as { name: string }[]).map( - (column) => column.name - ) - ) - const partitionColumn = firefoxColumns.has('isPartitionedAttributeSet') - ? ', isPartitionedAttributeSet' - : '' - const rows = db - .prepare( - `SELECT name, value, host, path, expiry, isSecure, isHttpOnly, sameSite${partitionColumn} FROM moz_cookies` - ) - .all() as FirefoxRow[] - db.close() - - diag(` Firefox source has ${rows.length} cookies`) - if (rows.length === 0) { - rmSync(tmpDir, { recursive: true, force: true }) - return { ok: false, reason: 'No cookies found in Firefox.' } - } - - const now = Math.floor(Date.now() / 1000) - const validated: ValidatedCookie[] = [] - for (const row of rows) { - if (!row.name || !row.host) { - continue - } - if (row.expiry > 0 && row.expiry < now) { - continue - } - - const domain = row.host - const secure = row.isSecure === 1 - const url = deriveUrl(domain, secure) - if (!url) { - continue - } - - validated.push({ - url, - name: row.name, - value: row.value ?? '', - domain, - path: row.path || '/', - secure, - httpOnly: row.isHttpOnly === 1, - sameSite: firefoxSameSite(row.sameSite), - expirationDate: row.expiry > 0 ? row.expiry : undefined, - partition: readFirefoxRowPartition(row, firefoxColumns) - }) - } - - rmSync(tmpDir, { recursive: true, force: true }) - - if (validated.length === 0) { - return { ok: false, reason: 'No valid cookies found in Firefox.' } - } - - return importValidatedCookies( - validated, - rows.length, - cookieImportTarget(targetPartition), - 'replace-imported-domains', - options - ) - } catch (err) { - rmSync(tmpDir, { recursive: true, force: true }) - diag(` Firefox import failed: ${String(err)}`) - return { - ok: false, - reason: 'Could not import cookies from Firefox. Try closing Firefox first.' - } - } -} - -// --------------------------------------------------------------------------- -// Safari import -// --------------------------------------------------------------------------- - -async function importCookiesFromSafari( - browser: DetectedBrowser, - targetPartition: string -): Promise { - diag(`importCookiesFromSafari: partition="${targetPartition}"`) - - let data: Buffer - try { - data = readFileSync(browser.cookiesPath) - } catch (err) { - diag(` Safari read failed: ${String(err)}`) - // Why: Safari's Cookies.binarycookies is in a sandbox container; reading it needs Full Disk Access. - const isPermError = - err instanceof Error && 'code' in err && (err as NodeJS.ErrnoException).code === 'EPERM' - if (isPermError) { - return { - ok: false, - reason: - 'macOS denied access to Safari cookies. Grant Full Disk Access to Orca in System Settings → Privacy & Security → Full Disk Access.' - } - } - return { ok: false, reason: 'Could not read Safari cookies.' } - } - - try { - const cookies = decodeSafariBinaryCookies(data) - diag(` Safari source has ${cookies.length} cookies`) - - if (cookies.length === 0) { - return { ok: false, reason: 'No cookies found in Safari.' } - } - - const now = Math.floor(Date.now() / 1000) - const valid = cookies.filter((c) => !c.expirationDate || c.expirationDate > now) - - if (valid.length === 0) { - return { ok: false, reason: 'All Safari cookies are expired.' } - } - - return importValidatedCookies( - valid, - cookies.length, - cookieImportTarget(targetPartition), - 'replace-imported-domains' - ) - } catch (err) { - diag(` Safari import failed: ${String(err)}`) - return { ok: false, reason: 'Could not import cookies from Safari.' } - } -} - -// --------------------------------------------------------------------------- -// Import dispatcher -// --------------------------------------------------------------------------- - export async function importCookiesFromBrowser( browser: DetectedBrowser, targetPartition: string, options: CookieImportOptions = {} ): Promise { - diag(`importCookiesFromBrowser: browser=${browser.family} partition="${targetPartition}"`) - if (!existsSync(browser.cookiesPath)) { - diag(` cookies DB not found: ${browser.cookiesPath}`) - return { ok: false, reason: `${browser.label} cookies database not found.` } - } - if (browser.family === 'firefox') { return importCookiesFromFirefox(browser, targetPartition, options) } if (browser.family === 'safari') { return importCookiesFromSafari(browser, targetPartition) } - - // Why: cookies.set() rejects many valid values (bytes > 0x7F); instead write plaintext to the `value` column, which CookieMonster reads raw when `encrypted_value` is empty and re-encrypts on flush in packaged builds. - - // Why: CookieMonster can reject otherwise valid imported bytes, so stage a populated copy whose - // imported-domain rows can be merged into the live DB on the next cold start. - const targetSession = session.fromPartition(targetPartition) - // Why (STA-4601): native imports mutate the live jar and their staged image before the old - // clear/write lock was reached. Hold the per-partition lock from the first flush through staging, - // live replacement, pending-image bookkeeping, and cleanup so an older image cannot race a newer - // import on the same partition. - return withCookieMutationLock(targetSession, async () => { - await targetSession.cookies.flushStore() - - // Why (STA-4300): ask the Session where its own storage lives instead of rebuilding the path from - // the caller's partition string. String surgery on a caller-supplied name is what let a value like - // "persist:../.." resolve a Cookies DB outside the Partitions directory and stage a replacement - // over it; it also drifts whenever Chromium changes how a partition name maps to a directory. - const partitionDir = targetSession.getStoragePath() - if (!partitionDir) { - return { ok: false, reason: 'Target cookie database not found. Open a browser tab first.' } - } - const partitionName = targetPartition.replace('persist:', '') - let liveCookiesPath = resolveChromiumCookiesPath(partitionDir) - - // Why: Electron creates the Cookies file only after a cookie is stored; a throwaway set/remove forces DB init for unused profiles. - // Why (STA-4601): this probe MUTATES the live jar, so it runs under the same per-partition lock as - // the import itself. An earlier revision left it outside on the argument that no import writes - // https://localhost/__init — that was wrong. normalizeCookieImportDomain accepts `localhost`, - // cookie names are unrestricted, and deriveUrl produces exactly this URL, so an import CAN write - // that coordinate. Unlocked, this probe's remove() would delete a cookie a concurrent import had - // just written and reported as imported. The cost is negligible: the probe only runs for a - // partition that has never stored a cookie, so it is at most a one-time wait per profile. - if (!liveCookiesPath) { - try { - await targetSession.cookies.set({ url: 'https://localhost', name: '__init', value: '1' }) - await targetSession.cookies.remove('https://localhost', '__init') - await targetSession.cookies.flushStore() - } catch { - // ignore — the set/remove may fail but flushStore should still create the file - } - liveCookiesPath = resolveChromiumCookiesPath(partitionDir) - } - - if (!liveCookiesPath) { - return { ok: false, reason: 'Target cookie database not found. Open a browser tab first.' } - } - - const stagingDir = join(app.getPath('userData'), 'cookie-import-staging') - const partitionSegment = partitionName.replace(/[^a-zA-Z0-9_-]/g, '_') - const stagingCookiesPath = join( - stagingDir, - `Cookies-${partitionSegment}-${Date.now()}-${randomUUID()}` - ) - // Why: #9355 — staging only backs the cold-restart replay for cookies the in-memory - // import rejects, so losing it must degrade that fallback rather than abort the import. - let stagingAvailable = false - // Why: a client-hosted route partition is derived at runtime and never reaches the startup - // replay, so staging it would only leave a plaintext cookie DB nothing ever consumes. - if (!supportsPendingBrowserCookieImportReplay(targetPartition)) { - diag( - ` restart fallback unsupported for partition "${targetPartition}" — not staging cookies` - ) - } else { - try { - mkdirSync(stagingDir, { recursive: true }) - copyFileWithWindowsRetry(liveCookiesPath, stagingCookiesPath) - stagingAvailable = true - } catch (err) { - const fsErr = err as NodeJS.ErrnoException - diag( - ` staging copy unavailable: code=${fsErr.code ?? 'unknown'} errno=${fsErr.errno ?? 'unknown'} syscall=${fsErr.syscall ?? 'unknown'} path=${liveCookiesPath} destination=${stagingCookiesPath}` - ) - // Why: copyFile is non-atomic and can leave a partial DB; delete it so failed imports retain no cookie data. - try { - unlinkSync(stagingCookiesPath) - } catch { - /* best-effort */ - } - } - } - - let sourceSnapshot: ChromiumCookieSnapshot - try { - // Why: an open browser may hold cookies in WAL only; snapshot retries avoid pairing the main DB with a racing WAL. - sourceSnapshot = createChromiumCookieSnapshot(browser.cookiesPath) - } catch (err) { - try { - unlinkSync(stagingCookiesPath) - } catch { - /* best-effort */ - } - diag(` Chromium snapshot failed: ${String(err)}`) - return { - ok: false, - reason: `Could not copy ${browser.label} cookies database. Try closing ${browser.label} first.` - } - } - - let sourceDb: InstanceType | null = null - let stagingDb: InstanceType | null = null - const closeStagingDb = (): void => { - try { - stagingDb?.close() - } catch { - /* best-effort */ - } - stagingDb = null - } - const discardStagingFile = (): void => { - // Why: the staged copy holds plaintext cookie values, and SQLite may have left sidecars beside it. - for (const suffix of ['', '-wal', '-shm']) { - try { - unlinkSync(stagingCookiesPath + suffix) - } catch { - /* best-effort */ - } - } - } - - try { - // Why: Chromium timestamps (µs since 1601) can exceed Number.MAX_SAFE_INTEGER; readBigInts avoids precision loss. - sourceDb = new DatabaseSync(sourceSnapshot.databasePath, { - readOnly: true, - readBigInts: true - }) - let targetColumnInfo: ChromiumCookieColumnInfo[] | null = null - let colList: string | null = null - let placeholders: string | null = null - if (stagingAvailable) { - // Why: the staged file is Orca's own partition DB, also named "Cookies", so the same - // transient AV handle can make opening it throw — degrade instead of killing the import. - try { - stagingDb = new DatabaseSync(stagingCookiesPath) - // Why (STA-4797): a new-format stage must be one self-contained file. Otherwise a lost WAL - // can erase its scope marker and make cold-start replay mistake it for a legacy whole-image - // import, restoring the unrelated-cookie data loss this format is meant to prevent. - stagingDb.exec('PRAGMA journal_mode = DELETE') - targetColumnInfo = stagingDb - .prepare('PRAGMA table_info(cookies)') - .all() as ChromiumCookieColumnInfo[] - const targetCols: string[] = targetColumnInfo.map((r) => r.name) - colList = targetCols.join(', ') - placeholders = targetCols.map(() => '?').join(', ') - } catch (err) { - diag(` staging database unusable, restart fallback disabled: ${String(err)}`) - stagingAvailable = false - targetColumnInfo = null - colList = null - placeholders = null - closeStagingDb() - // Why: the copy holds real partition cookies; discard it now rather than at the exit branches. - discardStagingFile() - } - } - - // Why (STA-4300): the partition columns drift across Chromium versions, so read the source - // schema rather than assuming a row's missing column means "unpartitioned". - const sourceColumns = new Set( - (sourceDb.prepare('PRAGMA table_info(cookies)').all() as ChromiumCookieColumnInfo[]).map( - (column) => column.name - ) - ) - const sourceRows = sourceDb.prepare('SELECT * FROM cookies ORDER BY rowid').all() as Record< - string, - unknown - >[] - sourceDb.close() - sourceDb = null - - diag(` source has ${sourceRows.length} cookies`) - - if (sourceRows.length === 0) { - closeStagingDb() - discardStagingFile() - return { ok: false, reason: `No cookies found in ${browser.label}.` } - } - - // Why (STA-4300): partition fidelity is a property of the source row, even when its value - // cannot be decrypted. Plan first so decryption failure cannot discard a family's skip. - const partitionCandidates = sourceRows.flatMap((sourceRow) => { - const domain = sourceRow.host_key as string - const name = sourceRow.name as string - return isGoogleSourceBoundCookie(name, domain) || isNonTransplantableCookieDomain(domain) - ? [] - : [{ sourceRow, domain, partition: readChromiumRowPartition(sourceRow, sourceColumns) }] - }) - const nativePlan = planImportWrites(partitionCandidates) - const plannedSourceRows = new Set(nativePlan.writes.map((candidate) => candidate.sourceRow)) - const partitionBySourceRow = new Map( - partitionCandidates.map((candidate) => [candidate.sourceRow, candidate.partition]) - ) - - // Why (§4.3c): a family we cannot name is one we cannot exclude from the clear, and clearing a - // family we cannot protect is the P0. Refuse before the jar is touched. - if (nativePlan.hasUnrepresentableSkip) { - closeStagingDb() - discardStagingFile() - return { - ok: false, - reason: - 'Could not import: a cookie with an unreadable site partition has no registrable domain, so its existing session cannot be protected.' - } - } - - const needsSourceKey = sourceRows.some((sourceRow) => { - const encRaw = sourceRow.encrypted_value - if (!(encRaw instanceof Uint8Array) || encRaw.length === 0) { - return false - } - const domain = sourceRow.host_key as string - const name = sourceRow.name as string - return !(isGoogleSourceBoundCookie(name, domain) || isNonTransplantableCookieDomain(domain)) - }) - const sourceKey = needsSourceKey - ? getEncryptionKey(browser.keychainService!, browser.keychainAccount!, browser) - : null - if (needsSourceKey && !sourceKey) { - closeStagingDb() - // Why: key denial happens after staging, so clean up the target DB copy or retries pile up. - discardStagingFile() - return { - ok: false, - reason: `Could not access ${browser.label} encryption key. The OS may have denied access.` - } - } - - let imported = 0 - let skipped = 0 - let decryptFailed = 0 - let appBoundFailed = 0 - let keyringUnavailableFailed = 0 - let integritySkipped = 0 - let nonTransplantableSkipped = 0 - const partitionSkipped = nativePlan.skips.length - let memoryLoaded = 0 - let memoryFailed = 0 - const domainSet = new Set() - - type DecryptedCookie = Omit & { - decryptedValue: Buffer - sameSite: 'unspecified' | 'no_restriction' | 'lax' | 'strict' - partition: SourcePartitionRead - } - - const decryptedCookies: DecryptedCookie[] = [] - // Why: the staging insert needs the RAW source row, so each scanned candidate carries it. - // A plan record holding only the derived fields compiles fine and then cannot stage. - const scanned: { entry: DecryptedCookie; sourceRow: Record }[] = [] - const sourceDomainValidity = new Map() - - // Why: staging only backs the cold-restart replay, so any failure writing it disables that - // fallback instead of aborting an import whose in-memory half still works. - let insertStmt: ReturnType['prepare']> | null = null - const disableStaging = (reason: string): void => { - diag(` staging disabled, restart fallback unavailable: ${reason}`) - stagingAvailable = false - insertStmt = null - closeStagingDb() - discardStagingFile() - } - - if (stagingDb && colList && placeholders) { - try { - insertStmt = stagingDb.prepare( - `INSERT OR REPLACE INTO cookies (${colList}) VALUES (${placeholders})` - ) - stagingDb.exec('BEGIN TRANSACTION') - } catch (err) { - disableStaging(String(err)) - } - } else if (stagingAvailable) { - disableStaging('staged database exposed no cookies columns') - } - - // Why: keep the existing conservative fallback boundary for family-level omissions. Expanding - // partial-import restart behavior is separate from narrowing what a staged replay may replace. - if (nativePlan.skippedFamilies.size > 0) { - disableStaging( - `${nativePlan.skippedFamilies.size} preserved cookie families cannot be represented in a staged image` - ) - } - - for (const sourceRow of sourceRows) { - const domain = sourceRow.host_key as string - const name = sourceRow.name as string - - if (isGoogleSourceBoundCookie(name, domain)) { - integritySkipped++ - continue - } - - // Why: transplanting these replaces a working sign-in with a session the site rejects. - if (isNonTransplantableCookieDomain(domain)) { - nonTransplantableSkipped++ - continue - } - - const encRaw = sourceRow.encrypted_value - // Why: node:sqlite returns BLOBs as Uint8Array; treat any other type as missing, not an empty buffer that would silently blank the cookie value. - const encBuf = encRaw instanceof Uint8Array ? Buffer.from(encRaw) : null - const plainRaw = sourceRow.value - - let decryptedValue: Buffer - if (encBuf && encBuf.length > 0) { - const version = cookieEncryptionVersion(encBuf) - const appBoundIneligible = version === 'v20' - const keyringIneligible = - version === 'v11' && - sourceKey?.mode === 'aes-128-cbc' && - sourceKey.keyringUnavailable === true - const raw = - sourceKey && !appBoundIneligible && !keyringIneligible - ? decryptCookieValueRaw(encBuf, sourceKey) - : null - if (!raw) { - // Why: once decrypt returns null every failure looks identical, so attribute the cause - // here while the version prefix is still in hand. Without this an undecryptable profile - // is indistinguishable from an empty one and reports success. - decryptFailed++ - if (appBoundIneligible) { - appBoundFailed++ - } else if (keyringIneligible) { - keyringUnavailableFailed++ - } - skipped++ - continue - } - decryptedValue = raw - } else if (plainRaw instanceof Uint8Array) { - decryptedValue = Buffer.from(plainRaw) - } else if (typeof plainRaw === 'string') { - decryptedValue = Buffer.from(plainRaw, 'latin1') - } else { - decryptedValue = Buffer.alloc(0) - } - - let validDomain = sourceDomainValidity.get(domain) - if (validDomain === undefined) { - validDomain = normalizeCookieImportDomain(domain) !== null - sourceDomainValidity.set(domain, validDomain) - } - if (!validDomain) { - skipped++ - continue - } - - // Decryption failures are already counted above. Every other row suppressed by the - // pre-decryption family plan is counted once here, keeping partitionSkipped a breakdown. - if (!plannedSourceRows.has(sourceRow)) { - skipped++ - continue - } - - const path = sourceRow.path as string - const secure = sourceRow.is_secure === 1n - const httpOnly = sourceRow.is_httponly === 1n - const sameSite = chromiumSameSite(Number(sourceRow.samesite ?? 0)) - const expiresUtc = chromiumTimestampToUnix(sourceRow.expires_utc as bigint) - const partition = partitionBySourceRow.get(sourceRow)! - // Why: cookie values are raw bytes, not UTF-8; latin1 preserves 0x00–0xFF without lossy replacement. - const value = decryptedValue.toString('latin1') - - // Why (STA-4300 I1): SCAN only. Nothing is emitted here — not decryptedCookies, not - // domainSet, not a staging row, not the imported count. bf6dc6fcba pushed the cookie and - // THEN applied the unreadable guard, so an unreadable row discovered late could not retract - // a sibling already emitted, and the jar-wide clear then removed more than was written back. - scanned.push({ - entry: { - decryptedValue, - value, - domain, - name, - path, - secure, - httpOnly, - sameSite, - expirationDate: expiresUtc > 0 ? expiresUtc : undefined, - partition - }, - sourceRow - }) - } - - for (const { entry } of scanned) { - domainSet.add(entry.domain.startsWith('.') ? entry.domain.slice(1) : entry.domain) - } - // Why (STA-4797): the import may only destroy what it is replacing. Naming the scope from the - // plan — the same rows the writes come from — is what keeps the removal set from drifting past - // the write set, and it is derived here rather than at the clear because the staged image below - // has to be cleared to the identical scope. - const importScope = importedDomainScope([...domainSet]) - - // Why (STA-4797): the staged image must carry the same imported-domain scope as the live clear. - // Cold-start replay uses it to replace only those rows and preserve newer unrelated sessions. - if (stagingDb && insertStmt) { - try { - prepareStagedCookiesForImport(stagingDb, importScope) - } catch (err) { - disableStaging(String(err)) - } - } - - // EMIT: everything downstream derives from the plan, so there is no second place a row can - // leak in. - for (const { entry, sourceRow } of scanned) { - decryptedCookies.push(entry) - if (insertStmt && targetColumnInfo) { - try { - const params = buildChromiumCookieInsertParams( - targetColumnInfo, - sourceRow, - entry.decryptedValue - ) - insertStmt.run(...params) - } catch (err) { - disableStaging(String(err)) - } - } - // Why: counts importable cookies, not staged rows — the summary must stay truthful when - // the optional staging DB is unavailable. - imported++ - } - diag( - ` skipped ${integritySkipped} Google integrity cookies (SIDCC/STRP/AEC) and ${nonTransplantableSkipped} non-transplantable-domain cookies` - ) - const googleCookiesSkipped = integritySkipped + nonTransplantableSkipped - - const undecryptableWarning = buildUndecryptableWarning({ - decryptFailed, - appBoundFailed, - keyringUnavailableFailed - }) - - // Why: an older remote client ignores the new counter and would present this loss as success. - // Placed before the early return and before any jar mutation, so a client that cannot render - // the skip fails the import outright rather than reporting a partial import as complete. - if (partitionSkipped > 0 && options.canReportPartitionSkippedCookies === false) { - closeStagingDb() - discardStagingFile() - return { - ok: false, - reason: - 'This Orca client cannot report cookies skipped for an unreadable site partition. Update Orca on this device and try again.' - } - } - - if (decryptedCookies.length === 0) { - const zeroPathWarning = undecryptableWarning - closeStagingDb() - discardStagingFile() - return { - ok: true, - profileId: '', - summary: { - totalCookies: sourceRows.length, - importedCookies: 0, - skippedCookies: skipped + integritySkipped + nonTransplantableSkipped, - ...(googleCookiesSkipped > 0 ? { googleCookiesSkipped } : {}), - // Why: partition skips are a breakdown of skippedCookies, never an addition to it, so - // totalCookies === importedCookies + skippedCookies keeps holding on this path too. - ...(partitionSkipped > 0 ? { partitionSkippedCookies: partitionSkipped } : {}), - domains: [], - // Why: a profile whose rows cannot be decrypted returns here, and without this it is - // reported as a successful empty import. - ...(zeroPathWarning ? { warning: zeroPathWarning } : {}) - } - } - } - - if (stagingDb) { - try { - stagingDb.exec('COMMIT') - closeStagingDb() - diag(` SQLite staging complete: ${imported} cookies, ${domainSet.size} domains`) - } catch (err) { - disableStaging(String(err)) - } - } else { - diag(` staging skipped: ${imported} cookies will load in-memory only`) - } - - // Why: clear stale cookies for the domains being imported first; mixing them with the imported - // set makes sites reject the session. Non-transplantable families are exempt — nothing was - // imported for them, and their live session is the only one that works. - // Why (STA-4797): every other site in the partition is exempt too. The rationale above reaches - // only as far as the domains this import writes; beyond them a clear has nothing to reconcile - // and only signs the user out of sessions the import was never about. - // Why (STA-4300): one store spans the clear and the writes, so both halves of the import speak - // the same CDP identities — cookies.set() cannot express the partition either one reads. - const cookieClearStore = openCookieClearStore(targetSession) - try { - // Why (STA-4601): the outer lock spans the clear and the writes that repopulate the jar, so a - // second import cannot clear between them and write on top of a newer import's jar. - await removeTransplantableCookies( - { - cookies: cookieClearStore, - snapshotClearIdentities: (cookies) => cookieClearStore.snapshotClearIdentities(cookies), - restoreClearIdentities: (identities) => - cookieClearStore.restoreClearIdentities(identities) - }, - // Why (STA-4300): the families this import declined to write must not be removed either. - // Passing them here keeps their coordinates out of the removal plan AND out of the CDP - // snapshot taken from it, so they are never submitted to any mutation. - nativePlan.skippedFamilies, - importScope - ) - diag( - ` cleared existing cookies for ${domainSet.size} imported domains before loading ${decryptedCookies.length} imported cookies` - ) - - const writable: SourceCookieToWrite[] = [] - for (const cookie of decryptedCookies) { - const url = deriveUrl(cookie.domain, cookie.secure) - if (!url) { - memoryFailed++ - continue - } - writable.push({ ...cookie, url }) - } - // Why: a rejected cookie here falls back to the staged cold-start replay rather than - // unwinding the import, so one failure must not stop the rest from loading. - const phase = await writeImportedCookies(cookieClearStore, writable, { - stopOnFailure: false, - log: diag - }) - memoryLoaded = phase.importedCount - memoryFailed += phase.writeRejected - } finally { - cookieClearStore.dispose() - } - - diag( - ` memory load: ${memoryLoaded} OK, ${memoryFailed} failed, ${partitionSkipped} partition-unreadable` - ) - - let warning: BrowserCookieImportSummary['warning'] - if (memoryFailed > 0 && stagingAvailable) { - // Why: keep the staging DB so the failed cookies load from SQLite on next cold start, where CookieMonster skips validation. - browserSessionRegistry.setPendingCookieImport(targetPartition, stagingCookiesPath) - diag(` staged at ${stagingCookiesPath} for ${memoryFailed} cookies that need restart`) - } else if (memoryFailed > 0) { - // Why: never register a path that was never written or can never be replayed — cold start - // would replay a missing or partial DB over the live partition. - browserSessionRegistry.clearPendingCookieImport(targetPartition) - discardStagingFile() - diag(` ${memoryFailed} cookies need a restart but staging is unavailable — skipped`) - // Why: the jar was already cleared, so silence here would report a lossy import as a clean success. - warning = { - code: 'restart-fallback-unavailable', - loadedCookies: memoryLoaded, - failedCookies: memoryFailed - } - } else { - // Why: this import already rewrote the live session, so an older staged DB must not replay over it. - browserSessionRegistry.clearPendingCookieImport(targetPartition) - discardStagingFile() - diag(` all cookies loaded in-memory — no restart needed`) - } - - // Why: the session keeps the UA the registry set at startup (clean or native). - // Imports must not impersonate the source browser — the synthesized UA read a - // fork's marketing version as a Chromium version (STA-3514), and Google binds - // sessions to the re-import, not the UA (#12884), so it bought nothing. - // Google-bound integrity cookies are already excluded by - // isGoogleSourceBoundCookie, which is what actually prevents CookieMismatch. - - // Why: a partial import still drops every undecryptable row, so silence here would report it - // as an unqualified success. The restart-fallback warning describes a lossier outcome and - // keeps precedence. - if (!warning && undecryptableWarning) { - warning = undecryptableWarning - } - - const summary: BrowserCookieImportSummary = { - totalCookies: sourceRows.length, - importedCookies: imported, - skippedCookies: skipped + integritySkipped + nonTransplantableSkipped, - ...(googleCookiesSkipped > 0 ? { googleCookiesSkipped } : {}), - ...(partitionSkipped > 0 ? { partitionSkippedCookies: partitionSkipped } : {}), - domains: [...domainSet].sort(), - ...(warning ? { warning } : {}) - } - - return { ok: true, profileId: '', summary } - } catch (err) { - try { - sourceDb?.close() - } catch { - /* may already be closed */ - } - try { - stagingDb?.close() - } catch { - /* may already be closed */ - } - // Why: drop the staging DB so a stale staged import isn't applied on the next cold start. - try { - unlinkSync(stagingCookiesPath) - } catch { - /* may not exist yet */ - } - diag(` SQLite import failed: ${String(err)}`) - return { - ok: false, - reason: reasonWithDiagLog( - `Could not import cookies from ${browser.label}: ${summarizeCookieImportError(err)}.` - ) - } - } finally { - try { - sourceSnapshot.cleanup() - } catch (err) { - diag(` Chromium snapshot cleanup failed: ${String(err)}`) - } - } - }) + return importChromiumCookies(browser, targetPartition, options) } diff --git a/src/main/browser/browser-cookie-key.ts b/src/main/browser/browser-cookie-key.ts new file mode 100644 index 00000000000..76fc14cf9e6 --- /dev/null +++ b/src/main/browser/browser-cookie-key.ts @@ -0,0 +1,162 @@ +import { pbkdf2Sync } from 'node:crypto' +import { existsSync, readFileSync } from 'node:fs' +import { join } from 'node:path' +import { runProcessSync } from '../../shared/child-process/run-process' +import { windowsPowerShellPath } from '../../shared/child-process/windows-system-binary' +import { diag } from './browser-cookie-import-diagnostics' +import { + CHROMIUM_BROWSERS, + browserRootPath, + type DetectedBrowser +} from './browser-cookie-detection-types' +import type { EncryptionKeyResult } from './browser-cookie-sqlite' + +const PBKDF2_ITERATIONS = 1003 +const PBKDF2_KEY_LENGTH = 16 +const PBKDF2_SALT = 'saltysalt' + +function runKeychainCommand(program: string, args: readonly string[], timeoutMs: number): string { + const result = runProcessSync({ program, args, timeoutMs }) + if (result.code !== 0 || result.timedOut) { + throw new Error(`${program} exited with code ${result.code ?? 'unknown'}`) + } + return result.stdout.trim() +} + +export function getEncryptionKey( + keychainService: string, + keychainAccount: string, + browser?: DetectedBrowser +): EncryptionKeyResult | null { + if (process.platform === 'darwin') { + return getMacEncryptionKey(keychainService, keychainAccount) + } + if (process.platform === 'linux') { + return getLinuxEncryptionKey(keychainService, keychainAccount) + } + if (process.platform === 'win32' && browser) { + return getWindowsEncryptionKey(browser) + } + return null +} + +export function getMacEncryptionKey( + keychainService: string, + keychainAccount: string +): EncryptionKeyResult | null { + try { + const raw = runKeychainCommand( + 'security', + ['find-generic-password', '-s', keychainService, '-a', keychainAccount, '-w'], + 30_000 + ) + return { + mode: 'aes-128-cbc', + keysByVersion: { + v10: pbkdf2Sync(raw, PBKDF2_SALT, PBKDF2_ITERATIONS, PBKDF2_KEY_LENGTH, 'sha1') + } + } + } catch { + return null + } +} + +export function getLinuxEncryptionKey( + keychainService: string, + keychainAccount: string +): EncryptionKeyResult | null { + // Chromium uses v11 only with OS key storage; without it, Linux writes v10 with hardcoded + // "peanuts". Keep eligibility explicit because CBC cannot authenticate a wrong-key result. + const v10Key = pbkdf2Sync('peanuts', PBKDF2_SALT, 1, PBKDF2_KEY_LENGTH, 'sha1') + + let keyringPassword = '' + try { + // Why: GNOME keyring stores the Chrome Safe Storage password via secret-tool. + keyringPassword = runKeychainCommand( + 'secret-tool', + ['lookup', 'service', keychainService, 'account', keychainAccount], + 5_000 + ) + } catch { + // Why: fall back to application-based lookup used by newer Chromium versions. + try { + const app = keychainAccount.toLowerCase().replaceAll(' ', '') + keyringPassword = runKeychainCommand('secret-tool', ['lookup', 'application', app], 5_000) + } catch { + diag(' Linux keyring unavailable — v11 cookies cannot be decrypted') + } + } + + if (!keyringPassword) { + return { + mode: 'aes-128-cbc', + keysByVersion: { v10: v10Key }, + keyringUnavailable: true + } + } + + const v11Key = pbkdf2Sync(keyringPassword, PBKDF2_SALT, 1, PBKDF2_KEY_LENGTH, 'sha1') + return { mode: 'aes-128-cbc', keysByVersion: { v10: v10Key, v11: v11Key } } +} + +export function getWindowsEncryptionKey(browser: DetectedBrowser): EncryptionKeyResult | null { + const browserDef = CHROMIUM_BROWSERS.find((b) => b.family === browser.family) + if (!browserDef) { + return null + } + const root = browserRootPath(browserDef) + if (!root) { + return null + } + + const localStatePath = join(root, 'Local State') + if (!existsSync(localStatePath)) { + return null + } + + try { + const raw = readFileSync(localStatePath, 'utf-8') + const localState = JSON.parse(raw) + const encryptedKeyB64 = localState?.os_crypt?.encrypted_key + if (typeof encryptedKeyB64 !== 'string') { + return null + } + + const encryptedKey = Buffer.from(encryptedKeyB64, 'base64') + const dpapiPrefix = Buffer.from('DPAPI', 'utf-8') + if (!encryptedKey.subarray(0, dpapiPrefix.length).equals(dpapiPrefix)) { + return null + } + + // Why: PowerShell DPAPI decrypt is the only native-addon-free path to the master key; pass via stdin to avoid injection. + const dpapiData = encryptedKey.subarray(dpapiPrefix.length).toString('base64') + const script = [ + 'try { Add-Type -AssemblyName System.Security.Cryptography.ProtectedData -ErrorAction Stop }', + 'catch { try { Add-Type -AssemblyName System.Security -ErrorAction Stop } catch {} };', + '$in=[Convert]::FromBase64String([Console]::In.ReadLine());', + '$out=[System.Security.Cryptography.ProtectedData]::Unprotect($in,$null,', + '[System.Security.Cryptography.DataProtectionScope]::CurrentUser);', + '[Convert]::ToBase64String($out)' + ].join('') + + // Why runProcessSync and an absolute path: a bare `powershell` spawn from a + // GUI-subsystem process opens a visible conhost that takes foreground, so + // keystrokes typed into an Orca terminal during a cookie import land in the + // black box (#14543), and PATH under Electron is not the user's (#11771). + const result = runProcessSync({ + program: windowsPowerShellPath(), + args: ['-NoProfile', '-NonInteractive', '-Command', script], + timeoutMs: 10_000, + input: dpapiData + }) + if (result.code !== 0 || result.timedOut) { + diag(' Windows DPAPI key extraction failed: PowerShell exited non-zero') + return null + } + + return { key: Buffer.from(result.stdout.trim(), 'base64'), mode: 'aes-256-gcm' } + } catch (err) { + diag(` Windows DPAPI key extraction failed: ${String(err)}`) + return null + } +} diff --git a/src/main/browser/browser-cookie-safari-import.ts b/src/main/browser/browser-cookie-safari-import.ts new file mode 100644 index 00000000000..e8fa79c5fa0 --- /dev/null +++ b/src/main/browser/browser-cookie-safari-import.ts @@ -0,0 +1,61 @@ +import { readFileSync } from 'node:fs' +import type { BrowserCookieImportResult } from '../../shared/browser-workspace-types' +import { decodeSafariBinaryCookies } from './browser-cookie-safari-parser' +import { importValidatedCookies, cookieImportTarget } from './browser-cookie-import-pipeline' +import type { DetectedBrowser } from './browser-cookie-detection-types' +import { diag } from './browser-cookie-import-diagnostics' + +// --------------------------------------------------------------------------- +// Safari import +// --------------------------------------------------------------------------- + +export async function importCookiesFromSafari( + browser: DetectedBrowser, + targetPartition: string +): Promise { + diag(`importCookiesFromSafari: partition="${targetPartition}"`) + + let data: Buffer + try { + data = readFileSync(browser.cookiesPath) + } catch (err) { + diag(` Safari read failed: ${String(err)}`) + // Why: Safari's Cookies.binarycookies is in a sandbox container; reading it needs Full Disk Access. + const isPermError = + err instanceof Error && 'code' in err && (err as NodeJS.ErrnoException).code === 'EPERM' + if (isPermError) { + return { + ok: false, + reason: + 'macOS denied access to Safari cookies. Grant Full Disk Access to Orca in System Settings → Privacy & Security → Full Disk Access.' + } + } + return { ok: false, reason: 'Could not read Safari cookies.' } + } + + try { + const cookies = decodeSafariBinaryCookies(data) + diag(` Safari source has ${cookies.length} cookies`) + + if (cookies.length === 0) { + return { ok: false, reason: 'No cookies found in Safari.' } + } + + const now = Math.floor(Date.now() / 1000) + const valid = cookies.filter((c) => !c.expirationDate || c.expirationDate > now) + + if (valid.length === 0) { + return { ok: false, reason: 'All Safari cookies are expired.' } + } + + return importValidatedCookies( + valid, + cookies.length, + cookieImportTarget(targetPartition), + 'replace-imported-domains' + ) + } catch (err) { + diag(` Safari import failed: ${String(err)}`) + return { ok: false, reason: 'Could not import cookies from Safari.' } + } +} diff --git a/src/main/browser/browser-cookie-safari-parser.ts b/src/main/browser/browser-cookie-safari-parser.ts new file mode 100644 index 00000000000..9c4f7552488 --- /dev/null +++ b/src/main/browser/browser-cookie-safari-parser.ts @@ -0,0 +1,148 @@ +import { deriveUrl } from './browser-cookie-validation' +import type { ValidatedCookie } from './browser-cookie-validation' + +const MAC_EPOCH_DELTA = 978_307_200 + +// --------------------------------------------------------------------------- +// Safari binary cookie parser +// --------------------------------------------------------------------------- + +export function decodeSafariBinaryCookies(buffer: Buffer): ValidatedCookie[] { + if (buffer.length < 8) { + return [] + } + if (buffer.subarray(0, 4).toString('utf8') !== 'cook') { + return [] + } + + const pageCount = buffer.readUInt32BE(4) + let cursor = 8 + if (cursor + pageCount * 4 > buffer.length) { + return [] + } + const pageSizes: number[] = [] + for (let i = 0; i < pageCount; i++) { + pageSizes.push(buffer.readUInt32BE(cursor)) + cursor += 4 + } + + const cookies: ValidatedCookie[] = [] + for (const pageSize of pageSizes) { + const page = buffer.subarray(cursor, cursor + pageSize) + cursor += pageSize + appendSafariCookies(cookies, decodeSafariPage(page)) + } + return cookies +} + +export function appendSafariCookies( + target: ValidatedCookie[], + cookies: readonly ValidatedCookie[] +): void { + // Why: pages can hold large cookie lists; push per-item to avoid exceeding the spread argument limit. + for (const cookie of cookies) { + target.push(cookie) + } +} + +export function decodeSafariPage(page: Buffer): ValidatedCookie[] { + if (page.length < 16) { + return [] + } + if (page.readUInt32BE(0) !== 0x00000100) { + return [] + } + + const cookieCount = page.readUInt32LE(4) + if (8 + cookieCount * 4 > page.length) { + return [] + } + const offsets: number[] = [] + let cursor = 8 + for (let i = 0; i < cookieCount; i++) { + offsets.push(page.readUInt32LE(cursor)) + cursor += 4 + } + + const cookies: ValidatedCookie[] = [] + for (const offset of offsets) { + const cookie = decodeSafariCookie(page.subarray(offset)) + if (cookie) { + cookies.push(cookie) + } + } + return cookies +} + +export function decodeSafariCookie(buf: Buffer): ValidatedCookie | null { + if (buf.length < 48) { + return null + } + // Why: size comes from the file and could be attacker-controlled; clamp so readCString can't escape the subarray. + const size = Math.min(buf.readUInt32LE(0), buf.length) + if (size < 48) { + return null + } + + const flags = buf.readUInt32LE(8) + const secure = (flags & 1) !== 0 + const httpOnly = (flags & 4) !== 0 + + const urlOffset = buf.readUInt32LE(16) + const nameOffset = buf.readUInt32LE(20) + const pathOffset = buf.readUInt32LE(24) + const valueOffset = buf.readUInt32LE(28) + + // Why: Safari stores dates as Mac absolute time (seconds since 2001-01-01). + const expiration = buf.length >= 48 ? buf.readDoubleLE(40) : 0 + + const name = readCString(buf, nameOffset, size) + if (!name) { + return null + } + const value = readCString(buf, valueOffset, size) ?? '' + const path = readCString(buf, pathOffset, size) ?? '/' + const rawUrl = readCString(buf, urlOffset, size) ?? '' + + // Why: Safari stores the domain in the URL field, not as a separate domain column. + const domain = rawUrl.startsWith('.') ? rawUrl : rawUrl || null + if (!domain) { + return null + } + + const url = deriveUrl(domain, secure) + if (!url) { + return null + } + + const expirationDate = expiration > 0 ? Math.round(expiration + MAC_EPOCH_DELTA) : undefined + + return { + url, + name, + value, + domain, + path, + secure, + httpOnly, + sameSite: 'unspecified', + expirationDate, + // Why: Cookies.binarycookies has no partition field — Safari's format predates CHIPS, so every + // decoded cookie is genuinely unpartitioned rather than missing an identity. + partition: { status: 'unpartitioned' } + } +} + +export function readCString(buf: Buffer, offset: number, end: number): string | null { + if (offset < 0 || offset >= end) { + return null + } + let cursor = offset + while (cursor < end && buf[cursor] !== 0) { + cursor++ + } + if (cursor >= end) { + return null + } + return buf.toString('utf8', offset, cursor) +} diff --git a/src/main/browser/browser-cookie-sqlite.ts b/src/main/browser/browser-cookie-sqlite.ts new file mode 100644 index 00000000000..05593c79024 --- /dev/null +++ b/src/main/browser/browser-cookie-sqlite.ts @@ -0,0 +1,147 @@ +const CHROMIUM_EPOCH_OFFSET = 11644473600n + +export function chromiumTimestampToUnix(chromiumTs: bigint | number | string): number { + if (!chromiumTs || chromiumTs === 0n || chromiumTs === 0 || chromiumTs === '0') { + return 0 + } + try { + const ts = + typeof chromiumTs === 'bigint' + ? chromiumTs + : BigInt(typeof chromiumTs === 'number' ? Math.round(chromiumTs) : chromiumTs) + if (ts === 0n) { + return 0 + } + return Math.max(Number(ts / 1000000n - CHROMIUM_EPOCH_OFFSET), 0) + } catch { + return 0 + } +} + +// Why: each platform protects the Chromium key differently: macOS/Linux PBKDF2→AES-128-CBC, Windows DPAPI→AES-256-GCM. + +export type EncryptionKeyResult = + | { + mode: 'aes-128-cbc' + keysByVersion: Partial> + keyringUnavailable?: boolean + } + | { mode: 'aes-256-gcm'; key: Buffer } + +export type ChromiumCookieColumnInfo = { + name: string + type?: string + notnull?: number | bigint + dflt_value?: unknown +} + +export function parseSqliteDefaultValue( + raw: unknown, + type: string +): string | number | Buffer | null { + if (raw === null || raw === undefined) { + return null + } + if (typeof raw !== 'string') { + return typeof raw === 'number' || typeof raw === 'bigint' ? Number(raw) : String(raw) + } + + const trimmed = raw.trim() + if (!trimmed || trimmed.toUpperCase() === 'NULL') { + return null + } + if (/^X''$/i.test(trimmed) || type.includes('BLOB')) { + return Buffer.alloc(0) + } + if ( + (trimmed.startsWith("'") && trimmed.endsWith("'")) || + (trimmed.startsWith('"') && trimmed.endsWith('"')) + ) { + return trimmed.slice(1, -1).replaceAll("''", "'") + } + if (type.includes('INT')) { + const numeric = Number(trimmed) + return Number.isFinite(numeric) ? numeric : 0 + } + return trimmed +} + +export function normalizeSqliteCookieValue( + value: unknown +): string | number | bigint | Buffer | null { + if (value instanceof Uint8Array) { + return Buffer.from(value) + } + if (value === undefined || value === null) { + return null + } + if (typeof value === 'number' || typeof value === 'bigint' || typeof value === 'string') { + return value + } + return String(value) +} + +export function isSqliteNotNull(column: ChromiumCookieColumnInfo): boolean { + return Number(column.notnull ?? 0) !== 0 +} + +export function fallbackChromiumCookieColumnValue( + column: ChromiumCookieColumnInfo, + sourceRow: Record +): string | number | bigint | Buffer | null { + const type = (column.type ?? '').toUpperCase() + const defaultValue = parseSqliteDefaultValue(column.dflt_value, type) + if (defaultValue !== null) { + return defaultValue + } + if (!isSqliteNotNull(column)) { + return null + } + + switch (column.name) { + case 'value': + case 'encrypted_value': + return Buffer.alloc(0) + case 'top_frame_site_key': + return '' + case 'source_port': + return -1 + case 'last_update_utc': + return normalizeSqliteCookieValue(sourceRow.creation_utc) ?? 0 + default: + if (type.includes('BLOB')) { + return Buffer.alloc(0) + } + if (type.includes('INT')) { + return 0 + } + return '' + } +} + +export function buildChromiumCookieInsertParams( + targetColumns: ChromiumCookieColumnInfo[], + sourceRow: Record, + decryptedValue: Buffer +): (string | number | bigint | Buffer | null)[] { + return targetColumns.map((column) => { + if (column.name === 'encrypted_value') { + return Buffer.alloc(0) + } + if (column.name === 'value') { + return decryptedValue + } + + const sourceHasColumn = Object.hasOwn(sourceRow, column.name) + const sourceValue = sourceHasColumn ? normalizeSqliteCookieValue(sourceRow[column.name]) : null + if (sourceValue !== null) { + return sourceValue + } + if (sourceHasColumn && !isSqliteNotNull(column)) { + return null + } + + // Why: cookie columns drift across Chrome/Electron versions; missing NOT NULL columns need Chromium defaults, not NULL. + return fallbackChromiumCookieColumnValue(column, sourceRow) + }) +} diff --git a/src/main/browser/browser-cookie-validation.ts b/src/main/browser/browser-cookie-validation.ts new file mode 100644 index 00000000000..6ae543245ef --- /dev/null +++ b/src/main/browser/browser-cookie-validation.ts @@ -0,0 +1,127 @@ +import { normalizeCookieDomain } from './browser-cookie-import-policy' +import { + readJsonCookiePartition, + type SourcePartitionRead +} from './browser-cookie-source-partition' +import type { ImportedCookieFields } from './browser-cookie-import-write' + +export type RawCookieEntry = { + domain?: unknown + name?: unknown + value?: unknown + path?: unknown + secure?: unknown + httpOnly?: unknown + sameSite?: unknown + expirationDate?: unknown + partitionKey?: unknown + partitionKeyOpaque?: unknown +} + +// Why (STA-4300): `partition` is required, not optional, so every source that builds a cookie has to +// state what it read. An optional field would let a new source silently default to unpartitioned. +export type ValidatedCookie = ImportedCookieFields & { + sameSite: 'unspecified' | 'no_restriction' | 'lax' | 'strict' + partition: SourcePartitionRead +} + +// Why: Chromium's CookieSameSiteForStorage enum (0=Unspecified,1=None,2=Lax,3=Strict) differs from Firefox's numbering. +export function chromiumSameSite(raw: number): 'unspecified' | 'no_restriction' | 'lax' | 'strict' { + switch (raw) { + case 1: + return 'no_restriction' + case 2: + return 'lax' + case 3: + return 'strict' + default: + return 'unspecified' + } +} + +export function firefoxSameSite(raw: number): 'unspecified' | 'no_restriction' | 'lax' | 'strict' { + switch (raw) { + case 0: + return 'no_restriction' + case 1: + return 'lax' + case 2: + return 'strict' + default: + return 'unspecified' + } +} + +export function normalizeSameSite( + raw: unknown +): 'unspecified' | 'no_restriction' | 'lax' | 'strict' { + if (typeof raw === 'number') { + return chromiumSameSite(raw) + } + if (typeof raw !== 'string') { + return 'unspecified' + } + const lower = raw.toLowerCase() + if (lower === 'lax') { + return 'lax' + } + if (lower === 'strict') { + return 'strict' + } + if (lower === 'none' || lower === 'no_restriction') { + return 'no_restriction' + } + return 'unspecified' +} + +// Why: a cookie identity needs a url to scope it; derive it from domain + secure flag. +export function deriveUrl(domain: string, secure: boolean): string | null { + const normalizedDomain = normalizeCookieDomain(domain) + if (!normalizedDomain) { + return null + } + const protocol = secure ? 'https' : 'http' + try { + const url = new URL(`${protocol}://${normalizedDomain}/`) + return url.toString() + } catch { + return null + } +} + +export function validateCookieEntry(raw: RawCookieEntry): ValidatedCookie | null { + if (typeof raw.domain !== 'string' || raw.domain.trim().length === 0) { + return null + } + if (typeof raw.name !== 'string' || raw.name.trim().length === 0) { + return null + } + if (typeof raw.value !== 'string') { + return null + } + + const domain = raw.domain.trim() + const secure = raw.secure === true || raw.secure === 1 + const url = deriveUrl(domain, secure) + if (!url) { + return null + } + + const expirationDate = + typeof raw.expirationDate === 'number' && raw.expirationDate > 0 + ? raw.expirationDate + : undefined + + return { + url, + name: raw.name.trim(), + value: raw.value, + domain, + path: typeof raw.path === 'string' ? raw.path : '/', + secure, + httpOnly: raw.httpOnly === true || raw.httpOnly === 1, + sameSite: normalizeSameSite(raw.sameSite), + expirationDate, + partition: readJsonCookiePartition(raw.partitionKey, raw.partitionKeyOpaque) + } +} diff --git a/src/main/browser/browser-manager-bindings.ts b/src/main/browser/browser-manager-bindings.ts new file mode 100644 index 00000000000..931c5c6bc3b --- /dev/null +++ b/src/main/browser/browser-manager-bindings.ts @@ -0,0 +1,89 @@ +import { resolveRendererWebContents } from './browser-guest-renderer-target' +import { setupGuestContextMenu } from './browser-guest-context-menu' +import { setupGrabShortcutForwarding } from './browser-guest-grab-shortcuts' +import { setupGuestMouseWheelZoomForwarding } from './browser-guest-wheel-zoom' +import { setupGuestShortcutForwarding } from './browser-guest-shortcut-forwarding' +import { BrowserManagerGrab } from './browser-manager-grab' + +export abstract class BrowserManagerBindings extends BrowserManagerGrab { + protected setupContextMenu(browserTabId: string, guest: Electron.WebContents): void { + this.contextMenuCleanupByTabId.set( + browserTabId, + setupGuestContextMenu({ + browserTabId, + guest, + resolveRenderer: (tabId) => this.resolveRendererForBrowserTab(tabId) + }) + ) + } + + // Why: forward grab's Cmd/Ctrl+C from a focused guest only when no edit field/selection is active, so native copy still works. + protected setupGrabShortcut(browserTabId: string, guest: Electron.WebContents): void { + const previousCleanup = this.grabShortcutCleanupByTabId.get(browserTabId) + if (previousCleanup) { + previousCleanup() + this.grabShortcutCleanupByTabId.delete(browserTabId) + } + + this.grabShortcutCleanupByTabId.set( + browserTabId, + setupGrabShortcutForwarding({ + browserTabId, + guest, + resolveRenderer: (tabId) => + resolveRendererWebContents(this.rendererWebContentsIdByTabId, tabId), + hasActiveGrabOp: (tabId) => this.hasActiveGrabOp(tabId), + getKeybindings: () => this.settingsResolver?.().keybindings + }) + ) + } + + // Why: a focused webview guest is a separate process, so its key events never reach the renderer; intercept and forward app shortcuts. + protected setupShortcutForwarding(browserTabId: string, guest: Electron.WebContents): void { + const previousCleanup = this.shortcutForwardingCleanupByTabId.get(browserTabId) + if (previousCleanup) { + previousCleanup() + this.shortcutForwardingCleanupByTabId.delete(browserTabId) + } + + this.shortcutForwardingCleanupByTabId.set( + browserTabId, + setupGuestShortcutForwarding({ + browserTabId, + guest, + resolveRenderer: (tabId) => + resolveRendererWebContents(this.rendererWebContentsIdByTabId, tabId), + shouldForwardDictationShortcut: () => this.shouldForwardDictationShortcut?.() ?? false, + isMobileEmulatorEnabled: () => this.settingsResolver?.().mobileEmulatorEnabled !== false, + getKeybindings: () => this.settingsResolver?.().keybindings, + resolveWorktreeId: (tabId) => this.worktreeIdByTabId.get(tabId) ?? null, + resolveWorkspaceId: (tabId) => this.workspaceIdByPageId.get(tabId) ?? null + }) + ) + } + + protected setupMouseWheelZoomForwarding(browserTabId: string, guest: Electron.WebContents): void { + const previousCleanup = this.mouseWheelZoomCleanupByTabId.get(browserTabId) + if (previousCleanup) { + previousCleanup() + this.mouseWheelZoomCleanupByTabId.delete(browserTabId) + } + + this.mouseWheelZoomCleanupByTabId.set( + browserTabId, + setupGuestMouseWheelZoomForwarding({ + browserTabId, + guest, + resolveRenderer: (tabId) => + resolveRendererWebContents(this.rendererWebContentsIdByTabId, tabId), + isViewportPresetActive: () => { + const state = this.viewportPresetActiveByTabId.get(browserTabId) + return state?.guestWebContentsId === guest.id && state.active + }, + canViewportScroll: (mouse) => this.canViewportScroll(browserTabId, mouse), + onViewportWheelConsumed: (deltaX, deltaY) => + this.recordViewportScrollDelta(browserTabId, deltaX, deltaY) + }) + ) + } +} diff --git a/src/main/browser/browser-manager-download-creation.ts b/src/main/browser/browser-manager-download-creation.ts new file mode 100644 index 00000000000..fcef84b9809 --- /dev/null +++ b/src/main/browser/browser-manager-download-creation.ts @@ -0,0 +1,188 @@ +import { randomUUID } from 'node:crypto' +import { browserDownloadDestinationReservations } from './browser-download-destination' +import { routeBrowserClientDownload } from './browser-client-download-routing' +import { + safeOrigin, + type ActiveDownload, + type BrowserDownloadDoneState +} from './browser-manager-types' +import type { BrowserDownloadFinishedEvent } from '../../shared/browser-guest-events' +import { BrowserManagerQueries } from './browser-manager-queries' + +export abstract class BrowserManagerDownloadCreation extends BrowserManagerQueries { + handleGuestWillDownload(args: { guestWebContentsId: number; item: Electron.DownloadItem }): void { + const { guestWebContentsId, item } = args + const downloadId = randomUUID() + const requestedFilename = (() => { + try { + return item.getFilename() || 'download' + } catch { + return 'download' + } + })() + const totalBytes = (() => { + try { + const total = item.getTotalBytes() + return total > 0 ? total : null + } catch { + return null + } + })() + const mimeType = (() => { + try { + const mime = item.getMimeType() + return mime || null + } catch { + return null + } + })() + const origin = (() => { + try { + return safeOrigin(item.getURL()) + } catch { + return 'unknown' + } + })() + + // Why: a client-hosted page's bytes belong on the remote workspace, so main stages them itself + // instead of reserving a name in the desktop Downloads folder. A popup downloads to its + // opener's page: the popup itself is a client-local transient with no logical page of its own. + const ownerContext = this.resolvePopupOwnerContext(guestWebContentsId) + const decision = routeBrowserClientDownload({ + guestWebContentsId: ownerContext?.rootGuestWebContentsId ?? guestWebContentsId + }) + const clientRoute = decision.kind === 'remote' ? decision.route : null + const destination = (() => { + if (clientRoute) { + return { + filename: requestedFilename, + savePath: clientRoute.stagingPath, + reservationKey: null + } + } + // Why: a client-hosted download with no resolvable remote destination is canceled rather than + // written to this desktop's Downloads folder. + if (decision.kind === 'blocked') { + return null + } + try { + return browserDownloadDestinationReservations.reserve(requestedFilename) + } catch (error) { + console.error('[browser-download] Failed to choose download destination:', error) + return null + } + })() + + const fallbackSavePath = destination?.savePath ?? '' + + const download: ActiveDownload = { + downloadId, + guestWebContentsId, + browserTabId: null, + rendererWebContentsId: null, + origin, + filename: destination?.filename ?? requestedFilename, + totalBytes, + mimeType, + item, + savePath: fallbackSavePath, + reservationKey: destination?.reservationKey ?? null, + clientRoute, + remoteDestination: undefined, + receivedBytes: 0, + transientState: null, + terminalEvent: null, + startedSent: false, + cleanup: null + } + this.downloadsById.set(downloadId, download) + + const browserTabId = ownerContext?.browserTabId ?? null + if (browserTabId) { + this.bindDownloadToTab(downloadId, browserTabId) + } else { + const pending = this.pendingDownloadIdsByGuestId.get(guestWebContentsId) ?? [] + pending.push(downloadId) + this.pendingDownloadIdsByGuestId.set(guestWebContentsId, pending) + } + + if (!destination) { + this.finishDownloadInternal( + downloadId, + 'failed', + decision.kind === 'blocked' + ? 'Could not save the download to the remote workspace.' + : 'Could not choose a Downloads file name.' + ) + try { + item.cancel() + } catch { + // Why: with no destination Chromium must not keep writing invisibly; cancel is best-effort after surfacing the failure. + } + return + } + + try { + item.setSavePath(destination.savePath) + } catch (error) { + console.error('[browser-download] Failed to set download destination:', error) + this.finishDownloadInternal(downloadId, 'failed', 'Failed to set download destination.') + try { + item.cancel() + } catch { + // Why: a failed setSavePath can leave Electron partially finalized; cancel is best-effort after the UI is made terminal. + } + return + } + + const updatedHandler = (_event: Electron.Event, state: 'progressing' | 'interrupted'): void => { + download.receivedBytes = this.getDownloadReceivedBytes(download.item) + download.transientState = state + this.sendDownloadProgress(download.browserTabId, { + browserPageId: download.browserTabId ?? undefined, + downloadId: download.downloadId, + receivedBytes: download.receivedBytes, + totalBytes: download.totalBytes, + state + }) + } + const doneHandler = (_event: Electron.Event, state: BrowserDownloadDoneState): void => { + const status: BrowserDownloadFinishedEvent['status'] = + state === 'completed' ? 'completed' : state === 'cancelled' ? 'canceled' : 'failed' + const failure = + status === 'failed' + ? state === 'interrupted' + ? 'Download was interrupted.' + : 'Download failed.' + : null + if (download.clientRoute) { + void this.settleClientHostedDownload(download, status, failure) + return + } + this.finishDownloadInternal(download.downloadId, status, failure) + } + download.cleanup = (): void => { + try { + download.item.off('updated', updatedHandler) + download.item.off('done', doneHandler) + } catch { + // Why: a completed DownloadItem may already be finalized; keep cleanup best-effort so teardown never crashes main. + } + } + item.on('updated', updatedHandler) + item.once('done', doneHandler) + + if (browserTabId) { + this.sendDownloadStarted(downloadId) + } + } + + cancelDownload(args: { downloadId: string; senderWebContentsId: number }): boolean { + const download = this.downloadsById.get(args.downloadId) + if (!download || download.rendererWebContentsId !== args.senderWebContentsId) { + return false + } + this.cancelDownloadInternal(args.downloadId, 'Canceled.') + return true + } +} diff --git a/src/main/browser/browser-manager-download-lifecycle.ts b/src/main/browser/browser-manager-download-lifecycle.ts new file mode 100644 index 00000000000..614b7651d16 --- /dev/null +++ b/src/main/browser/browser-manager-download-lifecycle.ts @@ -0,0 +1,241 @@ +import { browserDownloadDestinationReservations } from './browser-download-destination' +import type { + BrowserDownloadFinishedEvent, + BrowserDownloadProgressEvent, + BrowserDownloadRequestedEvent +} from '../../shared/browser-guest-events' +import type { ActiveDownload } from './browser-manager-types' +import { BrowserManagerDownloadCreation } from './browser-manager-download-creation' + +export abstract class BrowserManagerDownloadLifecycle extends BrowserManagerDownloadCreation { + protected bindDownloadToTab(downloadId: string, browserTabId: string): void { + const download = this.downloadsById.get(downloadId) + if (!download) { + return + } + download.browserTabId = browserTabId + download.rendererWebContentsId = this.rendererWebContentsIdByTabId.get(browserTabId) ?? null + } + + protected flushPendingDownloadRequests(browserTabId: string, guestWebContentsId: number): void { + const pending = this.pendingDownloadIdsByGuestId.get(guestWebContentsId) + if (!pending?.length) { + return + } + this.pendingDownloadIdsByGuestId.delete(guestWebContentsId) + for (const downloadId of pending) { + this.bindDownloadToTab(downloadId, browserTabId) + this.flushDownloadSnapshot(downloadId) + } + } + + protected flushDownloadSnapshot(downloadId: string): void { + const download = this.downloadsById.get(downloadId) + if (!download) { + return + } + this.sendDownloadStarted(downloadId) + if (download.receivedBytes > 0 || download.transientState) { + this.sendDownloadProgress(download.browserTabId, { + browserPageId: download.browserTabId ?? undefined, + downloadId: download.downloadId, + receivedBytes: download.receivedBytes, + totalBytes: download.totalBytes, + state: download.transientState + }) + } + if (download.terminalEvent) { + this.sendDownloadFinished(download.browserTabId, { + ...download.terminalEvent, + browserPageId: download.browserTabId ?? undefined + }) + this.downloadsById.delete(downloadId) + } + } + + protected sendDownloadStarted(downloadId: string): void { + const download = this.downloadsById.get(downloadId) + if (!download?.browserTabId) { + return + } + if (download.startedSent) { + return + } + const renderer = this.resolveRendererForBrowserTab(download.browserTabId) + if (!renderer) { + return + } + renderer.send('browser:download-requested', { + browserPageId: download.browserTabId, + downloadId: download.downloadId, + origin: download.origin, + filename: download.filename, + totalBytes: download.totalBytes, + mimeType: download.mimeType, + savePath: download.savePath, + status: 'downloading' + } satisfies BrowserDownloadRequestedEvent) + download.startedSent = true + } + + protected sendDownloadProgress( + browserTabId: string | null, + payload: BrowserDownloadProgressEvent + ): void { + if (!browserTabId) { + return + } + const renderer = this.resolveRendererForBrowserTab(browserTabId) + if (!renderer) { + return + } + renderer.send('browser:download-progress', payload) + } + + protected sendDownloadFinished( + browserTabId: string | null, + payload: BrowserDownloadFinishedEvent + ): void { + if (!browserTabId) { + return + } + const renderer = this.resolveRendererForBrowserTab(browserTabId) + if (!renderer) { + return + } + renderer.send('browser:download-finished', payload) + } + + protected async settleClientHostedDownload( + download: ActiveDownload, + status: BrowserDownloadFinishedEvent['status'], + failure: string | null + ): Promise { + const route = download.clientRoute + if (!route) { + return + } + if (status !== 'completed') { + download.clientRoute = null + await route.abort().catch(() => undefined) + this.finishDownloadInternal(download.downloadId, status, failure) + return + } + try { + // Why: the route stays on the record for the whole commit, which spans many round trips -- a + // cancel arriving mid-stream has to find something to abort or the bytes land anyway. + const remoteDestination = await route.complete(download.filename) + download.clientRoute = null + download.remoteDestination = remoteDestination + // Why: the staged copy is deleted, so a client save path would name a file that no longer exists. + download.savePath = '' + this.finishDownloadInternal(download.downloadId, 'completed', null) + } catch (error) { + download.clientRoute = null + if (download.terminalEvent) { + // A cancel already reported the outcome; this rejection is that cancel taking effect. + return + } + console.error('[browser-download] Failed to save download to the remote workspace:', error) + this.finishDownloadInternal( + download.downloadId, + 'failed', + 'Could not save the download to the remote workspace.' + ) + } + } + + protected cancelDownloadInternal(downloadId: string, reason: string): void { + const download = this.downloadsById.get(downloadId) + if (!download) { + return + } + + if (download.cleanup) { + download.cleanup() + download.cleanup = null + } + const shouldSendCancel = !download.terminalEvent + + try { + download.item.cancel() + } catch { + // Why: cancel() can throw on an already-finalized item; best-effort since UI state is authoritative. + } + + if (shouldSendCancel) { + this.finishDownloadInternal(downloadId, 'canceled', reason || null) + return + } + + this.downloadsById.delete(downloadId) + } + + protected finishDownloadInternal( + downloadId: string, + status: BrowserDownloadFinishedEvent['status'], + error: string | null + ): void { + const download = this.downloadsById.get(downloadId) + if (!download || download.terminalEvent) { + return + } + + if (download.cleanup) { + download.cleanup() + download.cleanup = null + } + browserDownloadDestinationReservations.release(download.reservationKey) + download.reservationKey = null + if (download.clientRoute) { + // Why: a cancel path can reach here before the relay settled; the staged copy must not survive. + void download.clientRoute.abort().catch(() => undefined) + download.clientRoute = null + } + const event: BrowserDownloadFinishedEvent = { + browserPageId: download.browserTabId ?? undefined, + downloadId: download.downloadId, + status, + savePath: download.savePath || null, + ...(download.remoteDestination ? { remoteDestination: download.remoteDestination } : {}), + error + } + download.terminalEvent = event + if (download.browserTabId) { + this.sendDownloadStarted(downloadId) + this.sendDownloadFinished(download.browserTabId, event) + this.downloadsById.delete(downloadId) + } + } + + protected cancelPendingDownloadsForGuest(guestWebContentsId: number): void { + const pending = this.pendingDownloadIdsByGuestId.get(guestWebContentsId) + this.pendingDownloadIdsByGuestId.delete(guestWebContentsId) + if (!pending?.length) { + return + } + for (const downloadId of pending) { + const download = this.downloadsById.get(downloadId) + if (!download) { + continue + } + if (download.terminalEvent) { + this.downloadsById.delete(downloadId) + continue + } + this.cancelDownloadInternal(downloadId, 'Browser page closed before download could be shown.') + const afterCancel = this.downloadsById.get(downloadId) + if (afterCancel?.terminalEvent && !afterCancel.browserTabId) { + this.downloadsById.delete(downloadId) + } + } + } + + protected getDownloadReceivedBytes(item: Electron.DownloadItem): number { + try { + return Math.max(0, item.getReceivedBytes()) + } catch { + return 0 + } + } +} diff --git a/src/main/browser/browser-manager-event-forwarding.ts b/src/main/browser/browser-manager-event-forwarding.ts new file mode 100644 index 00000000000..62c6e1d00a1 --- /dev/null +++ b/src/main/browser/browser-manager-event-forwarding.ts @@ -0,0 +1,123 @@ +import type { + BrowserPermissionDeniedEvent, + BrowserPopupEvent +} from '../../shared/browser-guest-events' +import { redactKagiSessionToken } from '../../shared/browser-url' +import { BrowserManagerBindings } from './browser-manager-bindings' +import type { PendingPermissionEvent, PendingPopupEvent } from './browser-manager-types' + +export abstract class BrowserManagerEventForwarding extends BrowserManagerBindings { + protected forwardOrQueueGuestLoadFailure( + guestWebContentsId: number, + loadError: { code: number; description: string; validatedUrl: string } + ): void { + const browserTabId = this.tabIdByWebContentsId.get(guestWebContentsId) + if (!browserTabId) { + // Why: a failure can arrive before the tab is registered; queue by guest ID so registerGuest can replay it. + this.pendingLoadFailuresByGuestId.set(guestWebContentsId, loadError) + return + } + this.sendGuestLoadFailure(browserTabId, loadError) + } + + protected forwardOrQueuePermissionDenied( + guestWebContentsId: number, + event: PendingPermissionEvent + ): void { + const browserTabId = this.resolveBrowserTabIdForGuestWebContentsId(guestWebContentsId) + if (!browserTabId) { + const pending = this.pendingPermissionEventsByGuestId.get(guestWebContentsId) ?? [] + pending.push(event) + if (pending.length > 5) { + pending.shift() + } + this.pendingPermissionEventsByGuestId.set(guestWebContentsId, pending) + return + } + this.sendPermissionDenied(browserTabId, event) + } + + protected flushPendingPermissionEvents(browserTabId: string, guestWebContentsId: number): void { + const pending = this.pendingPermissionEventsByGuestId.get(guestWebContentsId) + if (!pending?.length) { + return + } + this.pendingPermissionEventsByGuestId.delete(guestWebContentsId) + for (const event of pending) { + this.sendPermissionDenied(browserTabId, event) + } + } + + protected sendPermissionDenied(browserTabId: string, event: PendingPermissionEvent): void { + const renderer = this.resolveRendererForBrowserTab(browserTabId) + if (!renderer) { + return + } + renderer.send('browser:permission-denied', { + browserPageId: browserTabId, + ...event + } satisfies BrowserPermissionDeniedEvent) + } + + protected forwardOrQueuePopupEvent(guestWebContentsId: number, event: PendingPopupEvent): void { + const browserTabId = this.resolveBrowserTabIdForGuestWebContentsId(guestWebContentsId) + if (!browserTabId) { + const pending = this.pendingPopupEventsByGuestId.get(guestWebContentsId) ?? [] + pending.push(event) + if (pending.length > 5) { + pending.shift() + } + this.pendingPopupEventsByGuestId.set(guestWebContentsId, pending) + return + } + this.sendPopupEvent(browserTabId, event) + } + + protected flushPendingPopupEvents(browserTabId: string, guestWebContentsId: number): void { + const pending = this.pendingPopupEventsByGuestId.get(guestWebContentsId) + if (!pending?.length) { + return + } + this.pendingPopupEventsByGuestId.delete(guestWebContentsId) + for (const event of pending) { + this.sendPopupEvent(browserTabId, event) + } + } + + protected sendPopupEvent(browserTabId: string, event: PendingPopupEvent): void { + const renderer = this.resolveRendererForBrowserTab(browserTabId) + if (!renderer) { + return + } + renderer.send('browser:popup', { + browserPageId: browserTabId, + ...event + } satisfies BrowserPopupEvent) + } + + protected flushPendingLoadFailure(browserTabId: string, guestWebContentsId: number): void { + const pending = this.pendingLoadFailuresByGuestId.get(guestWebContentsId) + if (!pending) { + return + } + this.pendingLoadFailuresByGuestId.delete(guestWebContentsId) + this.sendGuestLoadFailure(browserTabId, pending) + } + + protected sendGuestLoadFailure( + browserTabId: string, + loadError: { code: number; description: string; validatedUrl: string } + ): void { + const renderer = this.resolveRendererForBrowserTab(browserTabId) + if (!renderer) { + return + } + renderer.send('browser:guest-load-failed', { + browserPageId: browserTabId, + loadError: { + ...loadError, + validatedUrl: redactKagiSessionToken(loadError.validatedUrl) + } + }) + } +} diff --git a/src/main/browser/browser-manager-final.ts b/src/main/browser/browser-manager-final.ts new file mode 100644 index 00000000000..b19369fc91d --- /dev/null +++ b/src/main/browser/browser-manager-final.ts @@ -0,0 +1,22 @@ +import { ORCA_BROWSER_BLANK_URL } from '../../shared/constants' +import { normalizeBrowserNavigationUrl } from '../../shared/browser-url' +import { BrowserManagerEventForwarding } from './browser-manager-event-forwarding' + +export abstract class BrowserManagerFinal extends BrowserManagerEventForwarding { + protected openLinkInOrcaTab(browserTabId: string, rawUrl: string): boolean { + const renderer = this.resolveRendererForBrowserTab(browserTabId) + if (!renderer) { + return false + } + const normalizedUrl = normalizeBrowserNavigationUrl(rawUrl) + if (!normalizedUrl || normalizedUrl === ORCA_BROWSER_BLANK_URL) { + return false + } + // Why: only the renderer owns Orca's worktree/tab model; main forwards a validated URL, never letting guest content mutate it. + renderer.send('browser:open-link-in-orca-tab', { + browserPageId: browserTabId, + url: normalizedUrl + }) + return true + } +} diff --git a/src/main/browser/browser-manager-grab.ts b/src/main/browser/browser-manager-grab.ts new file mode 100644 index 00000000000..77e890138c5 --- /dev/null +++ b/src/main/browser/browser-manager-grab.ts @@ -0,0 +1,126 @@ +import { webContents } from 'electron' +import { buildGuestOverlayScript } from './grab-guest-script' +import { clampGrabPayload } from './browser-grab-payload' +import { captureSelectionScreenshot as captureGrabSelectionScreenshot } from './browser-grab-screenshot' +import { getWorkspaceDocPageGuest } from './doc-preview-guest-policy' +import type { + BrowserGrabCancelReason, + BrowserGrabResult, + BrowserGrabRect, + BrowserGrabPayload, + BrowserGrabScreenshot +} from './browser-manager-types' +import { BrowserManagerViewport } from './browser-manager-viewport' + +export abstract class BrowserManagerGrab extends BrowserManagerViewport { + // --- Browser Context Grab — main-owned operations --- + + /** Validate that the sender owns browserTabId; returns the guest WebContents or null. */ + /** + * The guest a request from `senderWebContentsId` may act on, across both halves of the page + * registry. This is the only door taught about workspace-document guests: they are kept out of + * the browsing maps entirely, so page management, agent commands, download routing and + * certificate attribution all miss them without a guard of their own — and a reader who opens a + * tool on the document in front of them still gets an answer. + */ + getAuthorizedGuest( + browserTabId: string, + senderWebContentsId: number + ): Electron.WebContents | null { + const docGuest = getWorkspaceDocPageGuest(browserTabId, senderWebContentsId) + if (docGuest) { + return docGuest + } + const registeredRenderer = this.rendererWebContentsIdByTabId.get(browserTabId) + if (registeredRenderer == null || registeredRenderer !== senderWebContentsId) { + return null + } + const guestId = this.webContentsIdByTabId.get(browserTabId) + if (guestId == null) { + return null + } + const guest = webContents.fromId(guestId) + if (!guest || guest.isDestroyed()) { + // Why: a stale guest must clear every per-tab registry entry, not just the WebContents maps. + this.unregisterGuest(browserTabId) + return null + } + return guest + } + + /** Returns true if a grab operation is currently active for this tab. */ + hasActiveGrabOp(browserTabId: string): boolean { + return this.grabSessionController.hasActiveGrabOp(browserTabId) + } + + /** Enable/disable grab mode for a tab: on enable inject the overlay runtime, on disable cancel any active grab op. */ + async setGrabMode( + browserTabId: string, + enabled: boolean, + guest: Electron.WebContents + ): Promise { + if (!enabled) { + const hadActiveGrabOp = this.hasActiveGrabOp(browserTabId) + this.cancelGrabOp(browserTabId, 'user') + if (hadActiveGrabOp) { + return true + } + try { + await guest.executeJavaScript(buildGuestOverlayScript('teardown')) + return true + } catch { + return false + } + } + // Why: inject the overlay runtime eagerly on arm so the hover UI appears instantly; re-injection is idempotent/safe. + try { + await guest.executeJavaScript(buildGuestOverlayScript('arm')) + return true + } catch { + return false + } + } + + /** + * Await a single grab selection on the given tab; resolves once on click, cancel, or error. + * + * Why in-guest: before-input-event fires only for keyboard (not mouse) on guests, so the overlay hit-catcher consumes the click. + */ + awaitGrabSelection( + browserTabId: string, + opId: string, + guest: Electron.WebContents + ): Promise { + return this.grabSessionController.awaitGrabSelection(browserTabId, opId, guest) + } + + /** Cancel an active grab operation for the given tab. */ + cancelGrabOp(browserTabId: string, reason: BrowserGrabCancelReason): void { + this.grabSessionController.cancelGrabOp(browserTabId, reason) + } + + /** Capture a screenshot of the guest surface, optionally cropped to the given CSS-pixel rect. */ + async captureSelectionScreenshot( + _browserTabId: string, + rect: BrowserGrabRect, + guest: Electron.WebContents + ): Promise { + return captureGrabSelectionScreenshot(rect, guest) + } + + /** Extract the hovered element's payload without disrupting the active grab overlay/awaitClick listener. */ + async extractHoverPayload( + _browserTabId: string, + guest: Electron.WebContents + ): Promise { + try { + const rawPayload = await guest.executeJavaScript(buildGuestOverlayScript('extractHover')) + if (!rawPayload || typeof rawPayload !== 'object') { + return null + } + return clampGrabPayload(rawPayload) + } catch { + return null + } + } +} diff --git a/src/main/browser/browser-manager-guest-cleanup.ts b/src/main/browser/browser-manager-guest-cleanup.ts new file mode 100644 index 00000000000..9dac4f3f665 --- /dev/null +++ b/src/main/browser/browser-manager-guest-cleanup.ts @@ -0,0 +1,44 @@ +import { BrowserManagerGuestNavigationPolicy } from './browser-manager-guest-navigation-policy' + +export abstract class BrowserManagerGuestCleanup extends BrowserManagerGuestNavigationPolicy { + protected retireStaleGuestWebContents(previousWebContentsId: number): void { + // Why: after a renderer-process swap, stop the dead guest id resolving to the live page so stale callbacks don't hit the wrong session. + this.cleanupGuestPolicyAttachment(previousWebContentsId) + } + + protected cleanupGuestPolicyAttachment(guestWebContentsId: number): void { + const browserTabId = this.tabIdByWebContentsId.get(guestWebContentsId) + const isPrimaryGuest = browserTabId !== undefined + if (browserTabId && this.webContentsIdByTabId.get(browserTabId) === guestWebContentsId) { + this.webContentsIdByTabId.delete(browserTabId) + } + this.tabIdByWebContentsId.delete(guestWebContentsId) + this.certificateTrustController?.onGuestRetired(guestWebContentsId) + const policyCleanup = this.policyCleanupByGuestId.get(guestWebContentsId) + if (policyCleanup) { + policyCleanup() + this.policyCleanupByGuestId.delete(guestWebContentsId) + } + this.policyAttachedGuestIds.delete(guestWebContentsId) + this.clickedLinkFrameNameByGuestId.delete(guestWebContentsId) + this.offscreenGuestIds.delete(guestWebContentsId) + this.popupOwnerContextByGuestId.delete(guestWebContentsId) + this.pageInitiatedTabBudgetByRootGuestId.delete(guestWebContentsId) + this.authUserAgentOverrideStateByGuestId.delete(guestWebContentsId) + this.pendingNavigationByGuestId.delete(guestWebContentsId) + // Why: a popup must stop inheriting authorization the moment its owner retires, before Chromium destroys the child. + if (isPrimaryGuest) { + for (const [popupGuestId, owner] of this.popupOwnerContextByGuestId) { + if (owner.rootGuestWebContentsId === guestWebContentsId) { + this.popupOwnerContextByGuestId.delete(popupGuestId) + } + } + } + this.pendingLoadFailuresByGuestId.delete(guestWebContentsId) + this.loadErrorsByGuestId.delete(guestWebContentsId) + this.clearedLoadErrorsByGuestId.delete(guestWebContentsId) + this.pendingPermissionEventsByGuestId.delete(guestWebContentsId) + this.pendingPopupEventsByGuestId.delete(guestWebContentsId) + this.cancelPendingDownloadsForGuest(guestWebContentsId) + } +} diff --git a/src/main/browser/browser-manager-guest-navigation-policy.ts b/src/main/browser/browser-manager-guest-navigation-policy.ts new file mode 100644 index 00000000000..abacd268640 --- /dev/null +++ b/src/main/browser/browser-manager-guest-navigation-policy.ts @@ -0,0 +1,152 @@ +import { + normalizeBrowserNavigationUrl, + toSecureCertificateEndpoint +} from '../../shared/browser-url' +import { isChromiumInternalErrorUrl } from './browser-manager-types' +import { BrowserManagerGuestPopupPolicy } from './browser-manager-guest-popup-policy' + +export abstract class BrowserManagerGuestNavigationPolicy extends BrowserManagerGuestPopupPolicy { + protected installGuestNavigationPolicy(guest: Electron.WebContents): () => void { + const navigationGuard = (event: Electron.Event, url: string): boolean => { + // Why: Turnstile loads challenge resources via blob:; blocking them trips error 600010. Allow only http(s) blobs, not opaque ones. + if (url.startsWith('blob:https://') || url.startsWith('blob:http://')) { + return true + } + // Why: initial file:// attach is allowed for user-opened previews, but block later file:// redirects so remote pages can't probe the FS. + if (url.startsWith('file:')) { + event.preventDefault() + return false + } + if (!normalizeBrowserNavigationUrl(url)) { + // Why: will-attach-webview only validates the initial src; keep enforcing the allowlist on later navs. + event.preventDefault() + return false + } + return true + } + + const willRedirectHandler = ( + event: Electron.Event, + url: string, + _isInPlace: boolean, + isMainFrame: boolean + ): void => { + if (!navigationGuard(event, url) || !isMainFrame || isChromiumInternalErrorUrl(url)) { + return + } + this.updatePendingNavigationForRedirect(guest.id, url) + this.applyGoogleAuthUserAgent(guest, url, { duringRedirect: true }) + } + + const didFailLoadHandler = ( + _event: Electron.Event, + errorCode: number, + errorDescription: string, + validatedURL: string, + isMainFrame: boolean + ): void => { + if (!isMainFrame) { + return + } + // Why: a nav that never committed must not leave its target standing as the tab's host. + const failedNavigationWasCurrent = this.failPendingNavigation(guest.id, validatedURL) + if (failedNavigationWasCurrent) { + // The attempted host never committed, so restore every UA layer to the document that remains. + this.applyGoogleAuthUserAgent(guest, guest.getURL()) + } + 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: this cancellation carries the existing cert warning; don't overwrite it with ERR_ABORTED copy. + return + } + if (errorCode === -3) { + // Why: an aborted nav never committed; restore the error did-start-navigation cleared so it isn't lost. + 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 = this.buildLoadError( + errorCode, + errorDescription || 'This site could not be reached.', + 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 + } + // Why: getURL() still reports the previous committed URL until this navigation commits, so + // every UA writer must read the in-flight target or they disagree about the tab's host. + this.startPendingNavigation(guest.id, url) + this.applyGoogleAuthUserAgent(guest, url) + this.certificateTrustController?.onMainFrameNavigationStarted(guest.id) + // Why: a pre-registration failure belongs only to its own nav; a replacement nav must not replay it. + 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 can't resurrect an old failure. + 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: once committed, getURL() reports this url, so the pending target is redundant. + this.pendingNavigationByGuestId.delete(guest.id) + // Why: a committed nav makes the did-start-navigation stash obsolete; drop it so a later ERR_ABORTED can't restore an error over it. + this.clearedLoadErrorsByGuestId.delete(guest.id) + this.certificateTrustController?.onMainFrameNavigationCommitted(guest.id, url) + } + + guest.on('will-navigate', navigationGuard) + guest.on('will-redirect', willRedirectHandler) + guest.on('did-start-navigation', didStartNavigationHandler) + guest.on('did-navigate', didNavigateHandler) + guest.on('did-fail-load', didFailLoadHandler) + const handleDestroyed = (): void => { + // Why: guests can die before renderer registration, else attach-time closures leak until shutdown. + this.cleanupGuestPolicyAttachment(guest.id) + } + guest.on('destroyed', handleDestroyed) + + return () => { + try { + guest.off('destroyed', handleDestroyed) + } catch { + // guest may already be destroyed + } + if (!guest.isDestroyed()) { + guest.off('will-navigate', navigationGuard) + guest.off('will-redirect', willRedirectHandler) + guest.off('did-start-navigation', didStartNavigationHandler) + guest.off('did-navigate', didNavigateHandler) + guest.off('did-fail-load', didFailLoadHandler) + } + } + } +} diff --git a/src/main/browser/browser-manager-guest-policy.ts b/src/main/browser/browser-manager-guest-policy.ts new file mode 100644 index 00000000000..c0d522235c8 --- /dev/null +++ b/src/main/browser/browser-manager-guest-policy.ts @@ -0,0 +1,76 @@ +import { randomUUID } from 'node:crypto' +import { + BROWSING_GUEST_POLICY, + type BrowserGuestPolicy, + type PopupOwnerContext +} from './browser-manager-types' +import { BrowserManagerGuestCleanup } from './browser-manager-guest-cleanup' +import { installDocPreviewGuestPolicy } from './doc-preview-guest-policy' + +export abstract class BrowserManagerGuestPolicy extends BrowserManagerGuestCleanup { + attachGuestPolicies( + guest: Electron.WebContents, + inheritedOwnerContext: PopupOwnerContext | null = null, + policy: BrowserGuestPolicy = BROWSING_GUEST_POLICY + ): void { + if (this.policyAttachedGuestIds.has(guest.id)) { + return + } + this.policyAttachedGuestIds.add(guest.id) + // Why one door with a profile rather than a second installer beside it: whether a guest was + // policy-attached at all is what registration and teardown both key on, so a guest that took + // another path into the app is invisible to both. + if (policy.profile === 'workspace-doc') { + this.attachWorkspaceDocGuestPolicies(guest, policy.host) + return + } + if (inheritedOwnerContext) { + this.popupOwnerContextByGuestId.set(guest.id, inheritedOwnerContext) + } + // Why: only the primary embedded browser converts new-tab clicks to Orca tabs; OAuth child windows keep native link behavior. + const clickedLinkFrameName = inheritedOwnerContext + ? null + : `__orca_clicked_link_foreground_${randomUUID()}` + if (clickedLinkFrameName) { + this.clickedLinkFrameNameByGuestId.set(guest.id, clickedLinkFrameName) + } + + // Why: bot detectors probe APIs that differ in Electron webviews; inject overrides each load so manual browsing passes. + const disposeAntiDetection = this.injectAntiDetection(guest) + // Why: disable throttling so background screenshots still get frames; else the compositor stalls and capture returns empty. + guest.setBackgroundThrottling(false) + const disposePopupPolicy = this.installGuestPopupPolicy(guest, clickedLinkFrameName) + const disposeNavigationPolicy = this.installGuestNavigationPolicy(guest) + + // Why: store cleanup so unregisterGuest can drop these listeners on teardown and let the WebContents wrapper GC. + this.policyCleanupByGuestId.set(guest.id, () => { + disposeAntiDetection() + disposePopupPolicy() + disposeNavigationPolicy() + }) + } + + /** + * A workspace document is not the web: no popups, no link routing, no anti-detection, and no + * navigation bookkeeping for chrome it does not have. What it does share with a browsing guest is + * this method's teardown, so a retired preview drops its listeners on the same path. + */ + protected attachWorkspaceDocGuestPolicies( + guest: Electron.WebContents, + host: Electron.WebContents + ): void { + const disposeDocPolicy = installDocPreviewGuestPolicy(guest, host) + const handleDestroyed = (): void => { + this.cleanupGuestPolicyAttachment(guest.id) + } + guest.on('destroyed', handleDestroyed) + this.policyCleanupByGuestId.set(guest.id, () => { + disposeDocPolicy() + try { + guest.off('destroyed', handleDestroyed) + } catch { + // guest may already be destroyed + } + }) + } +} diff --git a/src/main/browser/browser-manager-guest-popup-policy.ts b/src/main/browser/browser-manager-guest-popup-policy.ts new file mode 100644 index 00000000000..43133fd8e29 --- /dev/null +++ b/src/main/browser/browser-manager-guest-popup-policy.ts @@ -0,0 +1,210 @@ +import { shell } from 'electron' +import { randomUUID } from 'node:crypto' +import { ORCA_BROWSER_BLANK_URL } from '../../shared/constants' +import { + normalizeBrowserNavigationUrl, + normalizeExternalBrowserUrl, + redactKagiSessionToken +} from '../../shared/browser-url' +import { + BROWSER_CLICKED_LINK_ROUTING_WORLD_ID, + buildBrowserClickedLinkRoutingScript, + buildBrowserIframeClickedLinkRoutingScript +} from './browser-clicked-link-routing' +import { isNewBrowserTabPopupIntent } from './browser-popup-new-tab-intent' +import { SAFE_POPUP_WINDOW_OPTIONS, safeOrigin } from './browser-manager-types' +import type { PopupChildWindowOptions } from './popup-origin-bar-window' +import { BrowserManagerNavigation } from './browser-manager-navigation' + +export abstract class BrowserManagerGuestPopupPolicy extends BrowserManagerNavigation { + protected installGuestPopupPolicy( + guest: Electron.WebContents, + clickedLinkFrameName: string | null + ): () => void { + let clickedLinkRoutingActive = Boolean(clickedLinkFrameName) + const installClickedLinkRouting = (): void => { + if (!clickedLinkRoutingActive || !clickedLinkFrameName || guest.isDestroyed()) { + return + } + // Why: an isolated-world listener labels real anchor clicks without exposing the frame name to page scripts. + void guest + .executeJavaScriptInIsolatedWorld( + BROWSER_CLICKED_LINK_ROUTING_WORLD_ID, + [ + { + // Why: mobile emulation spoofs the UA as iOS, so use the real host platform from main for modifier routing. + code: buildBrowserClickedLinkRoutingScript( + clickedLinkFrameName, + process.platform === 'darwin' + ) + } + ], + false + ) + .catch(() => {}) + } + if (clickedLinkFrameName) { + guest.on('dom-ready', installClickedLinkRouting) + } + const pendingIframeRoutingInstalls = new Map void>() + const iframeFrameNameByFrame = new Map() + const iframeFrameByFrameName = new Map() + const clearIframeFrameName = (frame: Electron.WebFrameMain): void => { + const name = iframeFrameNameByFrame.get(frame) + if (!name) { + return + } + iframeFrameNameByFrame.delete(frame) + iframeFrameByFrameName.delete(name) + } + const installIframeClickedLinkRouting = (frame: Electron.WebFrameMain): void => { + clearIframeFrameName(frame) + if (!clickedLinkRoutingActive || frame.isDestroyed()) { + return + } + const name = `__orca_clicked_link_iframe_foreground_${randomUUID()}` + iframeFrameNameByFrame.set(frame, name) + iframeFrameByFrameName.set(name, frame) + // Why: child-frame tokens live in the page world, so consume after one trusted click and replace before the next. + void frame + .executeJavaScript( + buildBrowserIframeClickedLinkRoutingScript(name, process.platform === 'darwin'), + false + ) + .catch(() => { + if (iframeFrameNameByFrame.get(frame) === name) { + clearIframeFrameName(frame) + } + }) + } + const handleFrameCreated = ( + _event: Electron.Event, + { frame }: Electron.FrameCreatedDetails + ): void => { + if (!clickedLinkFrameName || !frame || frame.parent === null) { + return + } + for (const knownFrame of iframeFrameNameByFrame.keys()) { + if (knownFrame.isDestroyed()) { + clearIframeFrameName(knownFrame) + } + } + const installAfterDomReady = (): void => { + pendingIframeRoutingInstalls.delete(frame) + installIframeClickedLinkRouting(frame) + } + pendingIframeRoutingInstalls.set(frame, installAfterDomReady) + frame.once('dom-ready', installAfterDomReady) + } + if (clickedLinkFrameName) { + guest.on('frame-created', handleFrameCreated) + } + const handleDidCreateWindow = (window: Electron.BrowserWindow): void => { + // Why: popup descendants inherit the opener's owner context but must not replace its primary registration. + this.attachGuestPolicies(window.webContents, this.resolvePopupOwnerContext(guest.id)) + } + guest.on('did-create-window', handleDidCreateWindow) + guest.setWindowOpenHandler(({ url, frameName, disposition, features }) => { + const ownerContext = this.resolvePopupOwnerContext(guest.id) + const browserTabId = ownerContext?.browserTabId ?? null + const browserUrl = normalizeBrowserNavigationUrl(url) + const externalUrl = normalizeExternalBrowserUrl(url) + const expectedClickedLinkFrameName = this.clickedLinkFrameNameByGuestId.get(guest.id) + const iframeFrame = frameName ? iframeFrameByFrameName.get(frameName) : undefined + let isClickedLink = Boolean( + expectedClickedLinkFrameName && frameName === expectedClickedLinkFrameName + ) + if (!isClickedLink && iframeFrame) { + isClickedLink = true + clearIframeFrameName(iframeFrame) + queueMicrotask(() => installIframeClickedLinkRouting(iframeFrame)) + } + + if (isClickedLink) { + if (browserTabId && browserUrl && this.openLinkInOrcaTab(browserTabId, browserUrl)) { + this.forwardOrQueuePopupEvent(guest.id, { + origin: safeOrigin(browserUrl), + action: 'opened-in-orca' + }) + } + // Why: a recognized gesture must never fall through to a native popup if its renderer vanished mid-click. + return { action: 'deny' } + } + + // Why: an unnamed, featureless window.open() is Chromium's own new-tab shape, so an Orca tab is + // the honest presentation; a floating origin-bar window is not. Opener-dependent shapes are + // excluded by isNewBrowserTabPopupIntent and still get a real child window below. + if ( + ownerContext && + externalUrl && + isNewBrowserTabPopupIntent({ frameName, disposition, features }) + ) { + // Why: one activation lets a page loop window.open, and each routed tab persists into + // workspace session state, so it survives the quit that used to clear popup windows. + if (!this.tryConsumePageInitiatedTab(ownerContext.rootGuestWebContentsId)) { + this.forwardOrQueuePopupEvent(guest.id, { + origin: safeOrigin(externalUrl), + action: 'blocked' + }) + return { action: 'deny' } + } + if (this.openLinkInOrcaTab(ownerContext.browserTabId, externalUrl)) { + this.forwardOrQueuePopupEvent(guest.id, { + origin: safeOrigin(externalUrl), + action: 'opened-in-orca' + }) + } + // Why: a recognized new-tab intent must never fall through to a native popup if its renderer vanished mid-open. + return { action: 'deny' } + } + + // Why: file URLs are fine for in-pane previews, but must not spawn native child windows targeting local paths. + const canOpenAsChild = Boolean(externalUrl || browserUrl === ORCA_BROWSER_BLANK_URL) + if (browserTabId && canOpenAsChild) { + // Why: OAuth may request size/position, but content must not create deceptive or inescapable native chrome. + return { + action: 'allow', + overrideBrowserWindowOptions: SAFE_POPUP_WINDOW_OPTIONS, + // Why: default child windows lack an address bar; host in an Orca origin-bar window so the destination is verifiable. + createWindow: (options: PopupChildWindowOptions) => + this.createPopupChildWindowWithOriginBar(guest, url, options) + } + } else if (externalUrl) { + // Why: Kagi target=_blank popup URLs still contain the bearer token; redact before handing to the OS browser. + void shell.openExternal(redactKagiSessionToken(externalUrl)) + this.forwardOrQueuePopupEvent(guest.id, { + origin: safeOrigin(externalUrl), + action: 'opened-external' + }) + } else { + // Why: popup URLs can carry auth redirects/one-time tokens; surface only sanitized origin metadata. + this.forwardOrQueuePopupEvent(guest.id, { + origin: safeOrigin(url), + action: 'blocked' + }) + } + return { action: 'deny' } + }) + + return () => { + clickedLinkRoutingActive = false + try { + guest.off('did-create-window', handleDidCreateWindow) + if (clickedLinkFrameName) { + guest.off('dom-ready', installClickedLinkRouting) + guest.off('frame-created', handleFrameCreated) + for (const [frame, install] of pendingIframeRoutingInstalls) { + if (!frame.isDestroyed()) { + frame.off('dom-ready', install) + } + } + pendingIframeRoutingInstalls.clear() + iframeFrameNameByFrame.clear() + iframeFrameByFrameName.clear() + } + } catch { + // guest may already be destroyed + } + } + } +} diff --git a/src/main/browser/browser-manager-navigation.ts b/src/main/browser/browser-manager-navigation.ts new file mode 100644 index 00000000000..4e061d288aa --- /dev/null +++ b/src/main/browser/browser-manager-navigation.ts @@ -0,0 +1,263 @@ +import { openPopupWithOriginBar, type PopupChildWindowOptions } from './popup-origin-bar-window' +import { cleanElectronUserAgent } from './browser-session-ua' +import { getBrowserSessionUserAgentMode } from './browser-session-user-agent-mode' +import { googleAuthUserAgent, isGoogleAuthUrl } from './browser-google-auth-ua' +import { buildViewportUserAgentOverride } from './browser-viewport-user-agent' +import { + safeOrigin, + type AuthUserAgentOverrideOperation, + type AuthUserAgentOverrideState +} from './browser-manager-types' +import { BrowserManagerVisibility } from './browser-manager-visibility' + +export abstract class BrowserManagerNavigation extends BrowserManagerVisibility { + // Why: navigator.userAgent (read by Google's auth JS) reflects the WebContents UA, + // not the request header, so the header-level Firefox switch in setupClientHintsOverride + // must be matched here per navigation or the two layers disagree — itself a bot tell. + // Restores the session's base identity off the auth hosts. Native-UA profiles opt out + // of the whole clean-UA path, so they keep their untouched identity everywhere. + protected applyGoogleAuthUserAgent( + guest: Electron.WebContents, + url: string, + options: { duringRedirect?: boolean } = {} + ): void { + const browserPageId = this.tabIdByWebContentsId.get(guest.id) + // Why: popup child windows get these policies but are never in tabIdByWebContentsId, so a direct + // lookup misses the native-UA opt-out and would hand a native profile's popup the Firefox UA. + // That is worse than doing nothing: native sessions skip setupClientHintsOverride entirely, so + // the popup would send the raw Electron UA on the wire while navigator.userAgent claims Firefox. + const ownerTabId = this.resolveBrowserTabIdForGuestWebContentsId(guest.id) + // Session state is authoritative before renderer registration and after a native profile imports a source UA. + const mode = + getBrowserSessionUserAgentMode(guest.session) ?? + (ownerTabId ? this.userAgentModeByPageId.get(ownerTabId) : undefined) + if (mode === 'native') { + return + } + const firefoxUa = googleAuthUserAgent() + const overrideState = this.authUserAgentOverrideStateByGuestId.get(guest.id) + const latestPendingOverride = overrideState?.pending.at(-1) + const confirmedOverride = overrideState?.confirmed + const currentOverride = + latestPendingOverride && latestPendingOverride.sequence > (confirmedOverride?.sequence ?? -1) + ? latestPendingOverride + : confirmedOverride + const currentUa = currentOverride?.userAgent ?? guest.getUserAgent() + const nextUa = isGoogleAuthUrl(url) + ? firefoxUa + : // Only restore when the auth-host override is actually in place, so normal + // navigation never touches the session UA. + currentUa === firefoxUa + ? guest.session.getUserAgent() + : null + let authOverrideIssuedOverCdp = false + if (nextUa !== null && nextUa !== currentUa) { + // Why: WebContents.setUserAgent() during a redirect makes Chromium cancel the in-flight + // navigation (ERR_ABORTED) and replay the original request, which a POST-started OAuth chain + // cannot survive — the sign-in lands on a blank tab. CDP retargets navigator.userAgent without + // touching the navigation, and it outranks the WebContents UA from then on, so a guest that + // switches to it stays on it. The wire UA never depended on this write: setupClientHintsOverride + // rewrites User-Agent per request for auth-host URLs on its own. + if (options.duringRedirect === true || overrideState !== undefined) { + if (this.canOverrideUserAgentOverCdp(guest)) { + authOverrideIssuedOverCdp = true + // Why: go through the viewport builder rather than writing nextUa raw, so both CDP writers + // resolve one identity for this URL — Firefox on auth hosts, the profile's clean base off + // them, any mobile preset preserved. Writing the session UA directly would put the + // unlaundered Electron token back on the wire. + void this.applyAuthUserAgentOverrideOverCdp( + guest, + (browserPageId ? this.viewportUaOverrideMobileByTabId.get(browserPageId) : undefined) ?? + false, + url, + nextUa + ) + } + // Why: with no debugger there is no way to retarget the identity without cancelling the + // redirect. A stale navigator.userAgent is recoverable; a dead navigation is not. + } else { + guest.setUserAgent(nextUa) + } + } + // Why: gate on the DIRECT page id, not ownerTabId — a popup has no device-metrics override of + // its own, so inheriting the owner tab's preset UA would pair a mobile UA with a desktop viewport. + if (browserPageId && !authOverrideIssuedOverCdp) { + this.reapplyViewportUserAgentOverride(guest, browserPageId, url) + } + } + + protected canOverrideUserAgentOverCdp(guest: Electron.WebContents): boolean { + try { + return !guest.isDestroyed() && guest.debugger.isAttached() + } catch { + return false + } + } + + protected applyAuthUserAgentOverrideOverCdp( + guest: Electron.WebContents, + mobile: boolean, + url: string, + userAgent: string + ): Promise { + if (!this.canOverrideUserAgentOverCdp(guest)) { + return Promise.resolve(false) + } + const state = this.authUserAgentOverrideStateByGuestId.get(guest.id) ?? { + confirmed: null, + nextSequence: 0, + pending: [] + } + const operation = { sequence: ++state.nextSequence, userAgent } + state.pending.push(operation) + this.authUserAgentOverrideStateByGuestId.set(guest.id, state) + return this.sendViewportUserAgentOverride(guest, mobile, url, userAgent).then( + () => this.settleAuthUserAgentOverride(guest.id, state, operation, true), + () => { + this.settleAuthUserAgentOverride(guest.id, state, operation, false) + return false + } + ) + } + + protected settleAuthUserAgentOverride( + guestId: number, + state: AuthUserAgentOverrideState, + operation: AuthUserAgentOverrideOperation, + succeeded: boolean + ): boolean { + if (this.authUserAgentOverrideStateByGuestId.get(guestId) !== state) { + return false + } + if (succeeded && (state.confirmed?.sequence ?? -1) < operation.sequence) { + state.confirmed = operation + } + const pendingIndex = state.pending.indexOf(operation) + if (pendingIndex !== -1) { + state.pending.splice(pendingIndex, 1) + } + if (state.confirmed === null && state.pending.length === 0) { + this.authUserAgentOverrideStateByGuestId.delete(guestId) + } + return true + } + + protected startPendingNavigation(guestId: number, url: string): void { + const pending = this.pendingNavigationByGuestId.get(guestId) + this.pendingNavigationByGuestId.set(guestId, { + currentUrl: url, + supersededUrls: pending ? [...pending.supersededUrls, pending.currentUrl] : [] + }) + } + + protected updatePendingNavigationForRedirect(guestId: number, url: string): void { + const pending = this.pendingNavigationByGuestId.get(guestId) + if (!pending) { + this.pendingNavigationByGuestId.set(guestId, { + currentUrl: url, + supersededUrls: [] + }) + return + } + pending.currentUrl = url + } + + protected failPendingNavigation(guestId: number, failedUrl: string): boolean { + const pending = this.pendingNavigationByGuestId.get(guestId) + if (!pending) { + return false + } + const supersededIndex = pending.supersededUrls.indexOf(failedUrl) + if (supersededIndex !== -1) { + pending.supersededUrls.splice(supersededIndex, 1) + return false + } + if (pending.currentUrl !== failedUrl) { + return false + } + this.pendingNavigationByGuestId.delete(guestId) + return true + } + + // Why: webContents.getURL() reports the last COMMITTED url, so mid-navigation it names the host + // the tab is leaving, not the one it is entering. Every UA writer must resolve the host through + // here or two writers racing the same navigation will pick opposite identities. + protected resolveTabNavigationUrl(guest: Electron.WebContents): string { + return this.pendingNavigationByGuestId.get(guest.id)?.currentUrl ?? guest.getURL() + } + + // Why: Emulation.setUserAgentOverride is set once and stands across every later navigation, + // outranking setUserAgent for navigator.userAgent. A viewport preset applied before reaching an + // auth host would otherwise pin navigator.userAgent to the Chrome-shaped preset UA while the + // request header says Firefox — the two-layer disagreement this scope exists to remove. + protected reapplyViewportUserAgentOverride( + guest: Electron.WebContents, + browserTabId: string, + url: string + ): void { + const mobile = this.viewportUaOverrideMobileByTabId.get(browserTabId) + if (mobile === undefined) { + return + } + // Why: no queue needed — debugger.sendCommand dispatches in call order over one channel, so the + // later-issued write wins. What matters is that both writers resolve the SAME host, which they + // now do via the navigation target rather than the stale committed URL. + void this.sendViewportUserAgentOverride(guest, mobile, url).catch(() => {}) + } + + protected async sendViewportUserAgentOverride( + guest: Electron.WebContents, + mobile: boolean, + url?: string, + baseUserAgent?: string + ): Promise { + if (guest.isDestroyed() || !guest.debugger.isAttached()) { + return + } + await guest.debugger.sendCommand( + 'Emulation.setUserAgentOverride', + buildViewportUserAgentOverride({ + url: url ?? this.resolveTabNavigationUrl(guest), + mobile, + // Why: the session UA is the profile's stable base identity. guest.getUserAgent() is not: + // applyGoogleAuthUserAgent leaves it pinned to the Firefox auth UA once a guest switches to + // the CDP override, so reading it back here would republish that identity on ordinary hosts. + baseUserAgent: cleanElectronUserAgent(baseUserAgent ?? guest.session.getUserAgent()) + }) + ) + } + + /** Route guests own their own popup handler, so their denials arrive here instead. */ + reportRouteGuestPopupBlocked(input: { openerWebContentsId: number; url: string }): void { + this.forwardOrQueuePopupEvent(input.openerWebContentsId, { + origin: safeOrigin(input.url), + action: 'blocked' + }) + } + + protected createPopupChildWindowWithOriginBar( + openerGuest: Electron.WebContents, + targetUrl: string, + options: PopupChildWindowOptions + ): Electron.WebContents { + const popup = openPopupWithOriginBar(options, targetUrl) + // Why: Electron emits no did-create-window for createWindow children, so attach the opener's policies here. + this.attachGuestPolicies( + popup.contentWebContents, + this.resolvePopupOwnerContext(openerGuest.id) + ) + this.forwardOrQueuePopupEvent(openerGuest.id, { + origin: safeOrigin(targetUrl), + action: 'opened-in-orca' + }) + // Why: match Electron's child-window lifecycle so closing the owning tab doesn't orphan session-bearing popups. + const closePopupWithOpener = (): void => popup.close() + openerGuest.once('destroyed', closePopupWithOpener) + popup.onClosed(() => { + if (!openerGuest.isDestroyed()) { + openerGuest.off('destroyed', closePopupWithOpener) + } + }) + return popup.contentWebContents + } +} diff --git a/src/main/browser/browser-manager-queries.ts b/src/main/browser/browser-manager-queries.ts new file mode 100644 index 00000000000..dd8a5dc87a7 --- /dev/null +++ b/src/main/browser/browser-manager-queries.ts @@ -0,0 +1,136 @@ +import { webContents } from 'electron' +import type { + BrowserCertificateFailure, + BrowserLoadError +} from '../../shared/browser-workspace-types' +import type { ManagedBrowserGuestContext } from './browser-certificate-trust-controller' +import { redactKagiSessionToken } from '../../shared/browser-url' +import { safeOrigin } from './browser-manager-types' +import { BrowserManagerRegistration } from './browser-manager-registration' + +export abstract class BrowserManagerQueries extends BrowserManagerRegistration { + getGuestWebContentsId(browserTabId: string): number | null { + return this.webContentsIdByTabId.get(browserTabId) ?? null + } + + getWebContentsIdByTabId(): Map { + return this.webContentsIdByTabId + } + + getTabIdForWebContentsId(webContentsId: number): string | null { + return this.tabIdByWebContentsId.get(webContentsId) ?? null + } + + getWorktreeIdForTab(browserTabId: string): string | undefined { + return this.worktreeIdByTabId.get(browserTabId) + } + + getRendererContextForGuest( + guestWebContentsId: number + ): { browserPageId: string; renderer: Electron.WebContents } | null { + const browserPageId = this.resolveBrowserTabIdForGuestWebContentsId(guestWebContentsId) + if (!browserPageId) { + return null + } + const renderer = this.resolveRendererForBrowserTab(browserPageId) + return renderer ? { browserPageId, renderer } : null + } + + getSessionProfileIdForTab(browserTabId: string): string | null { + 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' + } + } + + // Why: centralize Kagi session-token redaction so every load-error path (did-fail-load, cert failure) strips it. + protected buildLoadError(code: number, description: string, rawUrl: string): BrowserLoadError { + return { + code, + description, + validatedUrl: redactKagiSessionToken(rawUrl) + } + } + + notifyCertificateFailureChanged( + webContentsId: number, + failure: BrowserCertificateFailure | null, + navigationUrl?: string + ): void { + if (failure && navigationUrl) { + const loadError = this.buildLoadError(failure.errorCode ?? -1, failure.error, 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 }) + } + + protected 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: runs inside an Electron guest event dispatch, so an escaping throw would be a fatal uncaught exception. + try { + this.browserGuestStateChangedListener?.(worktreeId) + } catch (error) { + console.error('[browser-manager] browserGuestStateChanged listener failed', error) + } + } + } + + notifyPermissionDenied(args: { + guestWebContentsId: number + permission: string + rawUrl: string + }): void { + this.forwardOrQueuePermissionDenied(args.guestWebContentsId, { + permission: args.permission, + origin: safeOrigin(args.rawUrl) + }) + } +} diff --git a/src/main/browser/browser-manager-registration.ts b/src/main/browser/browser-manager-registration.ts new file mode 100644 index 00000000000..6850c240950 --- /dev/null +++ b/src/main/browser/browser-manager-registration.ts @@ -0,0 +1,230 @@ +import { webContents } from 'electron' +import { browserDownloadDestinationReservations } from './browser-download-destination' +import { isWorkspaceDocPageId } from './doc-preview-guest-policy' +import type { BrowserSessionUserAgentMode } from '../../shared/browser-workspace-types' +import type { BrowserGuestRegistration } from './browser-manager-types' +import { BrowserManagerGuestPolicy } from './browser-manager-guest-policy' + +export abstract class BrowserManagerRegistration extends BrowserManagerGuestPolicy { + registerGuest({ + browserPageId, + browserTabId: legacyBrowserTabId, + workspaceId, + worktreeId, + sessionProfileId, + userAgentMode, + webContentsId, + rendererWebContentsId + }: BrowserGuestRegistration): boolean { + const browserTabId = browserPageId ?? legacyBrowserTabId + // Why refuse rather than overwrite: the two halves of the registry must stay disjoint, or one + // id resolves in both and the tool door silently prefers the document guest over the page. + if (!browserTabId || isWorkspaceDocPageId(browserTabId)) { + return false + } + // Why: on guest-surface swap, cancel any grab bound to the old guest's listeners so it doesn't strand on a stale webContents. + this.cancelGrabOp(browserTabId, 'evicted') + + const previousCleanup = this.contextMenuCleanupByTabId.get(browserTabId) + if (previousCleanup) { + previousCleanup() + this.contextMenuCleanupByTabId.delete(browserTabId) + } + + const guest = webContents.fromId(webContentsId) + if (!guest || guest.isDestroyed()) { + return false + } + + // Why: don't trust the renderer-sent id blindly — a compromised renderer could pass the main window's id; only accept webview guests. + if (guest.getType() !== 'webview') { + return false + } + if (!this.policyAttachedGuestIds.has(webContentsId)) { + // Why: only trust guests that passed attach-time policy install, or a renderer could point us at an arbitrary webview. + return false + } + + const previousWebContentsId = this.webContentsIdByTabId.get(browserTabId) + if (previousWebContentsId !== undefined && previousWebContentsId !== webContentsId) { + this.retireStaleGuestWebContents(previousWebContentsId) + this.viewportPresetActiveByTabId.delete(browserTabId) + this.viewportScrollStateByTabId.delete(browserTabId) + } + this.webContentsIdByTabId.set(browserTabId, webContentsId) + this.tabIdByWebContentsId.set(webContentsId, browserTabId) + if (workspaceId) { + this.workspaceIdByPageId.set(browserTabId, workspaceId) + } + this.sessionProfileIdByPageId.set(browserTabId, sessionProfileId ?? null) + if (userAgentMode) { + this.userAgentModeByPageId.set(browserTabId, userAgentMode) + } else { + this.userAgentModeByPageId.delete(browserTabId) + } + this.rendererWebContentsIdByTabId.set(browserTabId, rendererWebContentsId) + if (worktreeId) { + this.worktreeIdByTabId.set(browserTabId, worktreeId) + } + this.certificateTrustController?.onGuestRegistered(webContentsId, browserTabId) + + this.setupContextMenu(browserTabId, guest) + this.setupGrabShortcut(browserTabId, guest) + this.setupShortcutForwarding(browserTabId, guest) + this.setupMouseWheelZoomForwarding(browserTabId, guest) + this.flushPendingLoadFailure(browserTabId, webContentsId) + this.flushPendingPermissionEvents(browserTabId, webContentsId) + this.flushPendingPopupEvents(browserTabId, webContentsId) + this.flushPendingDownloadRequests(browserTabId, webContentsId) + return true + } + + unregisterGuest(browserTabId: string): void { + // Why the check on the exit door too: a document page withdraws by revoking its grant, never + // through here, so its id arriving is misaddressed — and the cancel below would evict that + // preview's live grab on the strength of it. + if (isWorkspaceDocPageId(browserTabId)) { + return + } + // Why: teardown mid-grab must cancel it so the renderer gets a signal, not a dangling Promise. + this.cancelGrabOp(browserTabId, 'evicted') + + // Why: remove attachGuestPolicies listeners so their guest-WebContents closures don't block GC. + const guestWebContentsId = this.webContentsIdByTabId.get(browserTabId) + if (guestWebContentsId !== undefined) { + this.cleanupGuestPolicyAttachment(guestWebContentsId) + } + + const cleanup = this.contextMenuCleanupByTabId.get(browserTabId) + if (cleanup) { + cleanup() + this.contextMenuCleanupByTabId.delete(browserTabId) + } + const shortcutCleanup = this.grabShortcutCleanupByTabId.get(browserTabId) + if (shortcutCleanup) { + shortcutCleanup() + this.grabShortcutCleanupByTabId.delete(browserTabId) + } + const fwdCleanup = this.shortcutForwardingCleanupByTabId.get(browserTabId) + if (fwdCleanup) { + fwdCleanup() + this.shortcutForwardingCleanupByTabId.delete(browserTabId) + } + const mouseWheelZoomCleanup = this.mouseWheelZoomCleanupByTabId.get(browserTabId) + if (mouseWheelZoomCleanup) { + mouseWheelZoomCleanup() + this.mouseWheelZoomCleanupByTabId.delete(browserTabId) + } + // Why: downloads are per-tab chrome; closing the tab must cancel active writes, not orphan them. + for (const [downloadId, download] of this.downloadsById.entries()) { + if (download.browserTabId === browserTabId && !download.terminalEvent) { + this.cancelDownloadInternal(downloadId, 'Tab closed before download completed.') + } + } + const wcId = this.webContentsIdByTabId.get(browserTabId) + if (wcId !== undefined) { + this.tabIdByWebContentsId.delete(wcId) + } + this.webContentsIdByTabId.delete(browserTabId) + this.rendererWebContentsIdByTabId.delete(browserTabId) + this.workspaceIdByPageId.delete(browserTabId) + this.sessionProfileIdByPageId.delete(browserTabId) + this.userAgentModeByPageId.delete(browserTabId) + this.worktreeIdByTabId.delete(browserTabId) + // Why: drop the viewport-op chain so the Map doesn't retain a promise keyed to a destroyed guest. + this.viewportOpsByTabId.delete(browserTabId) + this.viewportUaOverrideMobileByTabId.delete(browserTabId) + this.viewportPresetActiveByTabId.delete(browserTabId) + this.viewportScrollStateByTabId.delete(browserTabId) + if (wcId !== undefined) { + this.pendingNavigationByGuestId.delete(wcId) + } + this.annotationViewportBridgeOpsByTabId.delete(browserTabId) + } + + // Why: headless orca serve has no window; back pages with offscreen WebContents and skip the webview-only setup. + registerOffscreenGuest({ + browserPageId, + worktreeId, + sessionProfileId, + userAgentMode, + webContentsId + }: { + browserPageId: string + worktreeId?: string + sessionProfileId?: string | null + userAgentMode?: BrowserSessionUserAgentMode + webContentsId: number + }): boolean { + // Why the same check on both registration doors: one id resolving in both halves is the exact + // confusion the split registries exist to prevent. + if (isWorkspaceDocPageId(browserPageId)) { + return false + } + const guest = webContents.fromId(webContentsId) + if (!guest || guest.isDestroyed()) { + return false + } + // Why: offscreen pages have no renderer webview listeners, so main owns their load-failure lifecycle. + this.offscreenGuestIds.add(webContentsId) + this.attachGuestPolicies(guest) + const previousWebContentsId = this.webContentsIdByTabId.get(browserPageId) + if (previousWebContentsId !== undefined && previousWebContentsId !== webContentsId) { + this.retireStaleGuestWebContents(previousWebContentsId) + this.viewportPresetActiveByTabId.delete(browserPageId) + this.viewportScrollStateByTabId.delete(browserPageId) + } + this.webContentsIdByTabId.set(browserPageId, webContentsId) + this.tabIdByWebContentsId.set(webContentsId, browserPageId) + this.sessionProfileIdByPageId.set(browserPageId, sessionProfileId ?? null) + if (userAgentMode) { + this.userAgentModeByPageId.set(browserPageId, userAgentMode) + } else { + this.userAgentModeByPageId.delete(browserPageId) + } + if (worktreeId) { + this.worktreeIdByTabId.set(browserPageId, worktreeId) + } + this.certificateTrustController?.onGuestRegistered(webContentsId, browserPageId) + return true + } + + unregisterAll(): void { + // Cancel all active grab ops before tearing down registrations + this.grabSessionController.cancelAll('evicted') + for (const downloadId of this.downloadsById.keys()) { + this.cancelDownloadInternal(downloadId, 'Orca is shutting down.') + } + browserDownloadDestinationReservations.clear() + for (const browserTabId of this.webContentsIdByTabId.keys()) { + this.unregisterGuest(browserTabId) + } + this.policyAttachedGuestIds.clear() + this.offscreenGuestIds.clear() + // Why: unregisterGuest skips guests that were policy-attached but never registered; invoke their cleanup closures here. + for (const cleanup of this.policyCleanupByGuestId.values()) { + cleanup() + } + this.policyCleanupByGuestId.clear() + this.clickedLinkFrameNameByGuestId.clear() + this.tabIdByWebContentsId.clear() + this.popupOwnerContextByGuestId.clear() + this.pageInitiatedTabBudgetByRootGuestId.clear() + this.worktreeIdByTabId.clear() + this.sessionProfileIdByPageId.clear() + this.userAgentModeByPageId.clear() + this.viewportUaOverrideMobileByTabId.clear() + this.viewportPresetActiveByTabId.clear() + this.viewportScrollStateByTabId.clear() + this.authUserAgentOverrideStateByGuestId.clear() + this.pendingNavigationByGuestId.clear() + this.pendingLoadFailuresByGuestId.clear() + this.loadErrorsByGuestId.clear() + this.clearedLoadErrorsByGuestId.clear() + this.pendingPermissionEventsByGuestId.clear() + this.pendingPopupEventsByGuestId.clear() + this.pendingDownloadIdsByGuestId.clear() + this.mouseWheelZoomCleanupByTabId.clear() + this.annotationViewportBridgeOpsByTabId.clear() + } +} diff --git a/src/main/browser/browser-manager-state.ts b/src/main/browser/browser-manager-state.ts new file mode 100644 index 00000000000..bc65cc3d2dc --- /dev/null +++ b/src/main/browser/browser-manager-state.ts @@ -0,0 +1,289 @@ +import { ANTI_DETECTION_SCRIPT } from './anti-detection' +import { BrowserGrabSessionController } from './browser-grab-session-controller' +import type { BrowserCertificateTrustController } from './browser-certificate-trust-controller' +import { + createPageInitiatedTabBudget, + type PageInitiatedTabBudget +} from './browser-page-initiated-tab-budget' +import type { KeybindingOverrides } from '../../shared/keybindings' +import type { + BrowserLoadError, + BrowserSessionUserAgentMode +} from '../../shared/browser-workspace-types' +import { resolveBrowserRouteGuestPopupOpener } from './browser-route-guest-popup-ownership' +import type { + ActiveDownload, + AuthUserAgentOverrideState, + PendingMainFrameNavigation, + PendingPermissionEvent, + PendingPopupEvent, + BrowserGuestPolicy, + BrowserManagerLoadError, + PopupOwnerContext +} from './browser-manager-types' +import type { + BrowserDownloadFinishedEvent, + BrowserDownloadProgressEvent +} from '../../shared/browser-guest-events' +import type { BrowserGrabCancelReason } from '../../shared/browser-grab-types' +import { BrowserManagerViewportScrollState } from './browser-manager-viewport-scroll-state' + +export abstract class BrowserManagerState extends BrowserManagerViewportScrollState { + protected abstract attachGuestPolicies( + guest: Electron.WebContents, + inheritedOwnerContext?: PopupOwnerContext | null, + policy?: BrowserGuestPolicy + ): void + + protected abstract forwardOrQueuePopupEvent( + guestWebContentsId: number, + event: PendingPopupEvent + ): void + + protected abstract cancelPendingDownloadsForGuest(guestWebContentsId: number): void + + protected abstract cleanupGuestPolicyAttachment(guestWebContentsId: number): void + protected abstract notifyBrowserGuestStateChanged(webContentsId: number): void + protected abstract buildLoadError( + code: number, + description: string, + rawUrl: string + ): BrowserLoadError + protected abstract forwardOrQueueGuestLoadFailure( + guestWebContentsId: number, + loadError: BrowserManagerLoadError + ): void + protected abstract forwardOrQueuePermissionDenied( + guestWebContentsId: number, + event: PendingPermissionEvent + ): void + protected abstract flushPendingLoadFailure(browserTabId: string, guestWebContentsId: number): void + protected abstract flushPendingPermissionEvents( + browserTabId: string, + guestWebContentsId: number + ): void + protected abstract flushPendingPopupEvents(browserTabId: string, guestWebContentsId: number): void + protected abstract flushPendingDownloadRequests( + browserTabId: string, + guestWebContentsId: number + ): void + protected abstract setupContextMenu(browserTabId: string, guest: Electron.WebContents): void + protected abstract setupGrabShortcut(browserTabId: string, guest: Electron.WebContents): void + protected abstract setupShortcutForwarding( + browserTabId: string, + guest: Electron.WebContents + ): void + protected abstract setupMouseWheelZoomForwarding( + browserTabId: string, + guest: Electron.WebContents + ): void + protected abstract cancelGrabOp(browserTabId: string, reason: BrowserGrabCancelReason): void + protected abstract hasActiveGrabOp(browserTabId: string): boolean + protected abstract unregisterGuest(browserTabId: string): void + protected abstract cancelDownloadInternal(downloadId: string, reason: string): void + protected abstract bindDownloadToTab(downloadId: string, browserTabId: string): void + protected abstract flushDownloadSnapshot(downloadId: string): void + protected abstract sendDownloadStarted(downloadId: string): void + protected abstract sendDownloadProgress( + browserTabId: string | null, + payload: BrowserDownloadProgressEvent + ): void + protected abstract sendDownloadFinished( + browserTabId: string | null, + payload: BrowserDownloadFinishedEvent + ): void + protected abstract settleClientHostedDownload( + download: ActiveDownload, + status: BrowserDownloadFinishedEvent['status'], + failure: string | null + ): Promise + protected abstract finishDownloadInternal( + downloadId: string, + status: BrowserDownloadFinishedEvent['status'], + error: string | null + ): void + protected abstract getDownloadReceivedBytes(item: Electron.DownloadItem): number + protected abstract openLinkInOrcaTab(browserTabId: string, rawUrl: string): boolean + + protected settingsResolver: + | (() => { + keybindings?: KeybindingOverrides + mobileEmulatorEnabled?: boolean + }) + | null = null + protected readonly webContentsIdByTabId = new Map() + // Why: reverse map gives O(1) guest→tab lookups on every mouse/load/permission/popup event. + protected readonly tabIdByWebContentsId = new Map() + protected readonly popupOwnerContextByGuestId = new Map() + // Why: keyed by the opener tree's root so named child popups can't each mint a fresh tab quota. + protected readonly pageInitiatedTabBudgetByRootGuestId = new Map() + // Why: guests are keyed by page id but renderer visibility by workspace id; bridge the mismatch to activate the right tab before capture. + protected readonly workspaceIdByPageId = new Map() + protected readonly sessionProfileIdByPageId = new Map() + protected readonly userAgentModeByPageId = new Map() + // Why: serialize per-tab setViewportOverride so rapid toggles don't interleave CDP commands and leave emulation in a wrong state. + protected readonly viewportOpsByTabId = new Map>() + // Why: presence means the preset requires a CDP UA override (installed or in flight), so navigation + // can re-issue it against the target URL's identity. + protected readonly viewportUaOverrideMobileByTabId = new Map() + // Why: the confirmed CDP identity outranks getUserAgent; pending intent keeps rapid navigations + // ordered without claiming a failed write was installed. + protected readonly authUserAgentOverrideStateByGuestId = new Map< + number, + AuthUserAgentOverrideState + >() + // Why: the in-flight main-frame navigation target, held only until commit or failure — getURL() + // still reports the outgoing page until then. See resolveTabNavigationUrl. + protected readonly pendingNavigationByGuestId = new Map() + protected readonly contextMenuCleanupByTabId = new Map void>() + protected readonly grabShortcutCleanupByTabId = new Map void>() + protected readonly shortcutForwardingCleanupByTabId = new Map void>() + protected readonly mouseWheelZoomCleanupByTabId = new Map void>() + protected readonly annotationViewportBridgeOpsByTabId = new Map>() + protected readonly worktreeIdByTabId = new Map() + protected readonly policyAttachedGuestIds = new Set() + protected readonly offscreenGuestIds = new Set() + protected readonly policyCleanupByGuestId = new Map void>() + protected readonly clickedLinkFrameNameByGuestId = new Map() + protected readonly loadErrorsByGuestId = new Map() + // Why: did-start-navigation hides the overlay optimistically; stash the cleared error so did-fail-load(-3) can restore an aborted nav. + protected readonly clearedLoadErrorsByGuestId = new Map() + protected browserGuestStateChangedListener: ((worktreeId: string) => void) | null = null + protected certificateTrustController: BrowserCertificateTrustController | null = null + protected shouldForwardDictationShortcut: (() => boolean) | null = null + protected readonly pendingLoadFailuresByGuestId = new Map< + number, + { code: number; description: string; validatedUrl: string } + >() + protected readonly pendingPermissionEventsByGuestId = new Map() + protected readonly pendingPopupEventsByGuestId = new Map() + protected readonly pendingDownloadIdsByGuestId = new Map() + protected readonly downloadsById = new Map() + protected 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 + mobileEmulatorEnabled?: boolean + } + ): void { + this.settingsResolver = resolver + } + + // Why: addScriptToEvaluateOnNewDocument (CDP) is the only reliable pre-page-script hook per nav; executeJavaScript ran on the old page context. + protected injectAntiDetection(guest: Electron.WebContents): () => void { + let disposed = false + let reattachTimer: ReturnType | null = null + + const attach = (): void => { + if (disposed || guest.isDestroyed()) { + return + } + try { + if (!guest.debugger.isAttached()) { + guest.debugger.attach('1.3') + } + void guest.debugger + .sendCommand('Page.enable', {}) + .then(() => + guest.debugger.sendCommand('Page.addScriptToEvaluateOnNewDocument', { + source: ANTI_DETECTION_SCRIPT + }) + ) + .catch(() => {}) + } catch { + /* best-effort — debugger may be unavailable */ + } + } + + // Why: proxy/bridge stop detaches the debugger and drops injections; re-attach (500ms delay to avoid racing a mid-restart) to keep overrides. + const onDetach = (): void => { + this.authUserAgentOverrideStateByGuestId.delete(guest.id) + if (!disposed && !guest.isDestroyed() && reattachTimer === null) { + reattachTimer = setTimeout(() => { + reattachTimer = null + attach() + }, 500) + } + } + + try { + attach() + guest.debugger.on('detach', onDetach) + } catch { + /* best-effort */ + } + + return () => { + disposed = true + if (reattachTimer !== null) { + clearTimeout(reattachTimer) + reattachTimer = null + } + try { + guest.debugger.off('detach', onDetach) + } catch { + /* guest may already be destroyed */ + } + } + } + + protected resolveBrowserTabIdForGuestWebContentsId(guestWebContentsId: number): string | null { + return this.resolvePopupOwnerContext(guestWebContentsId)?.browserTabId ?? null + } + + protected resolvePopupOwnerContext(guestWebContentsId: number): PopupOwnerContext | null { + const browserTabId = this.tabIdByWebContentsId.get(guestWebContentsId) + if (browserTabId) { + return { browserTabId, rootGuestWebContentsId: guestWebContentsId } + } + // Route popups live in an Orca-built window, so they never pass through did-create-window and + // have no inherited context; their owning page comes from the route popup registry instead. + const routeOpenerWebContentsId = resolveBrowserRouteGuestPopupOpener(guestWebContentsId) + if (routeOpenerWebContentsId !== null) { + const openerTabId = this.tabIdByWebContentsId.get(routeOpenerWebContentsId) + return openerTabId + ? { browserTabId: openerTabId, rootGuestWebContentsId: routeOpenerWebContentsId } + : null + } + const inherited = this.popupOwnerContextByGuestId.get(guestWebContentsId) + if ( + inherited && + this.webContentsIdByTabId.get(inherited.browserTabId) === inherited.rootGuestWebContentsId + ) { + return inherited + } + this.popupOwnerContextByGuestId.delete(guestWebContentsId) + return null + } + + /** Shared across the whole opener tree, so a chain of popups draws from one budget. */ + protected tryConsumePageInitiatedTab(rootGuestWebContentsId: number): boolean { + let budget = this.pageInitiatedTabBudgetByRootGuestId.get(rootGuestWebContentsId) + if (!budget) { + budget = createPageInitiatedTabBudget() + this.pageInitiatedTabBudgetByRootGuestId.set(rootGuestWebContentsId, budget) + } + return budget.tryConsume(Date.now()) + } +} diff --git a/src/main/browser/browser-manager-types.ts b/src/main/browser/browser-manager-types.ts new file mode 100644 index 00000000000..a1b832a65bc --- /dev/null +++ b/src/main/browser/browser-manager-types.ts @@ -0,0 +1,238 @@ +import { normalizeExternalBrowserUrl } from '../../shared/browser-url' +import type { + BrowserDownloadFinishedEvent, + BrowserDownloadProgressEvent, + BrowserPermissionDeniedEvent, + BrowserPopupEvent +} from '../../shared/browser-guest-events' +import type { + BrowserGrabCancelReason, + BrowserGrabPayload, + BrowserGrabRect, + BrowserGrabResult, + BrowserGrabScreenshot +} from '../../shared/browser-grab-types' +import type { BrowserClientDownloadRoute } from './browser-client-download-relay' +import type { PageInitiatedTabBudget } from './browser-page-initiated-tab-budget' +import type { + BrowserCertificateFailure, + BrowserLoadError, + BrowserSessionUserAgentMode, + BrowserViewportOverride +} from '../../shared/browser-workspace-types' +import type { BrowserAnnotationViewportBridgeOptions } from '../../shared/browser-annotation-viewport-bridge' +import type { KeybindingOverrides } from '../../shared/keybindings' + +export const AUTOMATION_VISIBILITY_ACQUIRE_TIMEOUT_MS = 2_000 + +export function isChromiumInternalErrorUrl(url: string): boolean { + return url.startsWith('chrome-error://') +} + +export function resolveWithTimeout( + promise: Promise, + timeoutMs: number, + fallbackValue: T +): Promise<{ value: T; timedOut: boolean }> { + let timeoutId: ReturnType | null = null + const timeoutPromise = new Promise<{ value: T; timedOut: boolean }>((resolve) => { + timeoutId = setTimeout(() => resolve({ value: fallbackValue, timedOut: true }), timeoutMs) + }) + return Promise.race([ + promise.then((value) => ({ value, timedOut: false })), + timeoutPromise + ]).finally(() => { + if (timeoutId) { + clearTimeout(timeoutId) + } + }) +} + +export function releaseAutomationVisibilityToken( + renderer: Electron.WebContents, + token: string +): void { + if (renderer.isDestroyed()) { + return + } + renderer + .executeJavaScript( + `(function() { + var bridge = window.__orcaBrowserAutomationVisibility; + if (!bridge || typeof bridge.release !== 'function') return false; + return bridge.release(${JSON.stringify(token)}); + })()` + ) + .catch(() => {}) +} + +export function cleanupLateAutomationVisibilityToken( + renderer: Electron.WebContents, + acquirePromise: Promise +): void { + acquirePromise + .then((lateToken) => { + if (typeof lateToken !== 'string' || lateToken.length === 0) { + return + } + // Why: the lease is created before paint; if main's acquire timed out, release the late token so hidden webviews don't stay paintable. + releaseAutomationVisibilityToken(renderer, lateToken) + }) + .catch(() => {}) +} + +export function createNoopRestoreForTimedOutAutomationAcquire( + renderer: Electron.WebContents, + acquirePromise: Promise, + timedOut: boolean +): () => void { + if (timedOut) { + cleanupLateAutomationVisibilityToken(renderer, acquirePromise) + } + return () => {} +} + +export function isAutomationVisibilityToken(token: unknown): token is string { + return typeof token === 'string' && token.length > 0 +} + +export type BrowserGuestRegistration = { + browserPageId?: string + browserTabId?: string + workspaceId?: string + worktreeId?: string + sessionProfileId?: string | null + userAgentMode?: BrowserSessionUserAgentMode + webContentsId: number + rendererWebContentsId: number +} + +export type PendingPermissionEvent = Omit +export type PendingPopupEvent = Omit +export type BrowserDownloadDoneState = 'completed' | 'cancelled' | 'interrupted' +export type PopupOwnerContext = { + browserTabId: string + rootGuestWebContentsId: number +} + +/** + * What a guest is allowed to be. A browsing guest is the web — popups, clicked-link routing and + * anti-detection all apply. A workspace-document guest renders one granted document and gets none + * of that; `host` is the renderer that minted its grant, and the only sink for what it reports. + */ +export type BrowserGuestPolicy = + | { profile: 'browsing' } + | { profile: 'workspace-doc'; host: Electron.WebContents } + +export const BROWSING_GUEST_POLICY: BrowserGuestPolicy = { profile: 'browsing' } + +export type PendingMainFrameNavigation = { + currentUrl: string + supersededUrls: string[] +} + +export type AuthUserAgentOverrideOperation = { + sequence: number + userAgent: string +} + +export type AuthUserAgentOverrideState = { + confirmed: AuthUserAgentOverrideOperation | null + nextSequence: number + pending: AuthUserAgentOverrideOperation[] +} + +export const SAFE_POPUP_WINDOW_OPTIONS = { + alwaysOnTop: false, + closable: true, + focusable: true, + frame: true, + fullscreen: false, + kiosk: false, + modal: false, + movable: true, + opacity: 1, + show: true, + simpleFullscreen: false, + skipTaskbar: false, + titleBarStyle: 'default', + transparent: false, + // Why: Electron applies these before createWindow; feature strings/opener inheritance must not relax the child's isolation. + webPreferences: { + allowRunningInsecureContent: false, + contextIsolation: true, + nodeIntegration: false, + nodeIntegrationInSubFrames: false, + sandbox: true, + webviewTag: false + } +} satisfies Electron.BrowserWindowConstructorOptions + +export type ActiveDownload = { + downloadId: string + guestWebContentsId: number + browserTabId: string | null + rendererWebContentsId: number | null + origin: string + filename: string + totalBytes: number | null + mimeType: string | null + item: Electron.DownloadItem + savePath: string + reservationKey: string | null + clientRoute: BrowserClientDownloadRoute | null + remoteDestination: BrowserDownloadFinishedEvent['remoteDestination'] + receivedBytes: number + transientState: BrowserDownloadProgressEvent['state'] + terminalEvent: BrowserDownloadFinishedEvent | null + startedSent: boolean + cleanup: (() => void) | null +} + +export function safeOrigin(rawUrl: string): string { + const external = normalizeExternalBrowserUrl(rawUrl) + const urlToParse = external ?? rawUrl + try { + return new URL(urlToParse).origin + } catch { + return external ?? 'unknown' + } +} + +export type BrowserManagerSettings = { + keybindings?: KeybindingOverrides + mobileEmulatorEnabled?: boolean +} + +export type BrowserManagerLoadError = Pick< + BrowserLoadError, + 'code' | 'description' | 'validatedUrl' +> + +export type BrowserManagerGrabTypes = { + cancelReason: BrowserGrabCancelReason + payload: BrowserGrabPayload + rect: BrowserGrabRect + result: BrowserGrabResult + screenshot: BrowserGrabScreenshot +} + +export type { + BrowserAnnotationViewportBridgeOptions, + BrowserCertificateFailure, + BrowserLoadError, + BrowserSessionUserAgentMode, + BrowserViewportOverride, + BrowserDownloadFinishedEvent, + BrowserDownloadProgressEvent, + BrowserPermissionDeniedEvent, + BrowserPopupEvent, + BrowserClientDownloadRoute, + BrowserGrabCancelReason, + BrowserGrabPayload, + BrowserGrabRect, + BrowserGrabResult, + BrowserGrabScreenshot, + KeybindingOverrides, + PageInitiatedTabBudget +} diff --git a/src/main/browser/browser-manager-viewport-scroll-state.ts b/src/main/browser/browser-manager-viewport-scroll-state.ts new file mode 100644 index 00000000000..1f83534c856 --- /dev/null +++ b/src/main/browser/browser-manager-viewport-scroll-state.ts @@ -0,0 +1,81 @@ +import { webContents } from 'electron' +import type { BrowserViewportScrollState } from '../../shared/browser-workspace-types' + +/** + * Renderer routing plus the host-side viewport-preset geometry the wheel path needs to decide + * whether a scroll belongs to the emulated viewport or to the guest page. + */ +export abstract class BrowserManagerViewportScrollState { + protected readonly rendererWebContentsIdByTabId = new Map() + // Why: host-side wheel panning follows the requested local viewport on the owning guest; + // replacement guests must not inherit a retired guest's state. + protected readonly viewportPresetActiveByTabId = new Map< + string, + { guestWebContentsId: number; active: boolean } + >() + protected readonly viewportScrollStateByTabId = new Map() + + setViewportScrollState( + browserTabId: string, + rendererWebContentsId: number, + state: BrowserViewportScrollState + ): void { + if (this.rendererWebContentsIdByTabId.get(browserTabId) !== rendererWebContentsId) { + return + } + if ( + ![state.scrollLeft, state.scrollTop, state.maxScrollLeft, state.maxScrollTop].every( + (value) => typeof value === 'number' && Number.isFinite(value) && value >= 0 + ) + ) { + return + } + this.viewportScrollStateByTabId.set(browserTabId, state) + } + + recordViewportScrollDelta(browserTabId: string, deltaX: number, deltaY: number): void { + const state = this.viewportScrollStateByTabId.get(browserTabId) + if (!state) { + return + } + this.viewportScrollStateByTabId.set(browserTabId, { + ...state, + scrollLeft: Math.min(state.maxScrollLeft, Math.max(0, state.scrollLeft + deltaX)), + scrollTop: Math.min(state.maxScrollTop, Math.max(0, state.scrollTop + deltaY)) + }) + } + + protected canViewportScroll(browserTabId: string, mouse: Electron.MouseWheelInputEvent): boolean { + const state = this.viewportScrollStateByTabId.get(browserTabId) + if (!state) { + return false + } + const deltaX = typeof mouse.deltaX === 'number' ? mouse.deltaX : 0 + const deltaY = typeof mouse.deltaY === 'number' ? mouse.deltaY : 0 + const canScrollAxis = (delta: number, position: number, maximum: number): boolean => { + if (delta < 0) { + return position > 0 + } + if (delta > 0) { + return position < maximum + } + return false + } + return ( + canScrollAxis(deltaX, state.scrollLeft, state.maxScrollLeft) || + canScrollAxis(deltaY, state.scrollTop, state.maxScrollTop) + ) + } + + protected resolveRendererForBrowserTab(browserTabId: string): Electron.WebContents | null { + const rendererWebContentsId = this.rendererWebContentsIdByTabId.get(browserTabId) + if (!rendererWebContentsId) { + return null + } + const renderer = webContents.fromId(rendererWebContentsId) + if (!renderer || renderer.isDestroyed()) { + return null + } + return renderer + } +} diff --git a/src/main/browser/browser-manager-viewport.ts b/src/main/browser/browser-manager-viewport.ts new file mode 100644 index 00000000000..1e79e760942 --- /dev/null +++ b/src/main/browser/browser-manager-viewport.ts @@ -0,0 +1,219 @@ +import { webContents } from 'electron' +import { + BROWSER_ANNOTATION_VIEWPORT_BRIDGE_WORLD_ID, + buildBrowserAnnotationViewportBridgeScript, + type BrowserAnnotationViewportBridgeOptions +} from '../../shared/browser-annotation-viewport-bridge' +import type { BrowserViewportOverride } from '../../shared/browser-workspace-types' +import { googleAuthUserAgent, isGoogleAuthUrl } from './browser-google-auth-ua' +import { BrowserManagerDownloadLifecycle } from './browser-manager-download-lifecycle' + +export abstract class BrowserManagerViewport extends BrowserManagerDownloadLifecycle { + // Why: guests are isolated from Orca's preload bridge, so main owns the devtools escape hatch after a tab→guest lookup. + async openDevTools(browserTabId: string): Promise { + const webContentsId = this.webContentsIdByTabId.get(browserTabId) + if (!webContentsId) { + return false + } + const guest = webContents.fromId(webContentsId) + if (!guest || guest.isDestroyed()) { + // Why: a stale guest must clear every per-tab registry entry, not just the WebContents maps. + this.unregisterGuest(browserTabId) + return false + } + // Offscreen guests have no visible window on this desktop; detaching DevTools would open it + // on the host display with no route back to the remote client. + if (this.offscreenGuestIds.has(webContentsId)) { + return false + } + guest.openDevTools({ mode: 'detach' }) + return true + } + + // Why: emulate viewport via CDP; never detach the debugger here or per-guest overrides (addScriptToEvaluateOnNewDocument) are cleared. + async setViewportOverride( + browserTabId: string, + override: BrowserViewportOverride | null + ): Promise { + // Why: chain per-tab so rapid toggles don't interleave CDP commands and the last-requested override wins. + const expectedWebContentsId = this.webContentsIdByTabId.get(browserTabId) + if (expectedWebContentsId !== undefined) { + // Keep host panning available while CDP applies the requested dimensions. The guest id fence + // prevents this intent from leaking to a replacement guest; clearing the preset removes it. + this.viewportPresetActiveByTabId.set(browserTabId, { + guestWebContentsId: expectedWebContentsId, + active: override !== null + }) + } + // The renderer resizes the host before CDP completes; discard the old geometry until it + // reports the new pane bounds so a pending preset cannot route wheel input using stale limits. + this.viewportScrollStateByTabId.delete(browserTabId) + const prev = this.viewportOpsByTabId.get(browserTabId) ?? Promise.resolve() + const next = prev + .catch(() => {}) + .then(() => this.doSetViewportOverrideImpl(browserTabId, override, expectedWebContentsId)) + this.viewportOpsByTabId.set(browserTabId, next) + try { + return await next + } finally { + // Why: only clear if we're still the tail; a later call may have replaced the entry, and deleting would break serialization. + if (this.viewportOpsByTabId.get(browserTabId) === next) { + this.viewportOpsByTabId.delete(browserTabId) + } + } + } + + async setAnnotationViewportBridge( + browserTabId: string, + options: BrowserAnnotationViewportBridgeOptions, + resolveGuest: () => Electron.WebContents | null + ): Promise { + const prev = this.annotationViewportBridgeOpsByTabId.get(browserTabId) ?? Promise.resolve() + const next = prev + .catch(() => {}) + .then(() => this.doSetAnnotationViewportBridgeImpl(options, resolveGuest)) + this.annotationViewportBridgeOpsByTabId.set(browserTabId, next) + try { + return await next + } finally { + if (this.annotationViewportBridgeOpsByTabId.get(browserTabId) === next) { + this.annotationViewportBridgeOpsByTabId.delete(browserTabId) + } + } + } + + // Why the caller resolves the guest: the same bridge serves browsing pages and workspace + // documents, which live in different halves of the page registry. + // Why a resolver and not the guest itself: this op may have waited behind another one, and a + // cross-process navigation meanwhile swaps the tab's contents without destroying the old one — + // injecting into the guest the request named would bridge a page nobody is looking at. + // Why no tab id: with teardown gone this reaches only the guest the resolver hands back, and + // taking an id it cannot act on would invite the next reader to act on it. + protected async doSetAnnotationViewportBridgeImpl( + options: BrowserAnnotationViewportBridgeOptions, + resolveGuest: () => Electron.WebContents | null + ): Promise { + // Why no teardown here: the resolver already unregisters a page whose guest died, and the only + // case it uniquely leaves is an ownership mismatch on a healthy page — where tearing down would + // cancel that page's in-flight downloads and grabs over a request that was merely misaddressed. + const guest = resolveGuest() + if (!guest || guest.isDestroyed()) { + return false + } + + try { + // Why: run the scroll bridge in an isolated world so page scripts can't read the per-tab token or tamper with it. + await guest.executeJavaScriptInIsolatedWorld( + BROWSER_ANNOTATION_VIEWPORT_BRIDGE_WORLD_ID, + [{ code: buildBrowserAnnotationViewportBridgeScript(options) }], + false + ) + return true + } catch { + return false + } + } + + protected async doSetViewportOverrideImpl( + browserTabId: string, + override: BrowserViewportOverride | null, + expectedWebContentsId: number | undefined + ): Promise { + const webContentsId = this.webContentsIdByTabId.get(browserTabId) + if (!webContentsId || webContentsId !== expectedWebContentsId) { + return false + } + const guest = webContents.fromId(webContentsId) + if (!guest || guest.isDestroyed()) { + // Why: a stale guest must clear every per-tab registry entry, not just the WebContents maps. + this.unregisterGuest(browserTabId) + return false + } + + try { + if (!guest.debugger.isAttached()) { + guest.debugger.attach('1.3') + } + } catch (err) { + // Why: attach throws if DevTools is open on the guest; log context so this failure mode is diagnosable. + console.warn('[browser-manager] setViewportOverride: failed to attach debugger', { + browserTabId, + webContentsId, + error: err instanceof Error ? err.message : String(err) + }) + return false + } + + const dbg = guest.debugger + try { + if (override) { + await dbg.sendCommand('Emulation.setDeviceMetricsOverride', { + width: override.width, + height: override.height, + deviceScaleFactor: override.deviceScaleFactor, + mobile: override.mobile + }) + if (this.webContentsIdByTabId.get(browserTabId) === webContentsId) { + this.viewportPresetActiveByTabId.set(browserTabId, { + guestWebContentsId: webContentsId, + active: true + }) + } + await dbg.sendCommand('Emulation.setTouchEmulationEnabled', { + enabled: override.mobile, + maxTouchPoints: override.mobile ? 5 : 0 + }) + // Why: viewport sizing must not override a profile's explicit native-UA identity. + if (this.userAgentModeByPageId.get(browserTabId) !== 'native') { + // Navigation must see the preset intent while the final CDP command is in flight. + this.viewportUaOverrideMobileByTabId.set(browserTabId, override.mobile) + // Why: same sender as the navigation path, so both resolve the tab's host identically. + await this.sendViewportUserAgentOverride(guest, override.mobile) + } + } else { + await dbg.sendCommand('Emulation.clearDeviceMetricsOverride', {}) + if (this.webContentsIdByTabId.get(browserTabId) === webContentsId) { + this.viewportPresetActiveByTabId.set(browserTabId, { + guestWebContentsId: webContentsId, + active: false + }) + } + await dbg.sendCommand('Emulation.setTouchEmulationEnabled', { + enabled: false, + maxTouchPoints: 0 + }) + const trackedMobile = this.viewportUaOverrideMobileByTabId.get(browserTabId) + // A navigation after this point must not re-install the override behind the clear. + this.viewportUaOverrideMobileByTabId.delete(browserTabId) + try { + if (this.authUserAgentOverrideStateByGuestId.has(guest.id)) { + const url = this.resolveTabNavigationUrl(guest) + const restored = await this.applyAuthUserAgentOverrideOverCdp( + guest, + false, + url, + isGoogleAuthUrl(url) ? googleAuthUserAgent() : guest.session.getUserAgent() + ) + if (!restored) { + throw new Error('Failed to preserve auth user agent') + } + } else { + // Why: passing an empty string restores the session default UA. + await dbg.sendCommand('Emulation.setUserAgentOverride', { userAgent: '' }) + } + } catch (error) { + if (trackedMobile !== undefined) { + this.viewportUaOverrideMobileByTabId.set(browserTabId, trackedMobile) + } + throw error + } + } + if (this.webContentsIdByTabId.get(browserTabId) !== webContentsId) { + return false + } + return true + } catch { + return false + } + } +} diff --git a/src/main/browser/browser-manager-visibility.ts b/src/main/browser/browser-manager-visibility.ts new file mode 100644 index 00000000000..1da2a75638d --- /dev/null +++ b/src/main/browser/browser-manager-visibility.ts @@ -0,0 +1,256 @@ +import { + AUTOMATION_VISIBILITY_ACQUIRE_TIMEOUT_MS, + createNoopRestoreForTimedOutAutomationAcquire, + isAutomationVisibilityToken, + releaseAutomationVisibilityToken, + resolveWithTimeout +} from './browser-manager-types' +import { BrowserManagerState } from './browser-manager-state' + +export abstract class BrowserManagerVisibility extends BrowserManagerState { + // Why: screenshots target page ids but visible chrome is keyed by workspace id; activate by workspace or the webview stays hidden and capture times out. + async ensureWebviewVisible(guestWebContentsId: number): Promise<() => void> { + const browserPageId = this.resolveBrowserTabIdForGuestWebContentsId(guestWebContentsId) + if (!browserPageId) { + return () => {} + } + const browserWorkspaceId = this.workspaceIdByPageId.get(browserPageId) ?? browserPageId + const worktreeId = this.worktreeIdByTabId.get(browserPageId) ?? null + const renderer = this.resolveRendererForBrowserTab(browserPageId) + if (!renderer || renderer.isDestroyed()) { + return () => {} + } + + const prev = await renderer + .executeJavaScript( + `(function() { + var store = window.__store; + if (!store) return null; + var state = store.getState(); + var prevTabType = state.activeTabType; + var prevActiveWorktreeId = state.activeWorktreeId || null; + var prevActiveBrowserWorkspaceId = state.activeBrowserTabId || null; + var prevActiveBrowserPageId = null; + var prevFocusedGroupTabId = null; + var targetWorktreeId = ${JSON.stringify(worktreeId)}; + var browserWorkspaceId = ${JSON.stringify(browserWorkspaceId)}; + var browserPageId = ${JSON.stringify(browserPageId)}; + var browserTabsByWorktree = state.browserTabsByWorktree || {}; + + if (prevActiveWorktreeId) { + var prevFocusedGroupId = (state.activeGroupIdByWorktree || {})[prevActiveWorktreeId]; + var prevGroups = (state.groupsByWorktree || {})[prevActiveWorktreeId] || []; + for (var pg = 0; pg < prevGroups.length; pg++) { + if (prevGroups[pg].id === prevFocusedGroupId) { + prevFocusedGroupTabId = prevGroups[pg].activeTabId; + break; + } + } + } + + if (prevActiveBrowserWorkspaceId) { + for (var prevWtId in browserTabsByWorktree) { + var prevBrowserTabs = browserTabsByWorktree[prevWtId] || []; + for (var pbt = 0; pbt < prevBrowserTabs.length; pbt++) { + if (prevBrowserTabs[pbt].id === prevActiveBrowserWorkspaceId) { + prevActiveBrowserPageId = prevBrowserTabs[pbt].activePageId || null; + break; + } + } + if (prevActiveBrowserPageId) break; + } + } + + if ( + targetWorktreeId && + prevActiveWorktreeId !== targetWorktreeId && + typeof state.setActiveWorktree === 'function' + ) { + state.setActiveWorktree(targetWorktreeId); + state = store.getState(); + } + + var foundWorkspace = null; + for (var wtId in browserTabsByWorktree) { + var tabs = browserTabsByWorktree[wtId] || []; + for (var i = 0; i < tabs.length; i++) { + if (tabs[i].id === browserWorkspaceId) { + foundWorkspace = tabs[i]; + if (!targetWorktreeId) { + targetWorktreeId = wtId; + } + break; + } + } + if (foundWorkspace) break; + } + + var hasTargetPage = false; + var targetPages = (state.browserPagesByWorkspace || {})[browserWorkspaceId] || []; + for (var pageIndex = 0; pageIndex < targetPages.length; pageIndex++) { + if (targetPages[pageIndex].id === browserPageId) { + hasTargetPage = true; + break; + } + } + + if (foundWorkspace) { + if (typeof state.setActiveBrowserTab === 'function') { + state.setActiveBrowserTab(browserWorkspaceId); + state = store.getState(); + } else { + var allTabs = state.unifiedTabsByWorktree || {}; + var found = null; + for (var unifiedWtId in allTabs) { + var unifiedTabs = allTabs[unifiedWtId] || []; + for (var unifiedIndex = 0; unifiedIndex < unifiedTabs.length; unifiedIndex++) { + if ( + unifiedTabs[unifiedIndex].contentType === 'browser' && + unifiedTabs[unifiedIndex].entityId === browserWorkspaceId + ) { + found = unifiedTabs[unifiedIndex]; + break; + } + } + if (found) break; + } + if (found) { + state.activateTab(found.id); + } + state.setActiveTabType('browser'); + state = store.getState(); + } + // Why: activating the workspace alone is not enough for screenshot + // capture when a browser workspace contains multiple pages. The + // compositor only paints the currently mounted page guest. + if ( + hasTargetPage && + foundWorkspace.activePageId !== browserPageId && + typeof state.setActiveBrowserPage === 'function' + ) { + state.setActiveBrowserPage(browserWorkspaceId, browserPageId); + state = store.getState(); + } + } + + return { + prevTabType: prevTabType, + prevActiveWorktreeId: prevActiveWorktreeId, + prevActiveBrowserWorkspaceId: prevActiveBrowserWorkspaceId, + prevActiveBrowserPageId: prevActiveBrowserPageId, + prevFocusedGroupTabId: prevFocusedGroupTabId, + targetWorktreeId: targetWorktreeId, + targetBrowserWorkspaceId: foundWorkspace ? browserWorkspaceId : null, + targetBrowserPageId: foundWorkspace && hasTargetPage ? browserPageId : null + }; + })()` + ) + .catch(() => null) + + const needsRestore = + prev && + (prev.prevTabType !== 'browser' || + prev.prevActiveWorktreeId !== prev.targetWorktreeId || + prev.prevFocusedGroupTabId !== null || + prev.prevActiveBrowserWorkspaceId !== prev.targetBrowserWorkspaceId || + prev.prevActiveBrowserPageId !== prev.targetBrowserPageId) + + if (!needsRestore) { + return () => {} + } + + return () => { + if (!prev || !renderer || renderer.isDestroyed()) { + return + } + renderer + .executeJavaScript( + `(function() { + var store = window.__store; + if (!store) return; + var state = store.getState(); + if ( + ${JSON.stringify(prev?.prevActiveWorktreeId)} && + ${JSON.stringify(prev?.prevActiveWorktreeId)} !== + ${JSON.stringify(prev?.targetWorktreeId)} && + typeof state.setActiveWorktree === 'function' + ) { + state.setActiveWorktree(${JSON.stringify(prev?.prevActiveWorktreeId)}); + state = store.getState(); + } + if ( + ${JSON.stringify(prev?.prevActiveBrowserWorkspaceId)} && + ${JSON.stringify(prev?.prevActiveBrowserWorkspaceId)} !== + ${JSON.stringify(prev?.targetBrowserWorkspaceId)} && + typeof state.setActiveBrowserTab === 'function' + ) { + state.setActiveBrowserTab(${JSON.stringify(prev?.prevActiveBrowserWorkspaceId)}); + state = store.getState(); + } + if ( + ${JSON.stringify(prev?.prevActiveBrowserWorkspaceId)} && + ${JSON.stringify(prev?.prevActiveBrowserPageId)} && + ${JSON.stringify(prev?.prevActiveBrowserPageId)} !== + ${JSON.stringify(prev?.targetBrowserPageId)} && + typeof state.setActiveBrowserPage === 'function' + ) { + // Why: Orca remembers the last browser workspace/page even when + // the user is currently in terminal/editor view. Screenshot prep + // temporarily switches that hidden browser selection state, so + // restore it independently of the visible tab type. + state.setActiveBrowserPage( + ${JSON.stringify(prev?.prevActiveBrowserWorkspaceId)}, + ${JSON.stringify(prev?.prevActiveBrowserPageId)} + ); + state = store.getState(); + } + if ( + ${JSON.stringify(prev?.prevTabType)} !== 'browser' && + ${JSON.stringify(prev?.prevFocusedGroupTabId)} + ) { + state.activateTab(${JSON.stringify(prev?.prevFocusedGroupTabId)}); + } + if (${JSON.stringify(prev?.prevTabType)} !== 'browser') { + state.setActiveTabType(${JSON.stringify(prev?.prevTabType)}); + } + })()` + ) + .catch(() => {}) + } + } + + async acquireAutomationVisibility(guestWebContentsId: number): Promise<() => void> { + const browserPageId = this.resolveBrowserTabIdForGuestWebContentsId(guestWebContentsId) + if (!browserPageId) { + return () => {} + } + const renderer = this.resolveRendererForBrowserTab(browserPageId) + if (!renderer || renderer.isDestroyed()) { + return () => {} + } + + // Why: agent commands need a paintable webview for lazy-loading sites without stealing the user's visible tab. + const acquirePromise = renderer + .executeJavaScript( + `(async function() { + var bridge = window.__orcaBrowserAutomationVisibility; + if (!bridge || typeof bridge.acquire !== 'function') return null; + return await bridge.acquire(${JSON.stringify(browserPageId)}); + })()` + ) + .catch(() => null) + const { value: token, timedOut } = await resolveWithTimeout( + acquirePromise, + AUTOMATION_VISIBILITY_ACQUIRE_TIMEOUT_MS, + null + ) + + if (!isAutomationVisibilityToken(token)) { + return createNoopRestoreForTimedOutAutomationAcquire(renderer, acquirePromise, timedOut) + } + + return () => { + releaseAutomationVisibilityToken(renderer, token) + } + } +} diff --git a/src/main/browser/browser-manager.ts b/src/main/browser/browser-manager.ts index 5764b45e5e8..bdac69dd906 100644 --- a/src/main/browser/browser-manager.ts +++ b/src/main/browser/browser-manager.ts @@ -1,2705 +1,14 @@ -/* eslint-disable max-lines -- Why: single privileged facade for guest registration, authorization, and lifecycle cleanup; keeps the browser security boundary in one file. */ -import { randomUUID } from 'node:crypto' +import { webContents } from 'electron' +import { BrowserCertificateTrustController } from './browser-certificate-trust-controller' +import { BrowserManagerFinal } from './browser-manager-final' -import { shell, webContents } from 'electron' -import { ORCA_BROWSER_BLANK_URL } from '../../shared/constants' -import { - normalizeBrowserNavigationUrl, - normalizeExternalBrowserUrl, - redactKagiSessionToken, - toSecureCertificateEndpoint -} from '../../shared/browser-url' -import type { - BrowserDownloadFinishedEvent, - BrowserDownloadProgressEvent, - BrowserDownloadRequestedEvent, - BrowserPermissionDeniedEvent, - BrowserPopupEvent -} from '../../shared/browser-guest-events' -import type { - BrowserGrabCancelReason, - BrowserGrabPayload, - BrowserGrabRect, - BrowserGrabResult, - BrowserGrabScreenshot -} from '../../shared/browser-grab-types' -import { buildGuestOverlayScript } from './grab-guest-script' -import { clampGrabPayload } from './browser-grab-payload' -import { captureSelectionScreenshot as captureGrabSelectionScreenshot } from './browser-grab-screenshot' -import { BrowserGrabSessionController } from './browser-grab-session-controller' -import { browserDownloadDestinationReservations } from './browser-download-destination' -import type { BrowserClientDownloadRoute } from './browser-client-download-relay' -import { routeBrowserClientDownload } from './browser-client-download-routing' -import { resolveBrowserRouteGuestPopupOpener } from './browser-route-guest-popup-ownership' -import { resolveRendererWebContents } from './browser-guest-renderer-target' -import { setupGrabShortcutForwarding } from './browser-guest-grab-shortcuts' -import { setupGuestContextMenu } from './browser-guest-context-menu' -import { setupGuestMouseWheelZoomForwarding } from './browser-guest-wheel-zoom' -import { setupGuestShortcutForwarding } from './browser-guest-shortcut-forwarding' -import { ANTI_DETECTION_SCRIPT } from './anti-detection' -import { openPopupWithOriginBar, type PopupChildWindowOptions } from './popup-origin-bar-window' -import { - BROWSER_CLICKED_LINK_ROUTING_WORLD_ID, - buildBrowserClickedLinkRoutingScript, - buildBrowserIframeClickedLinkRoutingScript -} from './browser-clicked-link-routing' -import { - createPageInitiatedTabBudget, - type PageInitiatedTabBudget -} from './browser-page-initiated-tab-budget' -import { isNewBrowserTabPopupIntent } from './browser-popup-new-tab-intent' -import { cleanElectronUserAgent } from './browser-session-ua' -import { getBrowserSessionUserAgentMode } from './browser-session-user-agent-mode' -import { googleAuthUserAgent, isGoogleAuthUrl } from './browser-google-auth-ua' -import { buildViewportUserAgentOverride } from './browser-viewport-user-agent' -import type { - BrowserCertificateFailure, - BrowserLoadError, - BrowserSessionUserAgentMode, - BrowserViewportOverride, - BrowserViewportScrollState -} from '../../shared/browser-workspace-types' -import { - type BrowserAnnotationViewportBridgeOptions, - BROWSER_ANNOTATION_VIEWPORT_BRIDGE_WORLD_ID, - buildBrowserAnnotationViewportBridgeScript -} from '../../shared/browser-annotation-viewport-bridge' -import { - getWorkspaceDocPageGuest, - installDocPreviewGuestPolicy, - isWorkspaceDocPageId -} from './doc-preview-guest-policy' -import type { KeybindingOverrides } from '../../shared/keybindings' -import { - BrowserCertificateTrustController, - type ManagedBrowserGuestContext -} from './browser-certificate-trust-controller' +export type { BrowserGuestPolicy, BrowserGuestRegistration } from './browser-manager-types' -const AUTOMATION_VISIBILITY_ACQUIRE_TIMEOUT_MS = 2_000 - -function isChromiumInternalErrorUrl(url: string): boolean { - return url.startsWith('chrome-error://') -} - -function resolveWithTimeout( - promise: Promise, - timeoutMs: number, - fallbackValue: T -): Promise<{ value: T; timedOut: boolean }> { - let timeoutId: ReturnType | null = null - const timeoutPromise = new Promise<{ value: T; timedOut: boolean }>((resolve) => { - timeoutId = setTimeout(() => resolve({ value: fallbackValue, timedOut: true }), timeoutMs) - }) - return Promise.race([ - promise.then((value) => ({ value, timedOut: false })), - timeoutPromise - ]).finally(() => { - if (timeoutId) { - clearTimeout(timeoutId) - } - }) -} - -function releaseAutomationVisibilityToken(renderer: Electron.WebContents, token: string): void { - if (renderer.isDestroyed()) { - return - } - renderer - .executeJavaScript( - `(function() { - var bridge = window.__orcaBrowserAutomationVisibility; - if (!bridge || typeof bridge.release !== 'function') return false; - return bridge.release(${JSON.stringify(token)}); - })()` - ) - .catch(() => {}) -} - -function cleanupLateAutomationVisibilityToken( - renderer: Electron.WebContents, - acquirePromise: Promise -): void { - acquirePromise - .then((lateToken) => { - if (typeof lateToken !== 'string' || lateToken.length === 0) { - return - } - // Why: the lease is created before paint; if main's acquire timed out, release the late token so hidden webviews don't stay paintable. - releaseAutomationVisibilityToken(renderer, lateToken) - }) - .catch(() => {}) -} - -function createNoopRestoreForTimedOutAutomationAcquire( - renderer: Electron.WebContents, - acquirePromise: Promise, - timedOut: boolean -): () => void { - if (timedOut) { - cleanupLateAutomationVisibilityToken(renderer, acquirePromise) - } - return () => {} -} - -function isAutomationVisibilityToken(token: unknown): token is string { - return typeof token === 'string' && token.length > 0 -} - -export type BrowserGuestRegistration = { - browserPageId?: string - browserTabId?: string - workspaceId?: string - worktreeId?: string - sessionProfileId?: string | null - userAgentMode?: BrowserSessionUserAgentMode - webContentsId: number - rendererWebContentsId: number -} - -type PendingPermissionEvent = Omit -type PendingPopupEvent = Omit -type BrowserDownloadDoneState = 'completed' | 'cancelled' | 'interrupted' -type PopupOwnerContext = { - browserTabId: string - rootGuestWebContentsId: number -} /** - * What a guest is allowed to be. A browsing guest is the web — popups, clicked-link routing and - * anti-detection all apply. A workspace-document guest renders one granted document and gets none - * of that; `host` is the renderer that minted its grant, and the only sink for what it reports. + * Privileged browser guest facade. Behavior is organized by lifecycle concern in the focused + * manager modules while this module keeps the stable import surface used by main and renderer code. */ -export type BrowserGuestPolicy = - | { profile: 'browsing' } - | { profile: 'workspace-doc'; host: Electron.WebContents } -const BROWSING_GUEST_POLICY: BrowserGuestPolicy = { profile: 'browsing' } -type PendingMainFrameNavigation = { - currentUrl: string - supersededUrls: string[] -} -type AuthUserAgentOverrideOperation = { - sequence: number - userAgent: string -} -type AuthUserAgentOverrideState = { - confirmed: AuthUserAgentOverrideOperation | null - nextSequence: number - pending: AuthUserAgentOverrideOperation[] -} -const SAFE_POPUP_WINDOW_OPTIONS = { - alwaysOnTop: false, - closable: true, - focusable: true, - frame: true, - fullscreen: false, - kiosk: false, - modal: false, - movable: true, - opacity: 1, - show: true, - simpleFullscreen: false, - skipTaskbar: false, - titleBarStyle: 'default', - transparent: false, - // Why: Electron applies these before createWindow; feature strings/opener inheritance must not relax the child's isolation. - webPreferences: { - allowRunningInsecureContent: false, - contextIsolation: true, - nodeIntegration: false, - nodeIntegrationInSubFrames: false, - sandbox: true, - webviewTag: false - } -} satisfies Electron.BrowserWindowConstructorOptions - -type ActiveDownload = { - downloadId: string - guestWebContentsId: number - browserTabId: string | null - rendererWebContentsId: number | null - origin: string - filename: string - totalBytes: number | null - mimeType: string | null - item: Electron.DownloadItem - savePath: string - reservationKey: string | null - clientRoute: BrowserClientDownloadRoute | null - remoteDestination: BrowserDownloadFinishedEvent['remoteDestination'] - receivedBytes: number - transientState: BrowserDownloadProgressEvent['state'] - terminalEvent: BrowserDownloadFinishedEvent | null - startedSent: boolean - cleanup: (() => void) | null -} - -function safeOrigin(rawUrl: string): string { - const external = normalizeExternalBrowserUrl(rawUrl) - const urlToParse = external ?? rawUrl - try { - return new URL(urlToParse).origin - } catch { - return external ?? 'unknown' - } -} - -export class BrowserManager { - private settingsResolver: - | (() => { - keybindings?: KeybindingOverrides - mobileEmulatorEnabled?: boolean - }) - | null = null - private readonly webContentsIdByTabId = new Map() - // Why: reverse map gives O(1) guest→tab lookups on every mouse/load/permission/popup event. - private readonly tabIdByWebContentsId = new Map() - private readonly popupOwnerContextByGuestId = new Map() - // Why: keyed by the opener tree's root so named child popups can't each mint a fresh tab quota. - private readonly pageInitiatedTabBudgetByRootGuestId = new Map() - // Why: guests are keyed by page id but renderer visibility by workspace id; bridge the mismatch to activate the right tab before capture. - private readonly workspaceIdByPageId = new Map() - private readonly sessionProfileIdByPageId = new Map() - private readonly userAgentModeByPageId = new Map() - private readonly rendererWebContentsIdByTabId = new Map() - // Why: serialize per-tab setViewportOverride so rapid toggles don't interleave CDP commands and leave emulation in a wrong state. - private readonly viewportOpsByTabId = new Map>() - // Why: presence means the preset requires a CDP UA override (installed or in flight), so navigation - // can re-issue it against the target URL's identity. - private readonly viewportUaOverrideMobileByTabId = new Map() - // Why: host-side wheel panning follows the requested local viewport on the owning guest; - // replacement guests must not inherit a retired guest's state. - private readonly viewportPresetActiveByTabId = new Map< - string, - { guestWebContentsId: number; active: boolean } - >() - private readonly viewportScrollStateByTabId = new Map() - // Why: the confirmed CDP identity outranks getUserAgent; pending intent keeps rapid navigations - // ordered without claiming a failed write was installed. - private readonly authUserAgentOverrideStateByGuestId = new Map< - number, - AuthUserAgentOverrideState - >() - // Why: the in-flight main-frame navigation target, held only until commit or failure — getURL() - // still reports the outgoing page until then. See resolveTabNavigationUrl. - private readonly pendingNavigationByGuestId = new Map() - private readonly contextMenuCleanupByTabId = new Map void>() - private readonly grabShortcutCleanupByTabId = new Map void>() - private readonly shortcutForwardingCleanupByTabId = new Map void>() - private readonly mouseWheelZoomCleanupByTabId = new Map void>() - 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 hides the overlay optimistically; stash the cleared error so did-fail-load(-3) can restore an aborted nav. - 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 } - >() - 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 - } - - setViewportScrollState( - browserTabId: string, - rendererWebContentsId: number, - state: BrowserViewportScrollState - ): void { - if (this.rendererWebContentsIdByTabId.get(browserTabId) !== rendererWebContentsId) { - return - } - if ( - ![state.scrollLeft, state.scrollTop, state.maxScrollLeft, state.maxScrollTop].every( - (value) => typeof value === 'number' && Number.isFinite(value) && value >= 0 - ) - ) { - return - } - this.viewportScrollStateByTabId.set(browserTabId, state) - } - - recordViewportScrollDelta(browserTabId: string, deltaX: number, deltaY: number): void { - const state = this.viewportScrollStateByTabId.get(browserTabId) - if (!state) { - return - } - this.viewportScrollStateByTabId.set(browserTabId, { - ...state, - scrollLeft: Math.min(state.maxScrollLeft, Math.max(0, state.scrollLeft + deltaX)), - scrollTop: Math.min(state.maxScrollTop, Math.max(0, state.scrollTop + deltaY)) - }) - } - - 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 - mobileEmulatorEnabled?: boolean - } - ): void { - this.settingsResolver = resolver - } - - // Why: addScriptToEvaluateOnNewDocument (CDP) is the only reliable pre-page-script hook per nav; executeJavaScript ran on the old page context. - private injectAntiDetection(guest: Electron.WebContents): () => void { - let disposed = false - let reattachTimer: ReturnType | null = null - - const attach = (): void => { - if (disposed || guest.isDestroyed()) { - return - } - try { - if (!guest.debugger.isAttached()) { - guest.debugger.attach('1.3') - } - void guest.debugger - .sendCommand('Page.enable', {}) - .then(() => - guest.debugger.sendCommand('Page.addScriptToEvaluateOnNewDocument', { - source: ANTI_DETECTION_SCRIPT - }) - ) - .catch(() => {}) - } catch { - /* best-effort — debugger may be unavailable */ - } - } - - // Why: proxy/bridge stop detaches the debugger and drops injections; re-attach (500ms delay to avoid racing a mid-restart) to keep overrides. - const onDetach = (): void => { - this.authUserAgentOverrideStateByGuestId.delete(guest.id) - if (!disposed && !guest.isDestroyed() && reattachTimer === null) { - reattachTimer = setTimeout(() => { - reattachTimer = null - attach() - }, 500) - } - } - - try { - attach() - guest.debugger.on('detach', onDetach) - } catch { - /* best-effort */ - } - - return () => { - disposed = true - if (reattachTimer !== null) { - clearTimeout(reattachTimer) - reattachTimer = null - } - try { - guest.debugger.off('detach', onDetach) - } catch { - /* guest may already be destroyed */ - } - } - } - - private resolveBrowserTabIdForGuestWebContentsId(guestWebContentsId: number): string | null { - return this.resolvePopupOwnerContext(guestWebContentsId)?.browserTabId ?? null - } - - private resolvePopupOwnerContext(guestWebContentsId: number): PopupOwnerContext | null { - const browserTabId = this.tabIdByWebContentsId.get(guestWebContentsId) - if (browserTabId) { - return { browserTabId, rootGuestWebContentsId: guestWebContentsId } - } - // Route popups live in an Orca-built window, so they never pass through did-create-window and - // have no inherited context; their owning page comes from the route popup registry instead. - const routeOpenerWebContentsId = resolveBrowserRouteGuestPopupOpener(guestWebContentsId) - if (routeOpenerWebContentsId !== null) { - const openerTabId = this.tabIdByWebContentsId.get(routeOpenerWebContentsId) - return openerTabId - ? { browserTabId: openerTabId, rootGuestWebContentsId: routeOpenerWebContentsId } - : null - } - const inherited = this.popupOwnerContextByGuestId.get(guestWebContentsId) - if ( - inherited && - this.webContentsIdByTabId.get(inherited.browserTabId) === inherited.rootGuestWebContentsId - ) { - return inherited - } - this.popupOwnerContextByGuestId.delete(guestWebContentsId) - return null - } - - /** Shared across the whole opener tree, so a chain of popups draws from one budget. */ - private tryConsumePageInitiatedTab(rootGuestWebContentsId: number): boolean { - let budget = this.pageInitiatedTabBudgetByRootGuestId.get(rootGuestWebContentsId) - if (!budget) { - budget = createPageInitiatedTabBudget() - this.pageInitiatedTabBudgetByRootGuestId.set(rootGuestWebContentsId, budget) - } - return budget.tryConsume(Date.now()) - } - - private resolveRendererForBrowserTab(browserTabId: string): Electron.WebContents | null { - const rendererWebContentsId = this.rendererWebContentsIdByTabId.get(browserTabId) - if (!rendererWebContentsId) { - return null - } - const renderer = webContents.fromId(rendererWebContentsId) - if (!renderer || renderer.isDestroyed()) { - return null - } - return renderer - } - - // Why: screenshots target page ids but visible chrome is keyed by workspace id; activate by workspace or the webview stays hidden and capture times out. - async ensureWebviewVisible(guestWebContentsId: number): Promise<() => void> { - const browserPageId = this.resolveBrowserTabIdForGuestWebContentsId(guestWebContentsId) - if (!browserPageId) { - return () => {} - } - const browserWorkspaceId = this.workspaceIdByPageId.get(browserPageId) ?? browserPageId - const worktreeId = this.worktreeIdByTabId.get(browserPageId) ?? null - const renderer = this.resolveRendererForBrowserTab(browserPageId) - if (!renderer || renderer.isDestroyed()) { - return () => {} - } - - const prev = await renderer - .executeJavaScript( - `(function() { - var store = window.__store; - if (!store) return null; - var state = store.getState(); - var prevTabType = state.activeTabType; - var prevActiveWorktreeId = state.activeWorktreeId || null; - var prevActiveBrowserWorkspaceId = state.activeBrowserTabId || null; - var prevActiveBrowserPageId = null; - var prevFocusedGroupTabId = null; - var targetWorktreeId = ${JSON.stringify(worktreeId)}; - var browserWorkspaceId = ${JSON.stringify(browserWorkspaceId)}; - var browserPageId = ${JSON.stringify(browserPageId)}; - var browserTabsByWorktree = state.browserTabsByWorktree || {}; - - if (prevActiveWorktreeId) { - var prevFocusedGroupId = (state.activeGroupIdByWorktree || {})[prevActiveWorktreeId]; - var prevGroups = (state.groupsByWorktree || {})[prevActiveWorktreeId] || []; - for (var pg = 0; pg < prevGroups.length; pg++) { - if (prevGroups[pg].id === prevFocusedGroupId) { - prevFocusedGroupTabId = prevGroups[pg].activeTabId; - break; - } - } - } - - if (prevActiveBrowserWorkspaceId) { - for (var prevWtId in browserTabsByWorktree) { - var prevBrowserTabs = browserTabsByWorktree[prevWtId] || []; - for (var pbt = 0; pbt < prevBrowserTabs.length; pbt++) { - if (prevBrowserTabs[pbt].id === prevActiveBrowserWorkspaceId) { - prevActiveBrowserPageId = prevBrowserTabs[pbt].activePageId || null; - break; - } - } - if (prevActiveBrowserPageId) break; - } - } - - if ( - targetWorktreeId && - prevActiveWorktreeId !== targetWorktreeId && - typeof state.setActiveWorktree === 'function' - ) { - state.setActiveWorktree(targetWorktreeId); - state = store.getState(); - } - - var foundWorkspace = null; - for (var wtId in browserTabsByWorktree) { - var tabs = browserTabsByWorktree[wtId] || []; - for (var i = 0; i < tabs.length; i++) { - if (tabs[i].id === browserWorkspaceId) { - foundWorkspace = tabs[i]; - if (!targetWorktreeId) { - targetWorktreeId = wtId; - } - break; - } - } - if (foundWorkspace) break; - } - - var hasTargetPage = false; - var targetPages = (state.browserPagesByWorkspace || {})[browserWorkspaceId] || []; - for (var pageIndex = 0; pageIndex < targetPages.length; pageIndex++) { - if (targetPages[pageIndex].id === browserPageId) { - hasTargetPage = true; - break; - } - } - - if (foundWorkspace) { - if (typeof state.setActiveBrowserTab === 'function') { - state.setActiveBrowserTab(browserWorkspaceId); - state = store.getState(); - } else { - var allTabs = state.unifiedTabsByWorktree || {}; - var found = null; - for (var unifiedWtId in allTabs) { - var unifiedTabs = allTabs[unifiedWtId] || []; - for (var unifiedIndex = 0; unifiedIndex < unifiedTabs.length; unifiedIndex++) { - if ( - unifiedTabs[unifiedIndex].contentType === 'browser' && - unifiedTabs[unifiedIndex].entityId === browserWorkspaceId - ) { - found = unifiedTabs[unifiedIndex]; - break; - } - } - if (found) break; - } - if (found) { - state.activateTab(found.id); - } - state.setActiveTabType('browser'); - state = store.getState(); - } - // Why: activating the workspace alone is not enough for screenshot - // capture when a browser workspace contains multiple pages. The - // compositor only paints the currently mounted page guest. - if ( - hasTargetPage && - foundWorkspace.activePageId !== browserPageId && - typeof state.setActiveBrowserPage === 'function' - ) { - state.setActiveBrowserPage(browserWorkspaceId, browserPageId); - state = store.getState(); - } - } - - return { - prevTabType: prevTabType, - prevActiveWorktreeId: prevActiveWorktreeId, - prevActiveBrowserWorkspaceId: prevActiveBrowserWorkspaceId, - prevActiveBrowserPageId: prevActiveBrowserPageId, - prevFocusedGroupTabId: prevFocusedGroupTabId, - targetWorktreeId: targetWorktreeId, - targetBrowserWorkspaceId: foundWorkspace ? browserWorkspaceId : null, - targetBrowserPageId: foundWorkspace && hasTargetPage ? browserPageId : null - }; - })()` - ) - .catch(() => null) - - const needsRestore = - prev && - (prev.prevTabType !== 'browser' || - prev.prevActiveWorktreeId !== prev.targetWorktreeId || - prev.prevFocusedGroupTabId !== null || - prev.prevActiveBrowserWorkspaceId !== prev.targetBrowserWorkspaceId || - prev.prevActiveBrowserPageId !== prev.targetBrowserPageId) - - if (!needsRestore) { - return () => {} - } - - return () => { - if (!prev || !renderer || renderer.isDestroyed()) { - return - } - renderer - .executeJavaScript( - `(function() { - var store = window.__store; - if (!store) return; - var state = store.getState(); - if ( - ${JSON.stringify(prev?.prevActiveWorktreeId)} && - ${JSON.stringify(prev?.prevActiveWorktreeId)} !== - ${JSON.stringify(prev?.targetWorktreeId)} && - typeof state.setActiveWorktree === 'function' - ) { - state.setActiveWorktree(${JSON.stringify(prev?.prevActiveWorktreeId)}); - state = store.getState(); - } - if ( - ${JSON.stringify(prev?.prevActiveBrowserWorkspaceId)} && - ${JSON.stringify(prev?.prevActiveBrowserWorkspaceId)} !== - ${JSON.stringify(prev?.targetBrowserWorkspaceId)} && - typeof state.setActiveBrowserTab === 'function' - ) { - state.setActiveBrowserTab(${JSON.stringify(prev?.prevActiveBrowserWorkspaceId)}); - state = store.getState(); - } - if ( - ${JSON.stringify(prev?.prevActiveBrowserWorkspaceId)} && - ${JSON.stringify(prev?.prevActiveBrowserPageId)} && - ${JSON.stringify(prev?.prevActiveBrowserPageId)} !== - ${JSON.stringify(prev?.targetBrowserPageId)} && - typeof state.setActiveBrowserPage === 'function' - ) { - // Why: Orca remembers the last browser workspace/page even when - // the user is currently in terminal/editor view. Screenshot prep - // temporarily switches that hidden browser selection state, so - // restore it independently of the visible tab type. - state.setActiveBrowserPage( - ${JSON.stringify(prev?.prevActiveBrowserWorkspaceId)}, - ${JSON.stringify(prev?.prevActiveBrowserPageId)} - ); - state = store.getState(); - } - if ( - ${JSON.stringify(prev?.prevTabType)} !== 'browser' && - ${JSON.stringify(prev?.prevFocusedGroupTabId)} - ) { - state.activateTab(${JSON.stringify(prev?.prevFocusedGroupTabId)}); - } - if (${JSON.stringify(prev?.prevTabType)} !== 'browser') { - state.setActiveTabType(${JSON.stringify(prev?.prevTabType)}); - } - })()` - ) - .catch(() => {}) - } - } - - async acquireAutomationVisibility(guestWebContentsId: number): Promise<() => void> { - const browserPageId = this.resolveBrowserTabIdForGuestWebContentsId(guestWebContentsId) - if (!browserPageId) { - return () => {} - } - const renderer = this.resolveRendererForBrowserTab(browserPageId) - if (!renderer || renderer.isDestroyed()) { - return () => {} - } - - // Why: agent commands need a paintable webview for lazy-loading sites without stealing the user's visible tab. - const acquirePromise = renderer - .executeJavaScript( - `(async function() { - var bridge = window.__orcaBrowserAutomationVisibility; - if (!bridge || typeof bridge.acquire !== 'function') return null; - return await bridge.acquire(${JSON.stringify(browserPageId)}); - })()` - ) - .catch(() => null) - const { value: token, timedOut } = await resolveWithTimeout( - acquirePromise, - AUTOMATION_VISIBILITY_ACQUIRE_TIMEOUT_MS, - null - ) - - if (!isAutomationVisibilityToken(token)) { - return createNoopRestoreForTimedOutAutomationAcquire(renderer, acquirePromise, timedOut) - } - - return () => { - releaseAutomationVisibilityToken(renderer, token) - } - } - - attachGuestPolicies( - guest: Electron.WebContents, - inheritedOwnerContext: PopupOwnerContext | null = null, - policy: BrowserGuestPolicy = BROWSING_GUEST_POLICY - ): void { - if (this.policyAttachedGuestIds.has(guest.id)) { - return - } - this.policyAttachedGuestIds.add(guest.id) - // Why one door with a profile rather than a second installer beside it: whether a guest was - // policy-attached at all is what registration and teardown both key on, so a guest that took - // another path into the app is invisible to both. - if (policy.profile === 'workspace-doc') { - this.attachWorkspaceDocGuestPolicies(guest, policy.host) - return - } - if (inheritedOwnerContext) { - this.popupOwnerContextByGuestId.set(guest.id, inheritedOwnerContext) - } - // Why: only the primary embedded browser converts new-tab clicks to Orca tabs; OAuth child windows keep native link behavior. - const clickedLinkFrameName = inheritedOwnerContext - ? null - : `__orca_clicked_link_foreground_${randomUUID()}` - if (clickedLinkFrameName) { - this.clickedLinkFrameNameByGuestId.set(guest.id, clickedLinkFrameName) - } - let clickedLinkRoutingActive = Boolean(clickedLinkFrameName) - - // Why: bot detectors probe APIs that differ in Electron webviews; inject overrides each load so manual browsing passes. - const disposeAntiDetection = this.injectAntiDetection(guest) - // Why: disable throttling so background screenshots still get frames; else the compositor stalls and capture returns empty. - guest.setBackgroundThrottling(false) - const installClickedLinkRouting = (): void => { - if (!clickedLinkRoutingActive || !clickedLinkFrameName || guest.isDestroyed()) { - return - } - // Why: an isolated-world listener labels real anchor clicks without exposing the frame name to page scripts. - void guest - .executeJavaScriptInIsolatedWorld( - BROWSER_CLICKED_LINK_ROUTING_WORLD_ID, - [ - { - // Why: mobile emulation spoofs the UA as iOS, so use the real host platform from main for modifier routing. - code: buildBrowserClickedLinkRoutingScript( - clickedLinkFrameName, - process.platform === 'darwin' - ) - } - ], - false - ) - .catch(() => {}) - } - if (clickedLinkFrameName) { - guest.on('dom-ready', installClickedLinkRouting) - } - const pendingIframeRoutingInstalls = new Map void>() - const iframeFrameNameByFrame = new Map() - const iframeFrameByFrameName = new Map() - const clearIframeFrameName = (frame: Electron.WebFrameMain): void => { - const name = iframeFrameNameByFrame.get(frame) - if (!name) { - return - } - iframeFrameNameByFrame.delete(frame) - iframeFrameByFrameName.delete(name) - } - const installIframeClickedLinkRouting = (frame: Electron.WebFrameMain): void => { - clearIframeFrameName(frame) - if (!clickedLinkRoutingActive || frame.isDestroyed()) { - return - } - const name = `__orca_clicked_link_iframe_foreground_${randomUUID()}` - iframeFrameNameByFrame.set(frame, name) - iframeFrameByFrameName.set(name, frame) - // Why: child-frame tokens live in the page world, so consume after one trusted click and replace before the next. - void frame - .executeJavaScript( - buildBrowserIframeClickedLinkRoutingScript(name, process.platform === 'darwin'), - false - ) - .catch(() => { - if (iframeFrameNameByFrame.get(frame) === name) { - clearIframeFrameName(frame) - } - }) - } - const handleFrameCreated = ( - _event: Electron.Event, - { frame }: Electron.FrameCreatedDetails - ): void => { - if (!clickedLinkFrameName || !frame || frame.parent === null) { - return - } - for (const knownFrame of iframeFrameNameByFrame.keys()) { - if (knownFrame.isDestroyed()) { - clearIframeFrameName(knownFrame) - } - } - const installAfterDomReady = (): void => { - pendingIframeRoutingInstalls.delete(frame) - installIframeClickedLinkRouting(frame) - } - pendingIframeRoutingInstalls.set(frame, installAfterDomReady) - frame.once('dom-ready', installAfterDomReady) - } - if (clickedLinkFrameName) { - guest.on('frame-created', handleFrameCreated) - } - const handleDidCreateWindow = (window: Electron.BrowserWindow): void => { - // Why: popup descendants inherit the opener's owner context but must not replace its primary registration. - this.attachGuestPolicies(window.webContents, this.resolvePopupOwnerContext(guest.id)) - } - guest.on('did-create-window', handleDidCreateWindow) - guest.setWindowOpenHandler(({ url, frameName, disposition, features }) => { - const ownerContext = this.resolvePopupOwnerContext(guest.id) - const browserTabId = ownerContext?.browserTabId ?? null - const browserUrl = normalizeBrowserNavigationUrl(url) - const externalUrl = normalizeExternalBrowserUrl(url) - const expectedClickedLinkFrameName = this.clickedLinkFrameNameByGuestId.get(guest.id) - const iframeFrame = frameName ? iframeFrameByFrameName.get(frameName) : undefined - let isClickedLink = Boolean( - expectedClickedLinkFrameName && frameName === expectedClickedLinkFrameName - ) - if (!isClickedLink && iframeFrame) { - isClickedLink = true - clearIframeFrameName(iframeFrame) - queueMicrotask(() => installIframeClickedLinkRouting(iframeFrame)) - } - - if (isClickedLink) { - if (browserTabId && browserUrl && this.openLinkInOrcaTab(browserTabId, browserUrl)) { - this.forwardOrQueuePopupEvent(guest.id, { - origin: safeOrigin(browserUrl), - action: 'opened-in-orca' - }) - } - // Why: a recognized gesture must never fall through to a native popup if its renderer vanished mid-click. - return { action: 'deny' } - } - - // Why: an unnamed, featureless window.open() is Chromium's own new-tab shape, so an Orca tab is - // the honest presentation; a floating origin-bar window is not. Opener-dependent shapes are - // excluded by isNewBrowserTabPopupIntent and still get a real child window below. - if ( - ownerContext && - externalUrl && - isNewBrowserTabPopupIntent({ frameName, disposition, features }) - ) { - // Why: one activation lets a page loop window.open, and each routed tab persists into - // workspace session state, so it survives the quit that used to clear popup windows. - if (!this.tryConsumePageInitiatedTab(ownerContext.rootGuestWebContentsId)) { - this.forwardOrQueuePopupEvent(guest.id, { - origin: safeOrigin(externalUrl), - action: 'blocked' - }) - return { action: 'deny' } - } - if (this.openLinkInOrcaTab(ownerContext.browserTabId, externalUrl)) { - this.forwardOrQueuePopupEvent(guest.id, { - origin: safeOrigin(externalUrl), - action: 'opened-in-orca' - }) - } - // Why: a recognized new-tab intent must never fall through to a native popup if its renderer vanished mid-open. - return { action: 'deny' } - } - - // Why: file URLs are fine for in-pane previews, but must not spawn native child windows targeting local paths. - const canOpenAsChild = Boolean(externalUrl || browserUrl === ORCA_BROWSER_BLANK_URL) - if (browserTabId && canOpenAsChild) { - // Why: OAuth may request size/position, but content must not create deceptive or inescapable native chrome. - return { - action: 'allow', - overrideBrowserWindowOptions: SAFE_POPUP_WINDOW_OPTIONS, - // Why: default child windows lack an address bar; host in an Orca origin-bar window so the destination is verifiable. - createWindow: (options: PopupChildWindowOptions) => - this.createPopupChildWindowWithOriginBar(guest, url, options) - } - } else if (externalUrl) { - // Why: Kagi target=_blank popup URLs still contain the bearer token; redact before handing to the OS browser. - void shell.openExternal(redactKagiSessionToken(externalUrl)) - this.forwardOrQueuePopupEvent(guest.id, { - origin: safeOrigin(externalUrl), - action: 'opened-external' - }) - } else { - // Why: popup URLs can carry auth redirects/one-time tokens; surface only sanitized origin metadata. - this.forwardOrQueuePopupEvent(guest.id, { - origin: safeOrigin(url), - action: 'blocked' - }) - } - return { action: 'deny' } - }) - - const navigationGuard = (event: Electron.Event, url: string): boolean => { - // Why: Turnstile loads challenge resources via blob:; blocking them trips error 600010. Allow only http(s) blobs, not opaque ones. - if (url.startsWith('blob:https://') || url.startsWith('blob:http://')) { - return true - } - // Why: initial file:// attach is allowed for user-opened previews, but block later file:// redirects so remote pages can't probe the FS. - if (url.startsWith('file:')) { - event.preventDefault() - return false - } - if (!normalizeBrowserNavigationUrl(url)) { - // Why: will-attach-webview only validates the initial src; keep enforcing the allowlist on later navs. - event.preventDefault() - return false - } - return true - } - - const willRedirectHandler = ( - event: Electron.Event, - url: string, - _isInPlace: boolean, - isMainFrame: boolean - ): void => { - if (!navigationGuard(event, url) || !isMainFrame || isChromiumInternalErrorUrl(url)) { - return - } - this.updatePendingNavigationForRedirect(guest.id, url) - this.applyGoogleAuthUserAgent(guest, url, { duringRedirect: true }) - } - - const didFailLoadHandler = ( - _event: Electron.Event, - errorCode: number, - errorDescription: string, - validatedURL: string, - isMainFrame: boolean - ): void => { - if (!isMainFrame) { - return - } - // Why: a nav that never committed must not leave its target standing as the tab's host. - const failedNavigationWasCurrent = this.failPendingNavigation(guest.id, validatedURL) - if (failedNavigationWasCurrent) { - // The attempted host never committed, so restore every UA layer to the document that remains. - this.applyGoogleAuthUserAgent(guest, guest.getURL()) - } - 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: this cancellation carries the existing cert warning; don't overwrite it with ERR_ABORTED copy. - return - } - if (errorCode === -3) { - // Why: an aborted nav never committed; restore the error did-start-navigation cleared so it isn't lost. - 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 = this.buildLoadError( - errorCode, - errorDescription || 'This site could not be reached.', - 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 - } - // Why: getURL() still reports the previous committed URL until this navigation commits, so - // every UA writer must read the in-flight target or they disagree about the tab's host. - this.startPendingNavigation(guest.id, url) - this.applyGoogleAuthUserAgent(guest, url) - this.certificateTrustController?.onMainFrameNavigationStarted(guest.id) - // Why: a pre-registration failure belongs only to its own nav; a replacement nav must not replay it. - 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 can't resurrect an old failure. - 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: once committed, getURL() reports this url, so the pending target is redundant. - this.pendingNavigationByGuestId.delete(guest.id) - // Why: a committed nav makes the did-start-navigation stash obsolete; drop it so a later ERR_ABORTED can't restore an error over it. - this.clearedLoadErrorsByGuestId.delete(guest.id) - this.certificateTrustController?.onMainFrameNavigationCommitted(guest.id, url) - } - - guest.on('will-navigate', navigationGuard) - guest.on('will-redirect', willRedirectHandler) - guest.on('did-start-navigation', didStartNavigationHandler) - guest.on('did-navigate', didNavigateHandler) - guest.on('did-fail-load', didFailLoadHandler) - const handleDestroyed = (): void => { - // Why: guests can die before renderer registration, else attach-time closures leak until shutdown. - this.cleanupGuestPolicyAttachment(guest.id) - } - guest.on('destroyed', handleDestroyed) - - // Why: store cleanup so unregisterGuest can drop these listeners on teardown and let the WebContents wrapper GC. - this.policyCleanupByGuestId.set(guest.id, () => { - disposeAntiDetection() - try { - guest.off('destroyed', handleDestroyed) - guest.off('did-create-window', handleDidCreateWindow) - if (clickedLinkFrameName) { - clickedLinkRoutingActive = false - guest.off('dom-ready', installClickedLinkRouting) - guest.off('frame-created', handleFrameCreated) - for (const [frame, install] of pendingIframeRoutingInstalls) { - if (!frame.isDestroyed()) { - frame.off('dom-ready', install) - } - } - pendingIframeRoutingInstalls.clear() - iframeFrameNameByFrame.clear() - iframeFrameByFrameName.clear() - } - } catch { - // guest may already be destroyed - } - if (!guest.isDestroyed()) { - guest.off('will-navigate', navigationGuard) - guest.off('will-redirect', willRedirectHandler) - guest.off('did-start-navigation', didStartNavigationHandler) - guest.off('did-navigate', didNavigateHandler) - guest.off('did-fail-load', didFailLoadHandler) - } - }) - } - - /** - * A workspace document is not the web: no popups, no link routing, no anti-detection, and no - * navigation bookkeeping for chrome it does not have. What it does share with a browsing guest is - * this method's teardown, so a retired preview drops its listeners on the same path. - */ - private attachWorkspaceDocGuestPolicies( - guest: Electron.WebContents, - host: Electron.WebContents - ): void { - const disposeDocPolicy = installDocPreviewGuestPolicy(guest, host) - const handleDestroyed = (): void => { - this.cleanupGuestPolicyAttachment(guest.id) - } - guest.on('destroyed', handleDestroyed) - this.policyCleanupByGuestId.set(guest.id, () => { - disposeDocPolicy() - try { - guest.off('destroyed', handleDestroyed) - } catch { - // guest may already be destroyed - } - }) - } - - // Why: navigator.userAgent (read by Google's auth JS) reflects the WebContents UA, - // not the request header, so the header-level Firefox switch in setupClientHintsOverride - // must be matched here per navigation or the two layers disagree — itself a bot tell. - // Restores the session's base identity off the auth hosts. Native-UA profiles opt out - // of the whole clean-UA path, so they keep their untouched identity everywhere. - private applyGoogleAuthUserAgent( - guest: Electron.WebContents, - url: string, - options: { duringRedirect?: boolean } = {} - ): void { - const browserPageId = this.tabIdByWebContentsId.get(guest.id) - // Why: popup child windows get these policies but are never in tabIdByWebContentsId, so a direct - // lookup misses the native-UA opt-out and would hand a native profile's popup the Firefox UA. - // That is worse than doing nothing: native sessions skip setupClientHintsOverride entirely, so - // the popup would send the raw Electron UA on the wire while navigator.userAgent claims Firefox. - const ownerTabId = this.resolveBrowserTabIdForGuestWebContentsId(guest.id) - // Session state is authoritative before renderer registration and after a native profile imports a source UA. - const mode = - getBrowserSessionUserAgentMode(guest.session) ?? - (ownerTabId ? this.userAgentModeByPageId.get(ownerTabId) : undefined) - if (mode === 'native') { - return - } - const firefoxUa = googleAuthUserAgent() - const overrideState = this.authUserAgentOverrideStateByGuestId.get(guest.id) - const latestPendingOverride = overrideState?.pending.at(-1) - const confirmedOverride = overrideState?.confirmed - const currentOverride = - latestPendingOverride && latestPendingOverride.sequence > (confirmedOverride?.sequence ?? -1) - ? latestPendingOverride - : confirmedOverride - const currentUa = currentOverride?.userAgent ?? guest.getUserAgent() - const nextUa = isGoogleAuthUrl(url) - ? firefoxUa - : // Only restore when the auth-host override is actually in place, so normal - // navigation never touches the session UA. - currentUa === firefoxUa - ? guest.session.getUserAgent() - : null - let authOverrideIssuedOverCdp = false - if (nextUa !== null && nextUa !== currentUa) { - // Why: WebContents.setUserAgent() during a redirect makes Chromium cancel the in-flight - // navigation (ERR_ABORTED) and replay the original request, which a POST-started OAuth chain - // cannot survive — the sign-in lands on a blank tab. CDP retargets navigator.userAgent without - // touching the navigation, and it outranks the WebContents UA from then on, so a guest that - // switches to it stays on it. The wire UA never depended on this write: setupClientHintsOverride - // rewrites User-Agent per request for auth-host URLs on its own. - if (options.duringRedirect === true || overrideState !== undefined) { - if (this.canOverrideUserAgentOverCdp(guest)) { - authOverrideIssuedOverCdp = true - // Why: go through the viewport builder rather than writing nextUa raw, so both CDP writers - // resolve one identity for this URL — Firefox on auth hosts, the profile's clean base off - // them, any mobile preset preserved. Writing the session UA directly would put the - // unlaundered Electron token back on the wire. - void this.applyAuthUserAgentOverrideOverCdp( - guest, - (browserPageId ? this.viewportUaOverrideMobileByTabId.get(browserPageId) : undefined) ?? - false, - url, - nextUa - ) - } - // Why: with no debugger there is no way to retarget the identity without cancelling the - // redirect. A stale navigator.userAgent is recoverable; a dead navigation is not. - } else { - guest.setUserAgent(nextUa) - } - } - // Why: gate on the DIRECT page id, not ownerTabId — a popup has no device-metrics override of - // its own, so inheriting the owner tab's preset UA would pair a mobile UA with a desktop viewport. - if (browserPageId && !authOverrideIssuedOverCdp) { - this.reapplyViewportUserAgentOverride(guest, browserPageId, url) - } - } - - private canOverrideUserAgentOverCdp(guest: Electron.WebContents): boolean { - try { - return !guest.isDestroyed() && guest.debugger.isAttached() - } catch { - return false - } - } - - private applyAuthUserAgentOverrideOverCdp( - guest: Electron.WebContents, - mobile: boolean, - url: string, - userAgent: string - ): Promise { - if (!this.canOverrideUserAgentOverCdp(guest)) { - return Promise.resolve(false) - } - const state = this.authUserAgentOverrideStateByGuestId.get(guest.id) ?? { - confirmed: null, - nextSequence: 0, - pending: [] - } - const operation = { sequence: ++state.nextSequence, userAgent } - state.pending.push(operation) - this.authUserAgentOverrideStateByGuestId.set(guest.id, state) - return this.sendViewportUserAgentOverride(guest, mobile, url, userAgent).then( - () => this.settleAuthUserAgentOverride(guest.id, state, operation, true), - () => { - this.settleAuthUserAgentOverride(guest.id, state, operation, false) - return false - } - ) - } - - private settleAuthUserAgentOverride( - guestId: number, - state: AuthUserAgentOverrideState, - operation: AuthUserAgentOverrideOperation, - succeeded: boolean - ): boolean { - if (this.authUserAgentOverrideStateByGuestId.get(guestId) !== state) { - return false - } - if (succeeded && (state.confirmed?.sequence ?? -1) < operation.sequence) { - state.confirmed = operation - } - const pendingIndex = state.pending.indexOf(operation) - if (pendingIndex !== -1) { - state.pending.splice(pendingIndex, 1) - } - if (state.confirmed === null && state.pending.length === 0) { - this.authUserAgentOverrideStateByGuestId.delete(guestId) - } - return true - } - - private startPendingNavigation(guestId: number, url: string): void { - const pending = this.pendingNavigationByGuestId.get(guestId) - this.pendingNavigationByGuestId.set(guestId, { - currentUrl: url, - supersededUrls: pending ? [...pending.supersededUrls, pending.currentUrl] : [] - }) - } - - private updatePendingNavigationForRedirect(guestId: number, url: string): void { - const pending = this.pendingNavigationByGuestId.get(guestId) - if (!pending) { - this.pendingNavigationByGuestId.set(guestId, { - currentUrl: url, - supersededUrls: [] - }) - return - } - pending.currentUrl = url - } - - private failPendingNavigation(guestId: number, failedUrl: string): boolean { - const pending = this.pendingNavigationByGuestId.get(guestId) - if (!pending) { - return false - } - const supersededIndex = pending.supersededUrls.indexOf(failedUrl) - if (supersededIndex !== -1) { - pending.supersededUrls.splice(supersededIndex, 1) - return false - } - if (pending.currentUrl !== failedUrl) { - return false - } - this.pendingNavigationByGuestId.delete(guestId) - return true - } - - // Why: webContents.getURL() reports the last COMMITTED url, so mid-navigation it names the host - // the tab is leaving, not the one it is entering. Every UA writer must resolve the host through - // here or two writers racing the same navigation will pick opposite identities. - private resolveTabNavigationUrl(guest: Electron.WebContents): string { - return this.pendingNavigationByGuestId.get(guest.id)?.currentUrl ?? guest.getURL() - } - - // Why: Emulation.setUserAgentOverride is set once and stands across every later navigation, - // outranking setUserAgent for navigator.userAgent. A viewport preset applied before reaching an - // auth host would otherwise pin navigator.userAgent to the Chrome-shaped preset UA while the - // request header says Firefox — the two-layer disagreement this scope exists to remove. - private reapplyViewportUserAgentOverride( - guest: Electron.WebContents, - browserTabId: string, - url: string - ): void { - const mobile = this.viewportUaOverrideMobileByTabId.get(browserTabId) - if (mobile === undefined) { - return - } - // Why: no queue needed — debugger.sendCommand dispatches in call order over one channel, so the - // later-issued write wins. What matters is that both writers resolve the SAME host, which they - // now do via the navigation target rather than the stale committed URL. - void this.sendViewportUserAgentOverride(guest, mobile, url).catch(() => {}) - } - - private async sendViewportUserAgentOverride( - guest: Electron.WebContents, - mobile: boolean, - url?: string, - baseUserAgent?: string - ): Promise { - if (guest.isDestroyed() || !guest.debugger.isAttached()) { - return - } - await guest.debugger.sendCommand( - 'Emulation.setUserAgentOverride', - buildViewportUserAgentOverride({ - url: url ?? this.resolveTabNavigationUrl(guest), - mobile, - // Why: the session UA is the profile's stable base identity. guest.getUserAgent() is not: - // applyGoogleAuthUserAgent leaves it pinned to the Firefox auth UA once a guest switches to - // the CDP override, so reading it back here would republish that identity on ordinary hosts. - baseUserAgent: cleanElectronUserAgent(baseUserAgent ?? guest.session.getUserAgent()) - }) - ) - } - - /** Route guests own their own popup handler, so their denials arrive here instead. */ - reportRouteGuestPopupBlocked(input: { openerWebContentsId: number; url: string }): void { - this.forwardOrQueuePopupEvent(input.openerWebContentsId, { - origin: safeOrigin(input.url), - action: 'blocked' - }) - } - - private createPopupChildWindowWithOriginBar( - openerGuest: Electron.WebContents, - targetUrl: string, - options: PopupChildWindowOptions - ): Electron.WebContents { - const popup = openPopupWithOriginBar(options, targetUrl) - // Why: Electron emits no did-create-window for createWindow children, so attach the opener's policies here. - this.attachGuestPolicies( - popup.contentWebContents, - this.resolvePopupOwnerContext(openerGuest.id) - ) - this.forwardOrQueuePopupEvent(openerGuest.id, { - origin: safeOrigin(targetUrl), - action: 'opened-in-orca' - }) - // Why: match Electron's child-window lifecycle so closing the owning tab doesn't orphan session-bearing popups. - const closePopupWithOpener = (): void => popup.close() - openerGuest.once('destroyed', closePopupWithOpener) - popup.onClosed(() => { - if (!openerGuest.isDestroyed()) { - openerGuest.off('destroyed', closePopupWithOpener) - } - }) - return popup.contentWebContents - } - - private retireStaleGuestWebContents(previousWebContentsId: number): void { - // Why: after a renderer-process swap, stop the dead guest id resolving to the live page so stale callbacks don't hit the wrong session. - this.cleanupGuestPolicyAttachment(previousWebContentsId) - } - - private cleanupGuestPolicyAttachment(guestWebContentsId: number): void { - const browserTabId = this.tabIdByWebContentsId.get(guestWebContentsId) - const isPrimaryGuest = browserTabId !== undefined - if (browserTabId && this.webContentsIdByTabId.get(browserTabId) === guestWebContentsId) { - this.webContentsIdByTabId.delete(browserTabId) - } - this.tabIdByWebContentsId.delete(guestWebContentsId) - this.certificateTrustController?.onGuestRetired(guestWebContentsId) - const policyCleanup = this.policyCleanupByGuestId.get(guestWebContentsId) - if (policyCleanup) { - policyCleanup() - this.policyCleanupByGuestId.delete(guestWebContentsId) - } - this.policyAttachedGuestIds.delete(guestWebContentsId) - this.clickedLinkFrameNameByGuestId.delete(guestWebContentsId) - this.offscreenGuestIds.delete(guestWebContentsId) - this.popupOwnerContextByGuestId.delete(guestWebContentsId) - this.pageInitiatedTabBudgetByRootGuestId.delete(guestWebContentsId) - this.authUserAgentOverrideStateByGuestId.delete(guestWebContentsId) - this.pendingNavigationByGuestId.delete(guestWebContentsId) - // Why: a popup must stop inheriting authorization the moment its owner retires, before Chromium destroys the child. - if (isPrimaryGuest) { - for (const [popupGuestId, owner] of this.popupOwnerContextByGuestId) { - if (owner.rootGuestWebContentsId === guestWebContentsId) { - this.popupOwnerContextByGuestId.delete(popupGuestId) - } - } - } - this.pendingLoadFailuresByGuestId.delete(guestWebContentsId) - this.loadErrorsByGuestId.delete(guestWebContentsId) - this.clearedLoadErrorsByGuestId.delete(guestWebContentsId) - this.pendingPermissionEventsByGuestId.delete(guestWebContentsId) - this.pendingPopupEventsByGuestId.delete(guestWebContentsId) - this.cancelPendingDownloadsForGuest(guestWebContentsId) - } - - registerGuest({ - browserPageId, - browserTabId: legacyBrowserTabId, - workspaceId, - worktreeId, - sessionProfileId, - userAgentMode, - webContentsId, - rendererWebContentsId - }: BrowserGuestRegistration): boolean { - const browserTabId = browserPageId ?? legacyBrowserTabId - // Why refuse rather than overwrite: the two halves of the registry must stay disjoint, or one - // id resolves in both and the tool door silently prefers the document guest over the page. - if (!browserTabId || isWorkspaceDocPageId(browserTabId)) { - return false - } - // Why: on guest-surface swap, cancel any grab bound to the old guest's listeners so it doesn't strand on a stale webContents. - this.cancelGrabOp(browserTabId, 'evicted') - - const previousCleanup = this.contextMenuCleanupByTabId.get(browserTabId) - if (previousCleanup) { - previousCleanup() - this.contextMenuCleanupByTabId.delete(browserTabId) - } - - const guest = webContents.fromId(webContentsId) - if (!guest || guest.isDestroyed()) { - return false - } - - // Why: don't trust the renderer-sent id blindly — a compromised renderer could pass the main window's id; only accept webview guests. - if (guest.getType() !== 'webview') { - return false - } - if (!this.policyAttachedGuestIds.has(webContentsId)) { - // Why: only trust guests that passed attach-time policy install, or a renderer could point us at an arbitrary webview. - return false - } - - const previousWebContentsId = this.webContentsIdByTabId.get(browserTabId) - if (previousWebContentsId !== undefined && previousWebContentsId !== webContentsId) { - this.retireStaleGuestWebContents(previousWebContentsId) - this.viewportPresetActiveByTabId.delete(browserTabId) - this.viewportScrollStateByTabId.delete(browserTabId) - } - this.webContentsIdByTabId.set(browserTabId, webContentsId) - this.tabIdByWebContentsId.set(webContentsId, browserTabId) - if (workspaceId) { - this.workspaceIdByPageId.set(browserTabId, workspaceId) - } - this.sessionProfileIdByPageId.set(browserTabId, sessionProfileId ?? null) - if (userAgentMode) { - this.userAgentModeByPageId.set(browserTabId, userAgentMode) - } else { - this.userAgentModeByPageId.delete(browserTabId) - } - this.rendererWebContentsIdByTabId.set(browserTabId, rendererWebContentsId) - if (worktreeId) { - this.worktreeIdByTabId.set(browserTabId, worktreeId) - } - this.certificateTrustController?.onGuestRegistered(webContentsId, browserTabId) - - this.setupContextMenu(browserTabId, guest) - this.setupGrabShortcut(browserTabId, guest) - this.setupShortcutForwarding(browserTabId, guest) - this.setupMouseWheelZoomForwarding(browserTabId, guest) - this.flushPendingLoadFailure(browserTabId, webContentsId) - this.flushPendingPermissionEvents(browserTabId, webContentsId) - this.flushPendingPopupEvents(browserTabId, webContentsId) - this.flushPendingDownloadRequests(browserTabId, webContentsId) - return true - } - - unregisterGuest(browserTabId: string): void { - // Why the check on the exit door too: a document page withdraws by revoking its grant, never - // through here, so its id arriving is misaddressed — and the cancel below would evict that - // preview's live grab on the strength of it. - if (isWorkspaceDocPageId(browserTabId)) { - return - } - // Why: teardown mid-grab must cancel it so the renderer gets a signal, not a dangling Promise. - this.cancelGrabOp(browserTabId, 'evicted') - - // Why: remove attachGuestPolicies listeners so their guest-WebContents closures don't block GC. - const guestWebContentsId = this.webContentsIdByTabId.get(browserTabId) - if (guestWebContentsId !== undefined) { - this.cleanupGuestPolicyAttachment(guestWebContentsId) - } - - const cleanup = this.contextMenuCleanupByTabId.get(browserTabId) - if (cleanup) { - cleanup() - this.contextMenuCleanupByTabId.delete(browserTabId) - } - const shortcutCleanup = this.grabShortcutCleanupByTabId.get(browserTabId) - if (shortcutCleanup) { - shortcutCleanup() - this.grabShortcutCleanupByTabId.delete(browserTabId) - } - const fwdCleanup = this.shortcutForwardingCleanupByTabId.get(browserTabId) - if (fwdCleanup) { - fwdCleanup() - this.shortcutForwardingCleanupByTabId.delete(browserTabId) - } - const mouseWheelZoomCleanup = this.mouseWheelZoomCleanupByTabId.get(browserTabId) - if (mouseWheelZoomCleanup) { - mouseWheelZoomCleanup() - this.mouseWheelZoomCleanupByTabId.delete(browserTabId) - } - // Why: downloads are per-tab chrome; closing the tab must cancel active writes, not orphan them. - for (const [downloadId, download] of this.downloadsById.entries()) { - if (download.browserTabId === browserTabId && !download.terminalEvent) { - this.cancelDownloadInternal(downloadId, 'Tab closed before download completed.') - } - } - const wcId = this.webContentsIdByTabId.get(browserTabId) - if (wcId !== undefined) { - this.tabIdByWebContentsId.delete(wcId) - } - this.webContentsIdByTabId.delete(browserTabId) - this.rendererWebContentsIdByTabId.delete(browserTabId) - this.workspaceIdByPageId.delete(browserTabId) - this.sessionProfileIdByPageId.delete(browserTabId) - this.userAgentModeByPageId.delete(browserTabId) - this.worktreeIdByTabId.delete(browserTabId) - // Why: drop the viewport-op chain so the Map doesn't retain a promise keyed to a destroyed guest. - this.viewportOpsByTabId.delete(browserTabId) - this.viewportUaOverrideMobileByTabId.delete(browserTabId) - this.viewportPresetActiveByTabId.delete(browserTabId) - this.viewportScrollStateByTabId.delete(browserTabId) - if (wcId !== undefined) { - this.pendingNavigationByGuestId.delete(wcId) - } - this.annotationViewportBridgeOpsByTabId.delete(browserTabId) - } - - // Why: headless orca serve has no window; back pages with offscreen WebContents and skip the webview-only setup. - registerOffscreenGuest({ - browserPageId, - worktreeId, - sessionProfileId, - userAgentMode, - webContentsId - }: { - browserPageId: string - worktreeId?: string - sessionProfileId?: string | null - userAgentMode?: BrowserSessionUserAgentMode - webContentsId: number - }): boolean { - // Why the same check on both registration doors: one id resolving in both halves is the exact - // confusion the split registries exist to prevent. - if (isWorkspaceDocPageId(browserPageId)) { - return false - } - const guest = webContents.fromId(webContentsId) - if (!guest || guest.isDestroyed()) { - return false - } - // Why: offscreen pages have no renderer webview listeners, so main owns their load-failure lifecycle. - this.offscreenGuestIds.add(webContentsId) - this.attachGuestPolicies(guest) - const previousWebContentsId = this.webContentsIdByTabId.get(browserPageId) - if (previousWebContentsId !== undefined && previousWebContentsId !== webContentsId) { - this.retireStaleGuestWebContents(previousWebContentsId) - this.viewportPresetActiveByTabId.delete(browserPageId) - this.viewportScrollStateByTabId.delete(browserPageId) - } - this.webContentsIdByTabId.set(browserPageId, webContentsId) - this.tabIdByWebContentsId.set(webContentsId, browserPageId) - this.sessionProfileIdByPageId.set(browserPageId, sessionProfileId ?? null) - if (userAgentMode) { - this.userAgentModeByPageId.set(browserPageId, userAgentMode) - } else { - this.userAgentModeByPageId.delete(browserPageId) - } - if (worktreeId) { - this.worktreeIdByTabId.set(browserPageId, worktreeId) - } - this.certificateTrustController?.onGuestRegistered(webContentsId, browserPageId) - return true - } - - unregisterAll(): void { - // Cancel all active grab ops before tearing down registrations - this.grabSessionController.cancelAll('evicted') - for (const downloadId of this.downloadsById.keys()) { - this.cancelDownloadInternal(downloadId, 'Orca is shutting down.') - } - browserDownloadDestinationReservations.clear() - for (const browserTabId of this.webContentsIdByTabId.keys()) { - this.unregisterGuest(browserTabId) - } - this.policyAttachedGuestIds.clear() - this.offscreenGuestIds.clear() - // Why: unregisterGuest skips guests that were policy-attached but never registered; invoke their cleanup closures here. - for (const cleanup of this.policyCleanupByGuestId.values()) { - cleanup() - } - this.policyCleanupByGuestId.clear() - this.clickedLinkFrameNameByGuestId.clear() - this.tabIdByWebContentsId.clear() - this.popupOwnerContextByGuestId.clear() - this.pageInitiatedTabBudgetByRootGuestId.clear() - this.worktreeIdByTabId.clear() - this.sessionProfileIdByPageId.clear() - this.userAgentModeByPageId.clear() - this.viewportUaOverrideMobileByTabId.clear() - this.viewportPresetActiveByTabId.clear() - this.viewportScrollStateByTabId.clear() - this.authUserAgentOverrideStateByGuestId.clear() - this.pendingNavigationByGuestId.clear() - this.pendingLoadFailuresByGuestId.clear() - this.loadErrorsByGuestId.clear() - this.clearedLoadErrorsByGuestId.clear() - this.pendingPermissionEventsByGuestId.clear() - this.pendingPopupEventsByGuestId.clear() - this.pendingDownloadIdsByGuestId.clear() - this.mouseWheelZoomCleanupByTabId.clear() - this.annotationViewportBridgeOpsByTabId.clear() - } - - getGuestWebContentsId(browserTabId: string): number | null { - return this.webContentsIdByTabId.get(browserTabId) ?? null - } - - getWebContentsIdByTabId(): Map { - return this.webContentsIdByTabId - } - - getTabIdForWebContentsId(webContentsId: number): string | null { - return this.tabIdByWebContentsId.get(webContentsId) ?? null - } - - getWorktreeIdForTab(browserTabId: string): string | undefined { - return this.worktreeIdByTabId.get(browserTabId) - } - - getRendererContextForGuest( - guestWebContentsId: number - ): { browserPageId: string; renderer: Electron.WebContents } | null { - const browserPageId = this.resolveBrowserTabIdForGuestWebContentsId(guestWebContentsId) - if (!browserPageId) { - return null - } - const renderer = this.resolveRendererForBrowserTab(browserPageId) - return renderer ? { browserPageId, renderer } : null - } - - getSessionProfileIdForTab(browserTabId: string): string | null { - 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' - } - } - - // Why: centralize Kagi session-token redaction so every load-error path (did-fail-load, cert failure) strips it. - private buildLoadError(code: number, description: string, rawUrl: string): BrowserLoadError { - return { - code, - description, - validatedUrl: redactKagiSessionToken(rawUrl) - } - } - - notifyCertificateFailureChanged( - webContentsId: number, - failure: BrowserCertificateFailure | null, - navigationUrl?: string - ): void { - if (failure && navigationUrl) { - const loadError = this.buildLoadError(failure.errorCode ?? -1, failure.error, 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: runs inside an Electron guest event dispatch, so an escaping throw would be a fatal uncaught exception. - try { - this.browserGuestStateChangedListener?.(worktreeId) - } catch (error) { - console.error('[browser-manager] browserGuestStateChanged listener failed', error) - } - } - } - - notifyPermissionDenied(args: { - guestWebContentsId: number - permission: string - rawUrl: string - }): void { - this.forwardOrQueuePermissionDenied(args.guestWebContentsId, { - permission: args.permission, - origin: safeOrigin(args.rawUrl) - }) - } - - handleGuestWillDownload(args: { guestWebContentsId: number; item: Electron.DownloadItem }): void { - const { guestWebContentsId, item } = args - const downloadId = randomUUID() - const requestedFilename = (() => { - try { - return item.getFilename() || 'download' - } catch { - return 'download' - } - })() - const totalBytes = (() => { - try { - const total = item.getTotalBytes() - return total > 0 ? total : null - } catch { - return null - } - })() - const mimeType = (() => { - try { - const mime = item.getMimeType() - return mime || null - } catch { - return null - } - })() - const origin = (() => { - try { - return safeOrigin(item.getURL()) - } catch { - return 'unknown' - } - })() - - // Why: a client-hosted page's bytes belong on the remote workspace, so main stages them itself - // instead of reserving a name in the desktop Downloads folder. A popup downloads to its - // opener's page: the popup itself is a client-local transient with no logical page of its own. - const ownerContext = this.resolvePopupOwnerContext(guestWebContentsId) - const decision = routeBrowserClientDownload({ - guestWebContentsId: ownerContext?.rootGuestWebContentsId ?? guestWebContentsId - }) - const clientRoute = decision.kind === 'remote' ? decision.route : null - const destination = (() => { - if (clientRoute) { - return { - filename: requestedFilename, - savePath: clientRoute.stagingPath, - reservationKey: null - } - } - // Why: a client-hosted download with no resolvable remote destination is canceled rather than - // written to this desktop's Downloads folder. - if (decision.kind === 'blocked') { - return null - } - try { - return browserDownloadDestinationReservations.reserve(requestedFilename) - } catch (error) { - console.error('[browser-download] Failed to choose download destination:', error) - return null - } - })() - - const fallbackSavePath = destination?.savePath ?? '' - - const download: ActiveDownload = { - downloadId, - guestWebContentsId, - browserTabId: null, - rendererWebContentsId: null, - origin, - filename: destination?.filename ?? requestedFilename, - totalBytes, - mimeType, - item, - savePath: fallbackSavePath, - reservationKey: destination?.reservationKey ?? null, - clientRoute, - remoteDestination: undefined, - receivedBytes: 0, - transientState: null, - terminalEvent: null, - startedSent: false, - cleanup: null - } - this.downloadsById.set(downloadId, download) - - const browserTabId = ownerContext?.browserTabId ?? null - if (browserTabId) { - this.bindDownloadToTab(downloadId, browserTabId) - } else { - const pending = this.pendingDownloadIdsByGuestId.get(guestWebContentsId) ?? [] - pending.push(downloadId) - this.pendingDownloadIdsByGuestId.set(guestWebContentsId, pending) - } - - if (!destination) { - this.finishDownloadInternal( - downloadId, - 'failed', - decision.kind === 'blocked' - ? 'Could not save the download to the remote workspace.' - : 'Could not choose a Downloads file name.' - ) - try { - item.cancel() - } catch { - // Why: with no destination Chromium must not keep writing invisibly; cancel is best-effort after surfacing the failure. - } - return - } - - try { - item.setSavePath(destination.savePath) - } catch (error) { - console.error('[browser-download] Failed to set download destination:', error) - this.finishDownloadInternal(downloadId, 'failed', 'Failed to set download destination.') - try { - item.cancel() - } catch { - // Why: a failed setSavePath can leave Electron partially finalized; cancel is best-effort after the UI is made terminal. - } - return - } - - const updatedHandler = (_event: Electron.Event, state: 'progressing' | 'interrupted'): void => { - download.receivedBytes = this.getDownloadReceivedBytes(download.item) - download.transientState = state - this.sendDownloadProgress(download.browserTabId, { - browserPageId: download.browserTabId ?? undefined, - downloadId: download.downloadId, - receivedBytes: download.receivedBytes, - totalBytes: download.totalBytes, - state - }) - } - const doneHandler = (_event: Electron.Event, state: BrowserDownloadDoneState): void => { - const status: BrowserDownloadFinishedEvent['status'] = - state === 'completed' ? 'completed' : state === 'cancelled' ? 'canceled' : 'failed' - const failure = - status === 'failed' - ? state === 'interrupted' - ? 'Download was interrupted.' - : 'Download failed.' - : null - if (download.clientRoute) { - void this.settleClientHostedDownload(download, status, failure) - return - } - this.finishDownloadInternal(download.downloadId, status, failure) - } - download.cleanup = (): void => { - try { - download.item.off('updated', updatedHandler) - download.item.off('done', doneHandler) - } catch { - // Why: a completed DownloadItem may already be finalized; keep cleanup best-effort so teardown never crashes main. - } - } - item.on('updated', updatedHandler) - item.once('done', doneHandler) - - if (browserTabId) { - this.sendDownloadStarted(downloadId) - } - } - - cancelDownload(args: { downloadId: string; senderWebContentsId: number }): boolean { - const download = this.downloadsById.get(args.downloadId) - if (!download || download.rendererWebContentsId !== args.senderWebContentsId) { - return false - } - this.cancelDownloadInternal(args.downloadId, 'Canceled.') - return true - } - - // Why: guests are isolated from Orca's preload bridge, so main owns the devtools escape hatch after a tab→guest lookup. - async openDevTools(browserTabId: string): Promise { - const webContentsId = this.webContentsIdByTabId.get(browserTabId) - if (!webContentsId) { - return false - } - const guest = webContents.fromId(webContentsId) - if (!guest || guest.isDestroyed()) { - // Why: a stale guest must clear every per-tab registry entry, not just the WebContents maps. - this.unregisterGuest(browserTabId) - return false - } - // Offscreen guests have no visible window on this desktop; detaching DevTools would open it - // on the host display with no route back to the remote client. - if (this.offscreenGuestIds.has(webContentsId)) { - return false - } - guest.openDevTools({ mode: 'detach' }) - return true - } - - // Why: emulate viewport via CDP; never detach the debugger here or per-guest overrides (addScriptToEvaluateOnNewDocument) are cleared. - async setViewportOverride( - browserTabId: string, - override: BrowserViewportOverride | null - ): Promise { - // Why: chain per-tab so rapid toggles don't interleave CDP commands and the last-requested override wins. - const expectedWebContentsId = this.webContentsIdByTabId.get(browserTabId) - if (expectedWebContentsId !== undefined) { - // Keep host panning available while CDP applies the requested dimensions. The guest id fence - // prevents this intent from leaking to a replacement guest; clearing the preset removes it. - this.viewportPresetActiveByTabId.set(browserTabId, { - guestWebContentsId: expectedWebContentsId, - active: override !== null - }) - } - // The renderer resizes the host before CDP completes; discard the old geometry until it - // reports the new pane bounds so a pending preset cannot route wheel input using stale limits. - this.viewportScrollStateByTabId.delete(browserTabId) - const prev = this.viewportOpsByTabId.get(browserTabId) ?? Promise.resolve() - const next = prev - .catch(() => {}) - .then(() => this.doSetViewportOverrideImpl(browserTabId, override, expectedWebContentsId)) - this.viewportOpsByTabId.set(browserTabId, next) - try { - return await next - } finally { - // Why: only clear if we're still the tail; a later call may have replaced the entry, and deleting would break serialization. - if (this.viewportOpsByTabId.get(browserTabId) === next) { - this.viewportOpsByTabId.delete(browserTabId) - } - } - } - - async setAnnotationViewportBridge( - browserTabId: string, - options: BrowserAnnotationViewportBridgeOptions, - resolveGuest: () => Electron.WebContents | null - ): Promise { - const prev = this.annotationViewportBridgeOpsByTabId.get(browserTabId) ?? Promise.resolve() - const next = prev - .catch(() => {}) - .then(() => this.doSetAnnotationViewportBridgeImpl(options, resolveGuest)) - this.annotationViewportBridgeOpsByTabId.set(browserTabId, next) - try { - return await next - } finally { - if (this.annotationViewportBridgeOpsByTabId.get(browserTabId) === next) { - this.annotationViewportBridgeOpsByTabId.delete(browserTabId) - } - } - } - - // Why the caller resolves the guest: the same bridge serves browsing pages and workspace - // documents, which live in different halves of the page registry. - // Why a resolver and not the guest itself: this op may have waited behind another one, and a - // cross-process navigation meanwhile swaps the tab's contents without destroying the old one — - // injecting into the guest the request named would bridge a page nobody is looking at. - // Why no tab id: with teardown gone this reaches only the guest the resolver hands back, and - // taking an id it cannot act on would invite the next reader to act on it. - private async doSetAnnotationViewportBridgeImpl( - options: BrowserAnnotationViewportBridgeOptions, - resolveGuest: () => Electron.WebContents | null - ): Promise { - // Why no teardown here: the resolver already unregisters a page whose guest died, and the only - // case it uniquely leaves is an ownership mismatch on a healthy page — where tearing down would - // cancel that page's in-flight downloads and grabs over a request that was merely misaddressed. - const guest = resolveGuest() - if (!guest || guest.isDestroyed()) { - return false - } - - try { - // Why: run the scroll bridge in an isolated world so page scripts can't read the per-tab token or tamper with it. - await guest.executeJavaScriptInIsolatedWorld( - BROWSER_ANNOTATION_VIEWPORT_BRIDGE_WORLD_ID, - [{ code: buildBrowserAnnotationViewportBridgeScript(options) }], - false - ) - return true - } catch { - return false - } - } - - private async doSetViewportOverrideImpl( - browserTabId: string, - override: BrowserViewportOverride | null, - expectedWebContentsId: number | undefined - ): Promise { - const webContentsId = this.webContentsIdByTabId.get(browserTabId) - if (!webContentsId || webContentsId !== expectedWebContentsId) { - return false - } - const guest = webContents.fromId(webContentsId) - if (!guest || guest.isDestroyed()) { - // Why: a stale guest must clear every per-tab registry entry, not just the WebContents maps. - this.unregisterGuest(browserTabId) - return false - } - - try { - if (!guest.debugger.isAttached()) { - guest.debugger.attach('1.3') - } - } catch (err) { - // Why: attach throws if DevTools is open on the guest; log context so this failure mode is diagnosable. - console.warn('[browser-manager] setViewportOverride: failed to attach debugger', { - browserTabId, - webContentsId, - error: err instanceof Error ? err.message : String(err) - }) - return false - } - - const dbg = guest.debugger - try { - if (override) { - await dbg.sendCommand('Emulation.setDeviceMetricsOverride', { - width: override.width, - height: override.height, - deviceScaleFactor: override.deviceScaleFactor, - mobile: override.mobile - }) - if (this.webContentsIdByTabId.get(browserTabId) === webContentsId) { - this.viewportPresetActiveByTabId.set(browserTabId, { - guestWebContentsId: webContentsId, - active: true - }) - } - await dbg.sendCommand('Emulation.setTouchEmulationEnabled', { - enabled: override.mobile, - maxTouchPoints: override.mobile ? 5 : 0 - }) - // Why: viewport sizing must not override a profile's explicit native-UA identity. - if (this.userAgentModeByPageId.get(browserTabId) !== 'native') { - // Navigation must see the preset intent while the final CDP command is in flight. - this.viewportUaOverrideMobileByTabId.set(browserTabId, override.mobile) - // Why: same sender as the navigation path, so both resolve the tab's host identically. - await this.sendViewportUserAgentOverride(guest, override.mobile) - } - } else { - await dbg.sendCommand('Emulation.clearDeviceMetricsOverride', {}) - if (this.webContentsIdByTabId.get(browserTabId) === webContentsId) { - this.viewportPresetActiveByTabId.set(browserTabId, { - guestWebContentsId: webContentsId, - active: false - }) - } - await dbg.sendCommand('Emulation.setTouchEmulationEnabled', { - enabled: false, - maxTouchPoints: 0 - }) - const trackedMobile = this.viewportUaOverrideMobileByTabId.get(browserTabId) - // A navigation after this point must not re-install the override behind the clear. - this.viewportUaOverrideMobileByTabId.delete(browserTabId) - try { - if (this.authUserAgentOverrideStateByGuestId.has(guest.id)) { - const url = this.resolveTabNavigationUrl(guest) - const restored = await this.applyAuthUserAgentOverrideOverCdp( - guest, - false, - url, - isGoogleAuthUrl(url) ? googleAuthUserAgent() : guest.session.getUserAgent() - ) - if (!restored) { - throw new Error('Failed to preserve auth user agent') - } - } else { - // Why: passing an empty string restores the session default UA. - await dbg.sendCommand('Emulation.setUserAgentOverride', { userAgent: '' }) - } - } catch (error) { - if (trackedMobile !== undefined) { - this.viewportUaOverrideMobileByTabId.set(browserTabId, trackedMobile) - } - throw error - } - } - if (this.webContentsIdByTabId.get(browserTabId) !== webContentsId) { - return false - } - return true - } catch { - return false - } - } - - // --- Browser Context Grab — main-owned operations --- - - /** Validate that the sender owns browserTabId; returns the guest WebContents or null. */ - /** - * The guest a request from `senderWebContentsId` may act on, across both halves of the page - * registry. This is the only door taught about workspace-document guests: they are kept out of - * the browsing maps entirely, so page management, agent commands, download routing and - * certificate attribution all miss them without a guard of their own — and a reader who opens a - * tool on the document in front of them still gets an answer. - */ - getAuthorizedGuest( - browserTabId: string, - senderWebContentsId: number - ): Electron.WebContents | null { - const docGuest = getWorkspaceDocPageGuest(browserTabId, senderWebContentsId) - if (docGuest) { - return docGuest - } - const registeredRenderer = this.rendererWebContentsIdByTabId.get(browserTabId) - if (registeredRenderer == null || registeredRenderer !== senderWebContentsId) { - return null - } - const guestId = this.webContentsIdByTabId.get(browserTabId) - if (guestId == null) { - return null - } - const guest = webContents.fromId(guestId) - if (!guest || guest.isDestroyed()) { - // Why: a stale guest must clear every per-tab registry entry, not just the WebContents maps. - this.unregisterGuest(browserTabId) - return null - } - return guest - } - - /** Returns true if a grab operation is currently active for this tab. */ - hasActiveGrabOp(browserTabId: string): boolean { - return this.grabSessionController.hasActiveGrabOp(browserTabId) - } - - /** Enable/disable grab mode for a tab: on enable inject the overlay runtime, on disable cancel any active grab op. */ - async setGrabMode( - browserTabId: string, - enabled: boolean, - guest: Electron.WebContents - ): Promise { - if (!enabled) { - const hadActiveGrabOp = this.hasActiveGrabOp(browserTabId) - this.cancelGrabOp(browserTabId, 'user') - if (hadActiveGrabOp) { - return true - } - try { - await guest.executeJavaScript(buildGuestOverlayScript('teardown')) - return true - } catch { - return false - } - } - // Why: inject the overlay runtime eagerly on arm so the hover UI appears instantly; re-injection is idempotent/safe. - try { - await guest.executeJavaScript(buildGuestOverlayScript('arm')) - return true - } catch { - return false - } - } - - /** - * Await a single grab selection on the given tab; resolves once on click, cancel, or error. - * - * Why in-guest: before-input-event fires only for keyboard (not mouse) on guests, so the overlay hit-catcher consumes the click. - */ - awaitGrabSelection( - browserTabId: string, - opId: string, - guest: Electron.WebContents - ): Promise { - return this.grabSessionController.awaitGrabSelection(browserTabId, opId, guest) - } - - /** Cancel an active grab operation for the given tab. */ - cancelGrabOp(browserTabId: string, reason: BrowserGrabCancelReason): void { - this.grabSessionController.cancelGrabOp(browserTabId, reason) - } - - /** Capture a screenshot of the guest surface, optionally cropped to the given CSS-pixel rect. */ - async captureSelectionScreenshot( - _browserTabId: string, - rect: BrowserGrabRect, - guest: Electron.WebContents - ): Promise { - return captureGrabSelectionScreenshot(rect, guest) - } - - /** Extract the hovered element's payload without disrupting the active grab overlay/awaitClick listener. */ - async extractHoverPayload( - _browserTabId: string, - guest: Electron.WebContents - ): Promise { - try { - const rawPayload = await guest.executeJavaScript(buildGuestOverlayScript('extractHover')) - if (!rawPayload || typeof rawPayload !== 'object') { - return null - } - return clampGrabPayload(rawPayload) - } catch { - return null - } - } - - private setupContextMenu(browserTabId: string, guest: Electron.WebContents): void { - this.contextMenuCleanupByTabId.set( - browserTabId, - setupGuestContextMenu({ - browserTabId, - guest, - resolveRenderer: (tabId) => this.resolveRendererForBrowserTab(tabId) - }) - ) - } - - // Why: forward grab's Cmd/Ctrl+C from a focused guest only when no edit field/selection is active, so native copy still works. - private setupGrabShortcut(browserTabId: string, guest: Electron.WebContents): void { - const previousCleanup = this.grabShortcutCleanupByTabId.get(browserTabId) - if (previousCleanup) { - previousCleanup() - this.grabShortcutCleanupByTabId.delete(browserTabId) - } - - this.grabShortcutCleanupByTabId.set( - browserTabId, - setupGrabShortcutForwarding({ - browserTabId, - guest, - resolveRenderer: (tabId) => - resolveRendererWebContents(this.rendererWebContentsIdByTabId, tabId), - hasActiveGrabOp: (tabId) => this.hasActiveGrabOp(tabId), - getKeybindings: () => this.settingsResolver?.().keybindings - }) - ) - } - - // Why: a focused webview guest is a separate process, so its key events never reach the renderer; intercept and forward app shortcuts. - private setupShortcutForwarding(browserTabId: string, guest: Electron.WebContents): void { - const previousCleanup = this.shortcutForwardingCleanupByTabId.get(browserTabId) - if (previousCleanup) { - previousCleanup() - this.shortcutForwardingCleanupByTabId.delete(browserTabId) - } - - this.shortcutForwardingCleanupByTabId.set( - browserTabId, - setupGuestShortcutForwarding({ - browserTabId, - guest, - resolveRenderer: (tabId) => - resolveRendererWebContents(this.rendererWebContentsIdByTabId, tabId), - shouldForwardDictationShortcut: () => this.shouldForwardDictationShortcut?.() ?? false, - isMobileEmulatorEnabled: () => this.settingsResolver?.().mobileEmulatorEnabled !== false, - getKeybindings: () => this.settingsResolver?.().keybindings, - resolveWorktreeId: (tabId) => this.worktreeIdByTabId.get(tabId) ?? null, - resolveWorkspaceId: (tabId) => this.workspaceIdByPageId.get(tabId) ?? null - }) - ) - } - - private setupMouseWheelZoomForwarding(browserTabId: string, guest: Electron.WebContents): void { - const previousCleanup = this.mouseWheelZoomCleanupByTabId.get(browserTabId) - if (previousCleanup) { - previousCleanup() - this.mouseWheelZoomCleanupByTabId.delete(browserTabId) - } - - this.mouseWheelZoomCleanupByTabId.set( - browserTabId, - setupGuestMouseWheelZoomForwarding({ - browserTabId, - guest, - resolveRenderer: (tabId) => - resolveRendererWebContents(this.rendererWebContentsIdByTabId, tabId), - isViewportPresetActive: () => { - const state = this.viewportPresetActiveByTabId.get(browserTabId) - return state?.guestWebContentsId === guest.id && state.active - }, - canViewportScroll: (mouse) => this.canViewportScroll(browserTabId, mouse), - onViewportWheelConsumed: (deltaX, deltaY) => - this.recordViewportScrollDelta(browserTabId, deltaX, deltaY) - }) - ) - } - - private canViewportScroll(browserTabId: string, mouse: Electron.MouseWheelInputEvent): boolean { - const state = this.viewportScrollStateByTabId.get(browserTabId) - if (!state) { - return false - } - const deltaX = typeof mouse.deltaX === 'number' ? mouse.deltaX : 0 - const deltaY = typeof mouse.deltaY === 'number' ? mouse.deltaY : 0 - const canScrollAxis = (delta: number, position: number, maximum: number): boolean => { - if (delta < 0) { - return position > 0 - } - if (delta > 0) { - return position < maximum - } - return false - } - return ( - canScrollAxis(deltaX, state.scrollLeft, state.maxScrollLeft) || - canScrollAxis(deltaY, state.scrollTop, state.maxScrollTop) - ) - } - - private forwardOrQueueGuestLoadFailure( - guestWebContentsId: number, - loadError: { code: number; description: string; validatedUrl: string } - ): void { - const browserTabId = this.tabIdByWebContentsId.get(guestWebContentsId) - if (!browserTabId) { - // Why: a failure can arrive before the tab is registered; queue by guest ID so registerGuest can replay it. - this.pendingLoadFailuresByGuestId.set(guestWebContentsId, loadError) - return - } - this.sendGuestLoadFailure(browserTabId, loadError) - } - - private forwardOrQueuePermissionDenied( - guestWebContentsId: number, - event: PendingPermissionEvent - ): void { - const browserTabId = this.resolveBrowserTabIdForGuestWebContentsId(guestWebContentsId) - if (!browserTabId) { - const pending = this.pendingPermissionEventsByGuestId.get(guestWebContentsId) ?? [] - pending.push(event) - if (pending.length > 5) { - pending.shift() - } - this.pendingPermissionEventsByGuestId.set(guestWebContentsId, pending) - return - } - this.sendPermissionDenied(browserTabId, event) - } - - private flushPendingPermissionEvents(browserTabId: string, guestWebContentsId: number): void { - const pending = this.pendingPermissionEventsByGuestId.get(guestWebContentsId) - if (!pending?.length) { - return - } - this.pendingPermissionEventsByGuestId.delete(guestWebContentsId) - for (const event of pending) { - this.sendPermissionDenied(browserTabId, event) - } - } - - private sendPermissionDenied(browserTabId: string, event: PendingPermissionEvent): void { - const renderer = this.resolveRendererForBrowserTab(browserTabId) - if (!renderer) { - return - } - renderer.send('browser:permission-denied', { - browserPageId: browserTabId, - ...event - } satisfies BrowserPermissionDeniedEvent) - } - - private forwardOrQueuePopupEvent(guestWebContentsId: number, event: PendingPopupEvent): void { - const browserTabId = this.resolveBrowserTabIdForGuestWebContentsId(guestWebContentsId) - if (!browserTabId) { - const pending = this.pendingPopupEventsByGuestId.get(guestWebContentsId) ?? [] - pending.push(event) - if (pending.length > 5) { - pending.shift() - } - this.pendingPopupEventsByGuestId.set(guestWebContentsId, pending) - return - } - this.sendPopupEvent(browserTabId, event) - } - - private flushPendingPopupEvents(browserTabId: string, guestWebContentsId: number): void { - const pending = this.pendingPopupEventsByGuestId.get(guestWebContentsId) - if (!pending?.length) { - return - } - this.pendingPopupEventsByGuestId.delete(guestWebContentsId) - for (const event of pending) { - this.sendPopupEvent(browserTabId, event) - } - } - - private sendPopupEvent(browserTabId: string, event: PendingPopupEvent): void { - const renderer = this.resolveRendererForBrowserTab(browserTabId) - if (!renderer) { - return - } - renderer.send('browser:popup', { - browserPageId: browserTabId, - ...event - } satisfies BrowserPopupEvent) - } - - private bindDownloadToTab(downloadId: string, browserTabId: string): void { - const download = this.downloadsById.get(downloadId) - if (!download) { - return - } - download.browserTabId = browserTabId - download.rendererWebContentsId = this.rendererWebContentsIdByTabId.get(browserTabId) ?? null - } - - private flushPendingDownloadRequests(browserTabId: string, guestWebContentsId: number): void { - const pending = this.pendingDownloadIdsByGuestId.get(guestWebContentsId) - if (!pending?.length) { - return - } - this.pendingDownloadIdsByGuestId.delete(guestWebContentsId) - for (const downloadId of pending) { - this.bindDownloadToTab(downloadId, browserTabId) - this.flushDownloadSnapshot(downloadId) - } - } - - private flushDownloadSnapshot(downloadId: string): void { - const download = this.downloadsById.get(downloadId) - if (!download) { - return - } - this.sendDownloadStarted(downloadId) - if (download.receivedBytes > 0 || download.transientState) { - this.sendDownloadProgress(download.browserTabId, { - browserPageId: download.browserTabId ?? undefined, - downloadId: download.downloadId, - receivedBytes: download.receivedBytes, - totalBytes: download.totalBytes, - state: download.transientState - }) - } - if (download.terminalEvent) { - this.sendDownloadFinished(download.browserTabId, { - ...download.terminalEvent, - browserPageId: download.browserTabId ?? undefined - }) - this.downloadsById.delete(downloadId) - } - } - - private sendDownloadStarted(downloadId: string): void { - const download = this.downloadsById.get(downloadId) - if (!download?.browserTabId) { - return - } - if (download.startedSent) { - return - } - const renderer = this.resolveRendererForBrowserTab(download.browserTabId) - if (!renderer) { - return - } - renderer.send('browser:download-requested', { - browserPageId: download.browserTabId, - downloadId: download.downloadId, - origin: download.origin, - filename: download.filename, - totalBytes: download.totalBytes, - mimeType: download.mimeType, - savePath: download.savePath, - status: 'downloading' - } satisfies BrowserDownloadRequestedEvent) - download.startedSent = true - } - - private sendDownloadProgress( - browserTabId: string | null, - payload: BrowserDownloadProgressEvent - ): void { - if (!browserTabId) { - return - } - const renderer = this.resolveRendererForBrowserTab(browserTabId) - if (!renderer) { - return - } - renderer.send('browser:download-progress', payload) - } - - private sendDownloadFinished( - browserTabId: string | null, - payload: BrowserDownloadFinishedEvent - ): void { - if (!browserTabId) { - return - } - const renderer = this.resolveRendererForBrowserTab(browserTabId) - if (!renderer) { - return - } - renderer.send('browser:download-finished', payload) - } - - private async settleClientHostedDownload( - download: ActiveDownload, - status: BrowserDownloadFinishedEvent['status'], - failure: string | null - ): Promise { - const route = download.clientRoute - if (!route) { - return - } - if (status !== 'completed') { - download.clientRoute = null - await route.abort().catch(() => undefined) - this.finishDownloadInternal(download.downloadId, status, failure) - return - } - try { - // Why: the route stays on the record for the whole commit, which spans many round trips -- a - // cancel arriving mid-stream has to find something to abort or the bytes land anyway. - const remoteDestination = await route.complete(download.filename) - download.clientRoute = null - download.remoteDestination = remoteDestination - // Why: the staged copy is deleted, so a client save path would name a file that no longer exists. - download.savePath = '' - this.finishDownloadInternal(download.downloadId, 'completed', null) - } catch (error) { - download.clientRoute = null - if (download.terminalEvent) { - // A cancel already reported the outcome; this rejection is that cancel taking effect. - return - } - console.error('[browser-download] Failed to save download to the remote workspace:', error) - this.finishDownloadInternal( - download.downloadId, - 'failed', - 'Could not save the download to the remote workspace.' - ) - } - } - - private cancelDownloadInternal(downloadId: string, reason: string): void { - const download = this.downloadsById.get(downloadId) - if (!download) { - return - } - - if (download.cleanup) { - download.cleanup() - download.cleanup = null - } - const shouldSendCancel = !download.terminalEvent - - try { - download.item.cancel() - } catch { - // Why: cancel() can throw on an already-finalized item; best-effort since UI state is authoritative. - } - - if (shouldSendCancel) { - this.finishDownloadInternal(downloadId, 'canceled', reason || null) - return - } - - this.downloadsById.delete(downloadId) - } - - private finishDownloadInternal( - downloadId: string, - status: BrowserDownloadFinishedEvent['status'], - error: string | null - ): void { - const download = this.downloadsById.get(downloadId) - if (!download || download.terminalEvent) { - return - } - - if (download.cleanup) { - download.cleanup() - download.cleanup = null - } - browserDownloadDestinationReservations.release(download.reservationKey) - download.reservationKey = null - if (download.clientRoute) { - // Why: a cancel path can reach here before the relay settled; the staged copy must not survive. - void download.clientRoute.abort().catch(() => undefined) - download.clientRoute = null - } - const event: BrowserDownloadFinishedEvent = { - browserPageId: download.browserTabId ?? undefined, - downloadId: download.downloadId, - status, - savePath: download.savePath || null, - ...(download.remoteDestination ? { remoteDestination: download.remoteDestination } : {}), - error - } - download.terminalEvent = event - if (download.browserTabId) { - this.sendDownloadStarted(downloadId) - this.sendDownloadFinished(download.browserTabId, event) - this.downloadsById.delete(downloadId) - } - } - - private cancelPendingDownloadsForGuest(guestWebContentsId: number): void { - const pending = this.pendingDownloadIdsByGuestId.get(guestWebContentsId) - this.pendingDownloadIdsByGuestId.delete(guestWebContentsId) - if (!pending?.length) { - return - } - for (const downloadId of pending) { - const download = this.downloadsById.get(downloadId) - if (!download) { - continue - } - if (download.terminalEvent) { - this.downloadsById.delete(downloadId) - continue - } - this.cancelDownloadInternal(downloadId, 'Browser page closed before download could be shown.') - const afterCancel = this.downloadsById.get(downloadId) - if (afterCancel?.terminalEvent && !afterCancel.browserTabId) { - this.downloadsById.delete(downloadId) - } - } - } - - private getDownloadReceivedBytes(item: Electron.DownloadItem): number { - try { - return Math.max(0, item.getReceivedBytes()) - } catch { - return 0 - } - } - - private flushPendingLoadFailure(browserTabId: string, guestWebContentsId: number): void { - const pending = this.pendingLoadFailuresByGuestId.get(guestWebContentsId) - if (!pending) { - return - } - this.pendingLoadFailuresByGuestId.delete(guestWebContentsId) - this.sendGuestLoadFailure(browserTabId, pending) - } - - private sendGuestLoadFailure( - browserTabId: string, - loadError: { code: number; description: string; validatedUrl: string } - ): void { - const renderer = this.resolveRendererForBrowserTab(browserTabId) - if (!renderer) { - return - } - - // Why: redact Kagi session tokens before the renderer persists validatedUrl to disk. - renderer.send('browser:guest-load-failed', { - browserPageId: browserTabId, - loadError: { - ...loadError, - validatedUrl: redactKagiSessionToken(loadError.validatedUrl) - } - }) - } - - private openLinkInOrcaTab(browserTabId: string, rawUrl: string): boolean { - const renderer = this.resolveRendererForBrowserTab(browserTabId) - if (!renderer) { - return false - } - const normalizedUrl = normalizeBrowserNavigationUrl(rawUrl) - if (!normalizedUrl || normalizedUrl === ORCA_BROWSER_BLANK_URL) { - return false - } - // Why: only the renderer owns Orca's worktree/tab model; main forwards a validated URL, never letting guest content mutate it. - renderer.send('browser:open-link-in-orca-tab', { - browserPageId: browserTabId, - url: normalizedUrl - }) - return true - } -} +export class BrowserManager extends BrowserManagerFinal {} export const browserManager = new BrowserManager() export const browserCertificateTrustController = new BrowserCertificateTrustController({ diff --git a/src/main/cli/appimage-cache-layout.ts b/src/main/cli/appimage-cache-layout.ts new file mode 100644 index 00000000000..4c692a94a9d --- /dev/null +++ b/src/main/cli/appimage-cache-layout.ts @@ -0,0 +1,34 @@ +import { isAbsolute, join, relative, resolve, sep } from 'node:path' +import { LINUX_CLI_COMMAND_NAME } from './bundled-cli-launcher-path' + +const CACHE_KEY_PATTERN = /^[0-9a-f]{24}$/u + +export function isAppImageCacheKey(value: string): boolean { + return CACHE_KEY_PATTERN.test(value) +} + +export function resolveCachedAppImagePayloadRoot( + cacheRootPath: string, + candidatePath: string, + launcherName = LINUX_CLI_COMMAND_NAME +): string | null { + if (!isAbsolute(candidatePath)) { + return null + } + const resolvedCacheRoot = resolve(cacheRootPath) + const segments = relative(resolvedCacheRoot, resolve(candidatePath)).split(sep) + const [namespaceKey, generationKey, resources, bin, candidateLauncherName] = segments + if ( + segments.length !== 5 || + !namespaceKey || + !generationKey || + !isAppImageCacheKey(namespaceKey) || + !isAppImageCacheKey(generationKey) || + resources !== 'resources' || + bin !== 'bin' || + candidateLauncherName !== launcherName + ) { + return null + } + return join(resolvedCacheRoot, namespaceKey, generationKey) +} diff --git a/src/main/cli/appimage-extracted-root.test.ts b/src/main/cli/appimage-extracted-root.test.ts new file mode 100644 index 00000000000..dbc7c95613b --- /dev/null +++ b/src/main/cli/appimage-extracted-root.test.ts @@ -0,0 +1,376 @@ +import { existsSync } from 'node:fs' +import { chmod, mkdir, mkdtemp, readFile, readdir, rm, symlink, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { + ensureAppImageExtractedRoot, + getAppImageCacheRootPath, + isAppImageExtractedLauncherPath, + isAppImageExtractionComplete, + isAppImageInstalledLauncherOwnedBySibling, + resolveAppImageCacheKey, + resolveAppImageExtractedRoot +} from './appimage-extracted-root' +import { getAppImageActiveExtractionPath } from './appimage-extraction-pruning' +import { + publishAppImageLauncherEndpoint, + resolveAppImageStableLauncherPath +} from './appimage-stable-launcher' + +const created: string[] = [] + +afterEach(async () => { + await Promise.all(created.splice(0).map((path) => rm(path, { recursive: true, force: true }))) +}) + +async function makeFixture(): Promise<{ + root: string + appImagePath: string + cacheRootPath: string +}> { + const root = await mkdtemp(join(tmpdir(), 'orca-appimage-extract-')) + created.push(root) + const appImagePath = join(root, 'Orca.AppImage') + await writeFile(appImagePath, '#!/usr/bin/env bash\n', { encoding: 'utf8', mode: 0o755 }) + return { root, appImagePath, cacheRootPath: join(root, 'cache') } +} + +/** Stands in for the AppImage runtime, which writes ./squashfs-root under cwd. */ +async function writePayload(cwd: string, content = ''): Promise { + const launcherDir = join(cwd, 'squashfs-root', 'resources', 'bin') + await mkdir(launcherDir, { recursive: true }) + await writeFile(join(launcherDir, 'orca-ide'), content, { encoding: 'utf8', mode: 0o755 }) +} + +describe('appimage extracted root', () => { + it('derives the cache root from XDG_CACHE_HOME when set', () => { + const previous = process.env.XDG_CACHE_HOME + process.env.XDG_CACHE_HOME = '/xdg-cache' + try { + expect(getAppImageCacheRootPath('/home/u')).toBe(join('/xdg-cache', 'orca', 'appimage')) + } finally { + if (previous === undefined) { + delete process.env.XDG_CACHE_HOME + } else { + process.env.XDG_CACHE_HOME = previous + } + } + }) + + it('ignores a relative XDG_CACHE_HOME', () => { + const previous = process.env.XDG_CACHE_HOME + process.env.XDG_CACHE_HOME = 'relative-cache' + try { + expect(getAppImageCacheRootPath('/home/u')).toBe( + join('/home/u', '.cache', 'orca', 'appimage') + ) + } finally { + if (previous === undefined) { + delete process.env.XDG_CACHE_HOME + } else { + process.env.XDG_CACHE_HOME = previous + } + } + }) + + it('extracts once and reuses the payload on the next call', async () => { + const { appImagePath, cacheRootPath } = await makeFixture() + let extractCount = 0 + const runExtract = async (_path: string, cwd: string): Promise => { + extractCount += 1 + await writePayload(cwd) + } + + const first = await ensureAppImageExtractedRoot({ appImagePath, cacheRootPath, runExtract }) + const second = await ensureAppImageExtractedRoot({ appImagePath, cacheRootPath, runExtract }) + + expect(extractCount).toBe(1) + expect(second?.stableLauncherPath).toBe(first?.stableLauncherPath) + expect(isAppImageExtractionComplete(first!)).toBe(true) + }) + + // Why: an update replaces the file in place, so stat identity must change the key or the command + // would keep resolving through the previous version's payload. + it('keys the payload on file identity and metadata, not just path', async () => { + const { appImagePath, cacheRootPath } = await makeFixture() + const before = resolveAppImageCacheKey(appImagePath) + await writeFile(appImagePath, '#!/usr/bin/env bash\n# newer\n', { + encoding: 'utf8', + mode: 0o755 + }) + + expect(resolveAppImageCacheKey(appImagePath)).not.toBe(before) + expect(resolveAppImageExtractedRoot({ appImagePath, cacheRootPath })?.rootPath).toContain( + resolveAppImageCacheKey(appImagePath) as string + ) + }) + + // Why: `chmod +x` is what every AppImage user is told to run, and a backup restore or SELinux + // relabel does the same thing. Re-keying on that cost a full re-extraction of an unchanged payload. + it('does not re-key the payload when only inode metadata changes', async () => { + const { appImagePath } = await makeFixture() + const before = resolveAppImageCacheKey(appImagePath) + + await chmod(appImagePath, 0o700) + expect(resolveAppImageCacheKey(appImagePath)).toBe(before) + + await chmod(appImagePath, 0o755) + expect(resolveAppImageCacheKey(appImagePath)).toBe(before) + }) + + // Why: a crashed extraction must not leave a directory that later reads treat + // as a usable payload — the command would exec a path that does not exist. + it('publishes nothing when extraction fails partway', async () => { + const { appImagePath, cacheRootPath } = await makeFixture() + + const result = await ensureAppImageExtractedRoot({ + appImagePath, + cacheRootPath, + runExtract: async (_path, cwd) => { + await mkdir(join(cwd, 'squashfs-root'), { recursive: true }) + throw new Error('extraction interrupted') + } + }) + + expect(result).toBeNull() + await expect(readdir(cacheRootPath)).resolves.toEqual([]) + }) + + it('reports failure when the payload has no launcher', async () => { + const { appImagePath, cacheRootPath } = await makeFixture() + + const result = await ensureAppImageExtractedRoot({ + appImagePath, + cacheRootPath, + runExtract: async (_path, cwd) => { + await mkdir(join(cwd, 'squashfs-root'), { recursive: true }) + } + }) + + expect(result).toBeNull() + }) + + it('retains one retry payload when a foreign stable launcher blocks publication', async () => { + const { appImagePath, cacheRootPath } = await makeFixture() + const root = resolveAppImageExtractedRoot({ appImagePath, cacheRootPath })! + const launcherPath = resolveAppImageStableLauncherPath(cacheRootPath) + let extractionCount = 0 + await mkdir(dirname(launcherPath), { recursive: true }) + await writeFile(launcherPath, '#!/usr/bin/env bash\nprintf foreign\n', { mode: 0o755 }) + + const extract = async (_path: string, cwd: string): Promise => { + extractionCount += 1 + await writePayload(cwd) + } + const options = { appImagePath, cacheRootPath, runExtract: extract } + + await expect(ensureAppImageExtractedRoot(options)).resolves.toBeNull() + await expect(ensureAppImageExtractedRoot(options)).resolves.toBeNull() + expect(extractionCount).toBe(1) + expect(existsSync(root.rootPath)).toBe(true) + expect(existsSync(getAppImageActiveExtractionPath(root.rootPath))).toBe(false) + await expect(readFile(launcherPath, 'utf8')).resolves.toContain('foreign') + }) + + it.each(['directory', 'non-executable'] as const)( + 'rejects a %s launcher entry', + async (entryKind) => { + const { appImagePath, cacheRootPath } = await makeFixture() + + const result = await ensureAppImageExtractedRoot({ + appImagePath, + cacheRootPath, + runExtract: async (_path, cwd) => { + const launcherPath = join(cwd, 'squashfs-root', 'resources', 'bin', 'orca-ide') + if (entryKind === 'directory') { + await mkdir(launcherPath, { recursive: true }) + } else { + await mkdir(dirname(launcherPath), { recursive: true }) + await writeFile(launcherPath, '#!/usr/bin/env bash\n', { mode: 0o644 }) + } + } + }) + + expect(result).toBeNull() + } + ) + + it.skipIf(process.platform === 'win32')( + 'rejects a launcher symlink even when its target is executable', + async () => { + const { root, appImagePath, cacheRootPath } = await makeFixture() + const executable = join(root, 'foreign-launcher') + await writeFile(executable, '#!/usr/bin/env bash\n', { mode: 0o755 }) + + const result = await ensureAppImageExtractedRoot({ + appImagePath, + cacheRootPath, + runExtract: async (_path, cwd) => { + const launcherPath = join(cwd, 'squashfs-root', 'resources', 'bin', 'orca-ide') + await mkdir(dirname(launcherPath), { recursive: true }) + await symlink(executable, launcherPath) + } + }) + + expect(result).toBeNull() + } + ) + + it('replaces an incomplete exact extraction root', async () => { + const { appImagePath, cacheRootPath } = await makeFixture() + const root = resolveAppImageExtractedRoot({ appImagePath, cacheRootPath })! + const partialPath = join(root.rootPath, 'partial') + await mkdir(root.rootPath, { recursive: true }) + await writeFile(partialPath, 'interrupted') + + const result = await ensureAppImageExtractedRoot({ + appImagePath, + cacheRootPath, + runExtract: async (_path, cwd) => writePayload(cwd, 'recovered') + }) + + expect(result).toEqual(root) + await expect(readFile(root.payloadLauncherPath, 'utf8')).resolves.toBe('recovered') + expect(existsSync(partialPath)).toBe(false) + }) + + it('preserves the winner when concurrent calls repair an incomplete root', async () => { + const { appImagePath, cacheRootPath } = await makeFixture() + const root = resolveAppImageExtractedRoot({ appImagePath, cacheRootPath })! + await mkdir(root.rootPath, { recursive: true }) + await writeFile(join(root.rootPath, 'partial'), 'interrupted') + let readyCount = 0 + let release!: () => void + const bothReady = new Promise((resolve) => { + release = resolve + }) + const runExtract = async (_path: string, cwd: string): Promise => { + readyCount += 1 + await writePayload(cwd, `winner-${readyCount}`) + if (readyCount === 2) { + release() + } + await bothReady + } + + const results = await Promise.all([ + ensureAppImageExtractedRoot({ appImagePath, cacheRootPath, runExtract }), + ensureAppImageExtractedRoot({ appImagePath, cacheRootPath, runExtract }) + ]) + + expect(results).toEqual([root, root]) + expect(['winner-1', 'winner-2']).toContain(await readFile(root.payloadLauncherPath, 'utf8')) + }) + + it('retries with the current generation when the AppImage changes during extraction', async () => { + const { appImagePath, cacheRootPath } = await makeFixture() + const initialRoot = resolveAppImageExtractedRoot({ appImagePath, cacheRootPath })! + let extractCount = 0 + + const result = await ensureAppImageExtractedRoot({ + appImagePath, + cacheRootPath, + runExtract: async (_path, cwd) => { + extractCount += 1 + await writePayload(cwd, `generation-${extractCount}`) + if (extractCount === 1) { + await writeFile(appImagePath, '#!/usr/bin/env bash\n# replaced during extraction\n', { + encoding: 'utf8', + mode: 0o755 + }) + } + } + }) + + const currentRoot = resolveAppImageExtractedRoot({ appImagePath, cacheRootPath })! + expect(extractCount).toBe(2) + expect(result).toEqual(currentRoot) + expect(result?.rootPath).not.toBe(initialRoot.rootPath) + await expect(readFile(currentRoot.payloadLauncherPath, 'utf8')).resolves.toBe('generation-2') + expect(existsSync(initialRoot.rootPath)).toBe(false) + }) + + it('bounds retries when every extraction changes the AppImage generation', async () => { + const { appImagePath, cacheRootPath } = await makeFixture() + let extractCount = 0 + + const result = await ensureAppImageExtractedRoot({ + appImagePath, + cacheRootPath, + runExtract: async (_path, cwd) => { + extractCount += 1 + await writePayload(cwd) + await writeFile(appImagePath, '#'.repeat(extractCount + 1), { mode: 0o755 }) + } + }) + + expect(result).toBeNull() + expect(extractCount).toBe(2) + }) + + it('returns null for an AppImage that is not there', async () => { + const { root, cacheRootPath } = await makeFixture() + const missing = join(root, 'Absent.AppImage') + + expect(resolveAppImageCacheKey(missing)).toBeNull() + expect(resolveAppImageExtractedRoot({ appImagePath: missing, cacheRootPath })).toBeNull() + }) + + it('recognizes managed launchers across AppImage path namespaces', async () => { + const { root, appImagePath, cacheRootPath } = await makeFixture() + const current = resolveAppImageExtractedRoot({ appImagePath, cacheRootPath })! + const previousGeneration = join( + dirname(current.rootPath), + 'a'.repeat(24), + 'resources', + 'bin', + 'orca-ide' + ) + const otherAppImagePath = join(root, 'Other.AppImage') + await writeFile(otherAppImagePath, '#!/usr/bin/env bash\n', { mode: 0o755 }) + const other = resolveAppImageExtractedRoot({ + appImagePath: otherAppImagePath, + cacheRootPath + })! + + expect( + isAppImageExtractedLauncherPath({ appImagePath, cacheRootPath }, current.stableLauncherPath) + ).toBe(true) + expect( + isAppImageExtractedLauncherPath({ appImagePath, cacheRootPath }, previousGeneration) + ).toBe(true) + expect( + isAppImageExtractedLauncherPath({ appImagePath, cacheRootPath }, other.stableLauncherPath) + ).toBe(true) + expect( + isAppImageExtractedLauncherPath({ appImagePath, cacheRootPath }, other.payloadLauncherPath) + ).toBe(true) + expect( + isAppImageExtractedLauncherPath( + { appImagePath, cacheRootPath }, + join(root, 'foreign', 'resources', 'bin', 'orca-ide') + ) + ).toBe(false) + }) + + it('requires a sibling installed endpoint to target an executable payload', async () => { + const { appImagePath, cacheRootPath } = await makeFixture() + const siblingLauncher = join( + cacheRootPath, + 'a'.repeat(24), + 'b'.repeat(24), + 'resources', + 'bin', + 'orca-ide' + ) + publishAppImageLauncherEndpoint(cacheRootPath, 'installed', siblingLauncher) + const options = { appImagePath, cacheRootPath } + + expect(isAppImageInstalledLauncherOwnedBySibling(options)).toBe(false) + + await mkdir(dirname(siblingLauncher), { recursive: true }) + await writeFile(siblingLauncher, '#!/usr/bin/env bash\n', { mode: 0o755 }) + expect(isAppImageInstalledLauncherOwnedBySibling(options)).toBe(true) + }) +}) diff --git a/src/main/cli/appimage-extracted-root.ts b/src/main/cli/appimage-extracted-root.ts new file mode 100644 index 00000000000..36d3a8b5493 --- /dev/null +++ b/src/main/cli/appimage-extracted-root.ts @@ -0,0 +1,277 @@ +import { createHash } from 'node:crypto' +import { lstatSync, readlinkSync, statSync } from 'node:fs' +import { mkdir, mkdtemp, rename, rm, rmdir, writeFile } from 'node:fs/promises' +import { homedir } from 'node:os' +import { dirname, isAbsolute, join, resolve } from 'node:path' +import { runProcess } from '../../shared/child-process/run-process' +import { resolveCachedAppImagePayloadRoot } from './appimage-cache-layout' +import { removeExtractedAppImagePayload } from './appimage-payload-removal' +import { LINUX_CLI_COMMAND_NAME } from './bundled-cli-launcher-path' +import { + APPIMAGE_EXTRACTION_TIMEOUT_MS, + getAppImageActiveExtractionPath, + pruneAppImageExtractedRoots, + trackAppImageExtraction +} from './appimage-extraction-pruning' +import { + isAppImageStableLauncherReady, + publishAppImageLauncherEndpoint, + resolveAppImageLauncherEndpointPath, + resolveAppImageStableLauncherPath +} from './appimage-stable-launcher' + +const CACHE_DIR_SEGMENTS = ['orca', 'appimage'] as const +const EXTRACT_OUTPUT_DIR = 'squashfs-root' +const MAX_GENERATION_ATTEMPTS = 2 +const EXTRACTION_STAGING_PREFIX = '.extract-' + +export type AppImageExtractedRoot = { + rootPath: string + payloadLauncherPath: string + stableLauncherPath: string +} + +export type AppImageExtractionOptions = { + appImagePath: string + cacheRootPath?: string + runExtract?: (appImagePath: string, cwd: string) => Promise +} + +export function getAppImageCacheRootPath(homePath = homedir()): string { + const xdgCacheHome = process.env.XDG_CACHE_HOME + const cacheHome = + xdgCacheHome && isAbsolute(xdgCacheHome) ? xdgCacheHome : join(homePath, '.cache') + return join(cacheHome, ...CACHE_DIR_SEGMENTS) +} + +/** + * Identity of the AppImage's *content*, used to key its extracted generation. + * + * Deliberately excludes ctime: it changes on any inode metadata write — `chmod +x` (which every + * AppImage user is told to run), `chown`, an ACL or SELinux relabel, a backup restore — none of + * which alter a byte of the payload. Including it re-keyed the cache on those, costing a full + * ~519 MB re-extraction and a multi-second stall for nothing. An in-place content change moves + * mtime and almost always size; a replacement moves the inode. + */ +export function resolveAppImageCacheKey(appImagePath: string): string | null { + try { + const stats = statSync(appImagePath) + return digest(`${stats.dev}\0${stats.ino}\0${stats.size}\0${stats.mtimeMs}`) + } catch { + return null + } +} + +export function resolveAppImageExtractedRoot( + options: AppImageExtractionOptions +): AppImageExtractedRoot | null { + const cacheKey = resolveAppImageCacheKey(options.appImagePath) + if (!cacheKey) { + return null + } + const cacheRootPath = resolveAppImageCacheRootPath(options) + const rootPath = join(resolveAppImageNamespacePath(options), cacheKey) + return extractedRootAt(rootPath, cacheRootPath) +} + +export function isAppImageExtractedLauncherPath( + options: AppImageExtractionOptions, + candidatePath: string, + launcherName = LINUX_CLI_COMMAND_NAME +): boolean { + if (!isAbsolute(candidatePath)) { + return false + } + const cacheRootPath = resolveAppImageCacheRootPath(options) + if ( + launcherName === LINUX_CLI_COMMAND_NAME && + resolve(candidatePath) === resolveAppImageStableLauncherPath(cacheRootPath) + ) { + return true + } + + return resolveCachedAppImagePayloadRoot(cacheRootPath, candidatePath, launcherName) !== null +} + +export function isAppImageExtractionComplete(root: AppImageExtractedRoot): boolean { + return hasPayloadLauncher(root.rootPath) +} + +export function isAppImageInstalledLauncherCurrent(options: AppImageExtractionOptions): boolean { + const root = resolveAppImageExtractedRoot(options) + const cacheRootPath = resolveAppImageCacheRootPath(options) + if ( + !root || + !isAppImageStableLauncherReady(cacheRootPath) || + !hasPayloadLauncher(root.rootPath) + ) { + return false + } + const endpointPath = resolveAppImageLauncherEndpointPath(cacheRootPath, 'installed') + try { + return resolve(dirname(endpointPath), readlinkSync(endpointPath)) === root.payloadLauncherPath + } catch { + return false + } +} + +export function isAppImageInstalledLauncherOwnedBySibling( + options: AppImageExtractionOptions +): boolean { + const cacheRootPath = resolveAppImageCacheRootPath(options) + const endpointPath = resolveAppImageLauncherEndpointPath(cacheRootPath, 'installed') + try { + const targetPath = resolve(dirname(endpointPath), readlinkSync(endpointPath)) + const targetRoot = resolveCachedAppImagePayloadRoot(cacheRootPath, targetPath) + return ( + targetRoot !== null && + hasPayloadLauncher(targetRoot) && + dirname(targetRoot) !== resolveAppImageNamespacePath(options) + ) + } catch { + return false + } +} + +export async function ensureAppImageExtractedRoot( + options: AppImageExtractionOptions +): Promise { + for (let attempt = 0; attempt < MAX_GENERATION_ATTEMPTS; attempt += 1) { + const root = resolveAppImageExtractedRoot(options) + if (!root) { + return null + } + const complete = + isAppImageExtractionComplete(root) || (await extractAppImageGeneration(options, root)) + if (isCurrentGeneration(options, root)) { + if (!complete || !isAppImageExtractionComplete(root)) { + await cleanFailedEndpointPublication(root) + continue + } + const launcherPath = publishAppImageLauncherEndpoint( + resolveAppImageCacheRootPath(options), + 'installed', + root.payloadLauncherPath + ) + if (launcherPath === root.stableLauncherPath) { + await rm(getAppImageActiveExtractionPath(root.rootPath), { force: true }).catch(() => {}) + return root + } + } + await cleanFailedEndpointPublication(root) + } + return null +} + +async function cleanFailedEndpointPublication(root: AppImageExtractedRoot): Promise { + await rm(getAppImageActiveExtractionPath(root.rootPath), { force: true }).catch(() => {}) + await pruneAppImageExtractedRoots(root.rootPath) +} + +async function extractAppImageGeneration( + options: AppImageExtractionOptions, + root: AppImageExtractedRoot +): Promise { + const namespacePath = dirname(root.rootPath) + await mkdir(namespacePath, { recursive: true }) + const stagingPath = await mkdtemp(join(namespacePath, EXTRACTION_STAGING_PREFIX)) + const stopTracking = trackAppImageExtraction(stagingPath) + try { + await (options.runExtract ?? runAppImageExtract)(options.appImagePath, stagingPath) + const extractedPath = join(stagingPath, EXTRACT_OUTPUT_DIR) + if (!hasPayloadLauncher(extractedPath) || !isCurrentGeneration(options, root)) { + return false + } + await writeFile(getAppImageActiveExtractionPath(root.rootPath), '') + return await publishExtractedRoot(extractedPath, root) + } catch { + // A concurrent extractor may have published the same payload first. + return isAppImageExtractionComplete(root) + } finally { + stopTracking() + await removeExtractedAppImagePayload(stagingPath).catch(() => {}) + await rmdir(namespacePath).catch(() => {}) + } +} + +function digest(value: string): string { + return createHash('sha256').update(value).digest('hex').slice(0, 24) +} + +export function resolveAppImageNamespacePath(options: AppImageExtractionOptions): string { + return join(resolveAppImageCacheRootPath(options), digest(options.appImagePath)) +} + +export function resolveAppImageCacheRootPath(options: AppImageExtractionOptions): string { + return resolve(options.cacheRootPath ?? getAppImageCacheRootPath()) +} + +function extractedRootAt(rootPath: string, cacheRootPath: string): AppImageExtractedRoot { + return { + rootPath, + payloadLauncherPath: join(rootPath, 'resources', 'bin', LINUX_CLI_COMMAND_NAME), + stableLauncherPath: resolveAppImageStableLauncherPath(cacheRootPath) + } +} + +function isCurrentGeneration( + options: AppImageExtractionOptions, + root: AppImageExtractedRoot +): boolean { + return resolveAppImageExtractedRoot(options)?.rootPath === root.rootPath +} + +async function publishExtractedRoot( + extractedPath: string, + root: AppImageExtractedRoot +): Promise { + if ((await renameRoot(extractedPath, root.rootPath)) || isAppImageExtractionComplete(root)) { + return true + } + + // Claim the destination atomically so a raced complete winner can be restored. + const displacedPath = `${extractedPath}.displaced` + if (!(await renameRoot(root.rootPath, displacedPath))) { + return (await renameRoot(extractedPath, root.rootPath)) || isAppImageExtractionComplete(root) + } + if (hasPayloadLauncher(displacedPath)) { + return (await renameRoot(displacedPath, root.rootPath)) || isAppImageExtractionComplete(root) + } + await removeExtractedAppImagePayload(displacedPath) + return (await renameRoot(extractedPath, root.rootPath)) || isAppImageExtractionComplete(root) +} + +function hasPayloadLauncher(rootPath: string): boolean { + try { + const stats = lstatSync(join(rootPath, 'resources', 'bin', LINUX_CLI_COMMAND_NAME)) + return stats.isFile() && (stats.mode & 0o111) !== 0 + } catch { + return false + } +} + +async function renameRoot(sourcePath: string, destinationPath: string): Promise { + try { + await rename(sourcePath, destinationPath) + return true + } catch { + return false + } +} + +async function runAppImageExtract(appImagePath: string, cwd: string): Promise { + const result = await runProcess({ + program: appImagePath, + args: ['--appimage-extract'], + cwd, + timeoutMs: APPIMAGE_EXTRACTION_TIMEOUT_MS, + maxOutputBytes: 1024 * 1024, + terminationBarrier: true + }) + if (result.timedOut) { + throw new Error('AppImage extraction timed out.') + } + if (result.code !== 0) { + throw new Error(result.stderr.trim() || `AppImage extraction exited ${result.code ?? 'early'}.`) + } +} diff --git a/src/main/cli/appimage-extraction-pruning.test.ts b/src/main/cli/appimage-extraction-pruning.test.ts new file mode 100644 index 00000000000..4b5745d3619 --- /dev/null +++ b/src/main/cli/appimage-extraction-pruning.test.ts @@ -0,0 +1,221 @@ +import { existsSync } from 'node:fs' +import type * as NodeFsPromises from 'node:fs/promises' +import { mkdir, mkdtemp, readFile, readlink, rm, utimes, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { basename, dirname, join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' + +const publicationRace = vi.hoisted(() => ({ endpointPath: '', replacementTarget: '' })) + +vi.mock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + rename: async (source: string, destination: string) => { + if (source === publicationRace.endpointPath && publicationRace.replacementTarget) { + await actual.unlink(source) + await actual.symlink(publicationRace.replacementTarget, source) + publicationRace.replacementTarget = '' + } + return actual.rename(source, destination) + } + } +}) + +import { + ensureAppImageExtractedRoot, + resolveAppImageExtractedRoot +} from './appimage-extracted-root' +import { + getAppImageActiveExtractionPath, + pruneAppImageExtractedRoots, + removeAppImageInstalledPayloads +} from './appimage-extraction-pruning' +import { + publishAppImageLauncherEndpoint, + resolveAppImageLauncherEndpointPath +} from './appimage-stable-launcher' + +const created: string[] = [] + +afterEach(async () => { + publicationRace.endpointPath = '' + publicationRace.replacementTarget = '' + await Promise.all(created.splice(0).map((path) => rm(path, { recursive: true, force: true }))) +}) + +async function writePayload(rootPath: string, content = '#!/usr/bin/env bash\n'): Promise { + const launcherPath = join(rootPath, 'resources', 'bin', 'orca-ide') + await mkdir(dirname(launcherPath), { recursive: true }) + await writeFile(launcherPath, content, { mode: 0o755 }) + return launcherPath +} + +async function makeExtractionFixture() { + const root = await mkdtemp(join(tmpdir(), 'orca-appimage-pruning-')) + created.push(root) + const appImagePath = join(root, 'Orca.AppImage') + await writeFile(appImagePath, '#!/usr/bin/env bash\n', { mode: 0o755 }) + return { root, appImagePath, cacheRootPath: join(root, 'cache') } +} + +function cacheKeyApartFrom(...excluded: string[]): string { + return ( + ['a', 'b', 'c'].map((value) => value.repeat(24)).find((key) => !excluded.includes(key)) ?? + 'd'.repeat(24) + ) +} + +describe('AppImage extraction pruning', () => { + it('prunes stale generations without touching sibling namespaces', async () => { + const { appImagePath, cacheRootPath } = await makeExtractionFixture() + const keepRoot = resolveAppImageExtractedRoot({ appImagePath, cacheRootPath })! + const keepKey = basename(keepRoot.rootPath) + const staleRoot = join(dirname(keepRoot.rootPath), cacheKeyApartFrom(keepKey)) + const siblingRoot = join( + cacheRootPath, + cacheKeyApartFrom(basename(dirname(keepRoot.rootPath))), + 'd'.repeat(24) + ) + await Promise.all([ + mkdir(keepRoot.rootPath, { recursive: true }), + mkdir(staleRoot, { recursive: true }), + mkdir(siblingRoot, { recursive: true }) + ]) + + await pruneAppImageExtractedRoots(keepRoot.rootPath) + + expect(existsSync(keepRoot.rootPath)).toBe(true) + expect(existsSync(staleRoot)).toBe(false) + expect(existsSync(siblingRoot)).toBe(true) + }) + + it('a sibling installed endpoint does not displace the owner generation', async () => { + const { appImagePath, cacheRootPath } = await makeExtractionFixture() + const ownerRoot = resolveAppImageExtractedRoot({ appImagePath, cacheRootPath })! + const siblingRoot = join( + dirname(ownerRoot.rootPath), + cacheKeyApartFrom(basename(ownerRoot.rootPath)) + ) + const [ownerLauncher, siblingLauncher] = await Promise.all([ + writePayload(ownerRoot.rootPath, 'owner'), + writePayload(siblingRoot, 'installed') + ]) + publishAppImageLauncherEndpoint(cacheRootPath, 'installed', siblingLauncher) + + await pruneAppImageExtractedRoots(ownerRoot.rootPath) + + await expect(readFile(ownerLauncher, 'utf8')).resolves.toBe('owner') + await expect(readFile(siblingLauncher, 'utf8')).resolves.toBe('installed') + await expect( + readlink(resolveAppImageLauncherEndpointPath(cacheRootPath, 'installed')) + ).resolves.toBe(siblingLauncher) + }) + + it('preserves an active extraction during pruning', async () => { + const { appImagePath, cacheRootPath } = await makeExtractionFixture() + const root = resolveAppImageExtractedRoot({ appImagePath, cacheRootPath })! + let reportStarted!: (stagingPath: string) => void + let releaseExtraction!: () => void + const started = new Promise((resolve) => { + reportStarted = resolve + }) + const release = new Promise((resolve) => { + releaseExtraction = resolve + }) + const extraction = ensureAppImageExtractedRoot({ + appImagePath, + cacheRootPath, + runExtract: async (_path, cwd) => { + reportStarted(cwd) + await release + await writePayload(join(cwd, 'squashfs-root'), '') + } + }) + const stagingPath = await started + + try { + await pruneAppImageExtractedRoots(root.rootPath) + expect(existsSync(stagingPath)).toBe(true) + } finally { + releaseExtraction() + } + await expect(extraction).resolves.toEqual(root) + }) + + it('retains recent cross-process staging and reclaims stale staging', async () => { + const { appImagePath, cacheRootPath } = await makeExtractionFixture() + const root = resolveAppImageExtractedRoot({ appImagePath, cacheRootPath })! + const namespacePath = dirname(root.rootPath) + const recentStaging = join(namespacePath, '.extract-recent') + const staleStaging = join(namespacePath, '.extract-stale') + await Promise.all([ + mkdir(root.rootPath, { recursive: true }), + mkdir(recentStaging, { recursive: true }), + mkdir(staleStaging, { recursive: true }) + ]) + await utimes(staleStaging, 0, 0) + + await pruneAppImageExtractedRoots(root.rootPath) + + expect(existsSync(recentStaging)).toBe(true) + expect(existsSync(staleStaging)).toBe(false) + }) + + it('preserves a recent active generation marker and reclaims a stale marker', async () => { + const { appImagePath, cacheRootPath } = await makeExtractionFixture() + const keepRoot = resolveAppImageExtractedRoot({ appImagePath, cacheRootPath })! + const keepKey = basename(keepRoot.rootPath) + const recentRoot = join(dirname(keepRoot.rootPath), cacheKeyApartFrom(keepKey)) + const staleRoot = join( + dirname(keepRoot.rootPath), + cacheKeyApartFrom(keepKey, basename(recentRoot)) + ) + const recentMarker = getAppImageActiveExtractionPath(recentRoot) + const staleMarker = getAppImageActiveExtractionPath(staleRoot) + await Promise.all([ + mkdir(keepRoot.rootPath, { recursive: true }), + mkdir(recentRoot, { recursive: true }), + mkdir(staleRoot, { recursive: true }) + ]) + await Promise.all([writeFile(recentMarker, ''), writeFile(staleMarker, '')]) + await utimes(staleMarker, 0, 0) + + await pruneAppImageExtractedRoots(keepRoot.rootPath) + + expect(existsSync(recentRoot)).toBe(true) + expect(existsSync(recentMarker)).toBe(true) + expect(existsSync(staleRoot)).toBe(false) + expect(existsSync(staleMarker)).toBe(false) + }) + + it('tolerates a missing cache namespace', async () => { + const { root } = await makeExtractionFixture() + + await expect( + pruneAppImageExtractedRoots(join(root, 'never-made', 'a'.repeat(24), 'b'.repeat(24))) + ).resolves.toBeUndefined() + }) + + it.skipIf(process.platform === 'win32')( + 'restores a sibling endpoint that wins the uninstall rename race', + async () => { + const cacheRootPath = await mkdtemp(join(tmpdir(), 'orca-appimage-pruning-')) + created.push(cacheRootPath) + const ownerNamespace = join(cacheRootPath, 'a'.repeat(24)) + const siblingNamespace = join(cacheRootPath, 'b'.repeat(24)) + const ownerLauncher = await writePayload(join(ownerNamespace, 'c'.repeat(24))) + const siblingLauncher = await writePayload(join(siblingNamespace, 'd'.repeat(24))) + const endpointPath = resolveAppImageLauncherEndpointPath(cacheRootPath, 'installed') + publishAppImageLauncherEndpoint(cacheRootPath, 'installed', ownerLauncher) + publicationRace.endpointPath = endpointPath + publicationRace.replacementTarget = siblingLauncher + + await removeAppImageInstalledPayloads(ownerNamespace) + + await expect(readlink(endpointPath)).resolves.toBe(siblingLauncher) + expect(existsSync(ownerNamespace)).toBe(false) + expect(existsSync(siblingLauncher)).toBe(true) + } + ) +}) diff --git a/src/main/cli/appimage-extraction-pruning.ts b/src/main/cli/appimage-extraction-pruning.ts new file mode 100644 index 00000000000..b491982129d --- /dev/null +++ b/src/main/cli/appimage-extraction-pruning.ts @@ -0,0 +1,153 @@ +import { randomUUID } from 'node:crypto' +import { existsSync } from 'node:fs' +import type { Dirent } from 'node:fs' +import { lstat, readlink, readdir, rename, rmdir, symlink, unlink } from 'node:fs/promises' +import { basename, dirname, join, resolve } from 'node:path' +import { isAppImageCacheKey, resolveCachedAppImagePayloadRoot } from './appimage-cache-layout' +import { removeExtractedAppImagePayload } from './appimage-payload-removal' +import { + removeAppImageLegacyLiveEndpoint, + resolveAppImageLauncherEndpointPath +} from './appimage-stable-launcher' + +const EXTRACTION_STAGING_PREFIX = '.extract-' +const ACTIVE_EXTRACTION_PREFIX = '.active-' +export const APPIMAGE_EXTRACTION_TIMEOUT_MS = 300_000 +// Cross-process extractors are bounded at five minutes; retain a second window before cleanup. +const STALE_EXTRACTION_GRACE_MS = APPIMAGE_EXTRACTION_TIMEOUT_MS * 2 + +const activeExtractionPaths = new Set() + +export function trackAppImageExtraction(stagingPath: string): () => void { + activeExtractionPaths.add(stagingPath) + return () => activeExtractionPaths.delete(stagingPath) +} + +export function getAppImageActiveExtractionPath(rootPath: string): string { + return join(dirname(rootPath), `${ACTIVE_EXTRACTION_PREFIX}${basename(rootPath)}`) +} + +export async function pruneAppImageExtractedRoots(keepRootPath: string): Promise { + const resolvedKeepRoot = resolve(keepRootPath) + const namespacePath = dirname(resolvedKeepRoot) + const cacheRootPath = dirname(namespacePath) + const protectedRoots = new Set([resolvedKeepRoot]) + const installedRoot = await resolveInstalledRoot(cacheRootPath) + if (installedRoot && dirname(installedRoot) === namespacePath) { + protectedRoots.add(installedRoot) + } + await pruneNamespace(namespacePath, protectedRoots) + await rmdir(namespacePath).catch(() => {}) +} + +export async function removeAppImageInstalledPayloads(namespacePath: string): Promise { + const resolvedNamespace = resolve(namespacePath) + const cacheRootPath = dirname(resolvedNamespace) + removeAppImageLegacyLiveEndpoint(cacheRootPath) + if (await removeInstalledEndpoint(cacheRootPath, resolvedNamespace)) { + await pruneNamespace(resolvedNamespace, new Set()) + await rmdir(resolvedNamespace).catch(() => {}) + } +} + +async function resolveInstalledRoot(cacheRootPath: string): Promise { + const endpointPath = resolveAppImageLauncherEndpointPath(cacheRootPath, 'installed') + try { + const targetPath = resolve(dirname(endpointPath), await readlink(endpointPath)) + return resolveCachedAppImagePayloadRoot(cacheRootPath, targetPath) + } catch { + return null + } +} + +async function removeInstalledEndpoint( + cacheRootPath: string, + namespacePath: string +): Promise { + const endpointPath = resolveAppImageLauncherEndpointPath(cacheRootPath, 'installed') + if (!(await endpointTargetsNamespace(cacheRootPath, endpointPath, namespacePath))) { + return true + } + + const displacedPath = join( + dirname(endpointPath), + `.orca-preserved-installed-${process.pid}-${randomUUID()}` + ) + try { + await rename(endpointPath, displacedPath) + } catch { + return !(await endpointTargetsNamespace(cacheRootPath, endpointPath, namespacePath)) + } + + if (await endpointTargetsNamespace(cacheRootPath, displacedPath, namespacePath)) { + await unlink(displacedPath).catch(() => {}) + return true + } + + try { + await symlink(await readlink(displacedPath), endpointPath) + await unlink(displacedPath) + } catch {} + return !(await endpointTargetsNamespace(cacheRootPath, endpointPath, namespacePath)) +} + +async function endpointTargetsNamespace( + cacheRootPath: string, + endpointPath: string, + namespacePath: string +): Promise { + try { + const targetPath = resolve(dirname(endpointPath), await readlink(endpointPath)) + const targetRoot = resolveCachedAppImagePayloadRoot(cacheRootPath, targetPath) + return targetRoot !== null && dirname(targetRoot) === namespacePath + } catch { + return false + } +} + +async function pruneNamespace( + namespacePath: string, + protectedRoots: ReadonlySet +): Promise { + let entries: Dirent[] + try { + entries = await readdir(namespacePath, { withFileTypes: true }) + } catch { + return + } + + for (const entry of entries) { + const entryPath = join(namespacePath, entry.name) + if ( + entry.name.startsWith(EXTRACTION_STAGING_PREFIX) || + entry.name.startsWith(ACTIVE_EXTRACTION_PREFIX) + ) { + if (activeExtractionPaths.has(entryPath) || !(await isOlderThanGrace(entryPath))) { + continue + } + } else if (!isAppImageCacheKey(entry.name)) { + continue + } else if ( + protectedRoots.has(resolve(entryPath)) || + (existsSync(getAppImageActiveExtractionPath(entryPath)) && + !(await isOlderThanGrace(getAppImageActiveExtractionPath(entryPath)))) + ) { + continue + } + // Best effort, but never silent: a swallowed failure here leaks a whole payload generation. + await removeExtractedAppImagePayload(entryPath).catch((error: unknown) => { + console.warn( + `[cli] could not reclaim AppImage payload ${entryPath}:`, + error instanceof Error ? error.message : error + ) + }) + } +} + +async function isOlderThanGrace(candidatePath: string): Promise { + try { + return Date.now() - (await lstat(candidatePath)).mtimeMs >= STALE_EXTRACTION_GRACE_MS + } catch { + return true + } +} diff --git a/src/main/cli/appimage-payload-removal.reentrancy.test.ts b/src/main/cli/appimage-payload-removal.reentrancy.test.ts new file mode 100644 index 00000000000..ebf9c399c9e --- /dev/null +++ b/src/main/cli/appimage-payload-removal.reentrancy.test.ts @@ -0,0 +1,65 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +// Why a mocked rm: the property under test is the ORDER in which overlapping removals settle, which +// real filesystem timing cannot pin down. Deferreds make the interleaving exact. +const { rmMock } = vi.hoisted(() => ({ rmMock: vi.fn() })) +vi.mock('node:fs/promises', () => ({ rm: rmMock })) + +const originalNoAsar = process.noAsar + +afterEach(() => { + process.noAsar = originalNoAsar + vi.resetModules() +}) + +function deferred(): { promise: Promise; resolve: () => void } { + let resolve!: () => void + const promise = new Promise((r) => { + resolve = r + }) + return { promise, resolve } +} + +describe('removeExtractedAppImagePayload reentrancy', () => { + // The hazard is the FIRST removal settling while a later one is still running: a naive + // save/restore would hand the shim back and silently leak the rest of the second removal. + it('keeps asar interception disabled while a later removal is still running', async () => { + process.noAsar = false + const first = deferred() + const second = deferred() + rmMock.mockReset() + rmMock.mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise) + const { removeExtractedAppImagePayload } = await import('./appimage-payload-removal') + + const firstCall = removeExtractedAppImagePayload('/cache/gen-a') + const secondCall = removeExtractedAppImagePayload('/cache/gen-b') + expect(process.noAsar).toBe(true) + + first.resolve() + await firstCall + // gen-b is still being removed: handing the shim back here is exactly the leak. + expect(process.noAsar).toBe(true) + + second.resolve() + await secondCall + expect(process.noAsar).toBe(false) + }) + + it('keeps the flag held when the first removal rejects mid-overlap', async () => { + process.noAsar = false + const second = deferred() + rmMock.mockReset() + rmMock.mockRejectedValueOnce(new Error('EACCES')).mockReturnValueOnce(second.promise) + const { removeExtractedAppImagePayload } = await import('./appimage-payload-removal') + + const firstCall = removeExtractedAppImagePayload('/cache/gen-a') + const secondCall = removeExtractedAppImagePayload('/cache/gen-b') + + await expect(firstCall).rejects.toThrow('EACCES') + expect(process.noAsar).toBe(true) + + second.resolve() + await secondCall + expect(process.noAsar).toBe(false) + }) +}) diff --git a/src/main/cli/appimage-payload-removal.test.ts b/src/main/cli/appimage-payload-removal.test.ts new file mode 100644 index 00000000000..caa3ee33c1c --- /dev/null +++ b/src/main/cli/appimage-payload-removal.test.ts @@ -0,0 +1,66 @@ +import { mkdtemp, mkdir, writeFile } from 'node:fs/promises' +import { existsSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { removeExtractedAppImagePayload } from './appimage-payload-removal' + +const originalNoAsar = process.noAsar + +afterEach(() => { + process.noAsar = originalNoAsar +}) + +async function makePayloadTree(): Promise { + const root = await mkdtemp(join(tmpdir(), 'orca-payload-removal-')) + await mkdir(join(root, 'resources'), { recursive: true }) + // The real leak: Electron's fs patch reports a *.asar file as a directory. + await writeFile(join(root, 'resources', 'app.asar'), 'asar-payload') + await writeFile(join(root, 'AppRun'), '#!/bin/sh\n') + return root +} + +describe('removeExtractedAppImagePayload', () => { + it('removes a payload tree containing an asar file', async () => { + const root = await makePayloadTree() + await removeExtractedAppImagePayload(root) + expect(existsSync(root)).toBe(false) + }) + + it('disables asar interception for the removal', async () => { + const root = await makePayloadTree() + let observed: boolean | undefined + const originalRealpath = process.noAsar + Object.defineProperty(process, 'noAsar', { + configurable: true, + get: () => observed ?? originalRealpath, + set: (value: boolean) => { + observed ??= value + } + }) + try { + await removeExtractedAppImagePayload(root) + } finally { + Object.defineProperty(process, 'noAsar', { + configurable: true, + writable: true, + value: originalRealpath + }) + } + expect(observed).toBe(true) + }) + + it('restores the previous asar setting after a failure', async () => { + process.noAsar = false + await expect( + removeExtractedAppImagePayload(join(tmpdir(), 'orca-missing', 'nested', '\0invalid')) + ).rejects.toThrow() + expect(process.noAsar).toBe(false) + }) + + it('is a no-op for a path that does not exist', async () => { + await expect( + removeExtractedAppImagePayload(join(tmpdir(), 'orca-payload-removal-absent')) + ).resolves.toBeUndefined() + }) +}) diff --git a/src/main/cli/appimage-payload-removal.ts b/src/main/cli/appimage-payload-removal.ts new file mode 100644 index 00000000000..86a6a326cee --- /dev/null +++ b/src/main/cli/appimage-payload-removal.ts @@ -0,0 +1,35 @@ +import { rm } from 'node:fs/promises' + +// Why a counter and not a saved value: `process.noAsar` is process-wide, so two overlapping +// removals would race — the first to settle would restore the shim while the second is still +// running, and the rest of that removal would silently leak again. Removals are sequential today; +// this keeps that a property of the module rather than of its callers. +let activeRemovals = 0 +// Captured once when the outermost removal starts; `process.noAsar` is typed boolean, and an +// unset flag is falsy, so restoring `false` is equivalent to restoring `undefined`. +let asarBeforeOutermostRemoval = false + +/** + * Removes an extracted AppImage payload tree. + * + * Why not a plain recursive `rm`: Electron patches `fs` so a `*.asar` file reports + * `isDirectory() === true`. A recursive remove then tries to `rmdir` a real file, fails with + * ENOTEMPTY, and strands the ~105 MB `resources/app.asar` of every superseded generation. + * `process.noAsar` restores real filesystem semantics; the window is ~33 ms for a full 519 MB + * generation, and nothing in the registration path reads asar content inside it. + */ +export async function removeExtractedAppImagePayload(targetPath: string): Promise { + if (activeRemovals === 0) { + asarBeforeOutermostRemoval = process.noAsar === true + } + activeRemovals += 1 + process.noAsar = true + try { + await rm(targetPath, { recursive: true, force: true }) + } finally { + activeRemovals -= 1 + if (activeRemovals === 0) { + process.noAsar = asarBeforeOutermostRemoval + } + } +} diff --git a/src/main/cli/appimage-registration-lock.test.ts b/src/main/cli/appimage-registration-lock.test.ts new file mode 100644 index 00000000000..f32cf7d5b0d --- /dev/null +++ b/src/main/cli/appimage-registration-lock.test.ts @@ -0,0 +1,51 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +const { lockMock, mkdirMock } = vi.hoisted(() => ({ lockMock: vi.fn(), mkdirMock: vi.fn() })) + +vi.mock('proper-lockfile', () => ({ lock: lockMock })) +vi.mock('node:fs/promises', () => ({ mkdir: mkdirMock })) + +afterEach(() => { + vi.resetModules() +}) + +async function load() { + mkdirMock.mockReset().mockResolvedValue(undefined) + return import('./appimage-registration-lock') +} + +describe('withAppImageRegistrationLock', () => { + it('bounds the wait with a wall-clock deadline, not just an attempt count', async () => { + lockMock.mockReset().mockResolvedValue(vi.fn().mockResolvedValue(undefined)) + const { withAppImageRegistrationLock } = await load() + + await withAppImageRegistrationLock('/cache/orca/appimage', async () => 'done') + + const options = lockMock.mock.calls[0][1] + // Why: `retries` alone caps attempts, not elapsed time — 1000 x 1s is ~16 minutes. + expect(options.retries.maxRetryTime).toBeGreaterThan(0) + expect(options.retries.maxRetryTime).toBeLessThanOrEqual(options.stale) + }) + + it('reports a wedged holder with a remedy instead of hanging', async () => { + lockMock.mockReset().mockRejectedValue(Object.assign(new Error('ELOCKED'), { code: 'ELOCKED' })) + const { withAppImageRegistrationLock } = await load() + + await expect( + withAppImageRegistrationLock('/cache/orca/appimage', async () => 'done') + ).rejects.toThrow(/Timed out waiting for another Orca process[\s\S]*remove .*\.lock/) + }) + + it('releases the lock when the operation throws', async () => { + const release = vi.fn().mockResolvedValue(undefined) + lockMock.mockReset().mockResolvedValue(release) + const { withAppImageRegistrationLock } = await load() + + await expect( + withAppImageRegistrationLock('/cache/orca/appimage', async () => { + throw new Error('boom') + }) + ).rejects.toThrow('boom') + expect(release).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/main/cli/appimage-registration-lock.ts b/src/main/cli/appimage-registration-lock.ts new file mode 100644 index 00000000000..7c6350e249f --- /dev/null +++ b/src/main/cli/appimage-registration-lock.ts @@ -0,0 +1,48 @@ +import { mkdir } from 'node:fs/promises' +import { join } from 'node:path' +import { lock } from 'proper-lockfile' +import { APPIMAGE_EXTRACTION_TIMEOUT_MS } from './appimage-extraction-pruning' + +const LOCK_TARGET_NAME = '.cli-registration' +const LOCK_STALE_MS = APPIMAGE_EXTRACTION_TIMEOUT_MS * 3 +// Why a wall-clock deadline: `retries` alone bounds the attempt count, not the wait — 1000 attempts +// at up to 1s each let an IPC-driven registration hang ~16 minutes against a wedged holder with no +// feedback. A legitimate holder is bounded by the extraction timeout, so anything past that plus +// slack is wedged, and failing with a message beats hanging. +const LOCK_ACQUIRE_DEADLINE_MS = APPIMAGE_EXTRACTION_TIMEOUT_MS + 30_000 +const LOCK_RETRIES = { + retries: 1_000, + factor: 1.2, + minTimeout: 25, + maxTimeout: 1_000, + randomize: true, + maxRetryTime: LOCK_ACQUIRE_DEADLINE_MS +} + +export async function withAppImageRegistrationLock( + cacheRootPath: string, + operation: () => Promise +): Promise { + await mkdir(cacheRootPath, { recursive: true, mode: 0o700 }) + let release: () => Promise + try { + release = await lock(join(cacheRootPath, LOCK_TARGET_NAME), { + realpath: false, + retries: LOCK_RETRIES, + stale: LOCK_STALE_MS, + update: APPIMAGE_EXTRACTION_TIMEOUT_MS / 10 + }) + } catch (error) { + throw new Error( + `Timed out waiting for another Orca process to finish CLI registration ` + + `(waited ${Math.round(LOCK_ACQUIRE_DEADLINE_MS / 1000)}s). ` + + `If no other Orca is running, remove ${join(cacheRootPath, LOCK_TARGET_NAME)}.lock and retry.`, + { cause: error } + ) + } + try { + return await operation() + } finally { + await release() + } +} diff --git a/src/main/cli/appimage-stable-launcher.test.ts b/src/main/cli/appimage-stable-launcher.test.ts new file mode 100644 index 00000000000..b7830443b4b --- /dev/null +++ b/src/main/cli/appimage-stable-launcher.test.ts @@ -0,0 +1,181 @@ +import { existsSync } from 'node:fs' +import type * as NodeFs from 'node:fs' +import { mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { runProcess } from '../../shared/child-process/run-process' + +const { filePublicationFailures } = vi.hoisted(() => ({ + filePublicationFailures: { copy: 0, link: 0, replaceBeforeRename: '' } +})) + +vi.mock('node:fs', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + copyFileSync: (...args: Parameters) => { + if (filePublicationFailures.copy > 0) { + filePublicationFailures.copy -= 1 + throw Object.assign(new Error('copy unsupported'), { code: 'ENOTSUP' }) + } + return actual.copyFileSync(...args) + }, + linkSync: (...args: Parameters) => { + if (filePublicationFailures.link > 0) { + filePublicationFailures.link -= 1 + throw Object.assign(new Error('link unsupported'), { code: 'ENOTSUP' }) + } + return actual.linkSync(...args) + }, + renameSync: (...args: Parameters) => { + if (filePublicationFailures.replaceBeforeRename) { + actual.writeFileSync(args[0], filePublicationFailures.replaceBeforeRename, { mode: 0o755 }) + filePublicationFailures.replaceBeforeRename = '' + } + return actual.renameSync(...args) + } + } +}) +import { + publishAppImageLauncherEndpoint, + resolveAppImageLauncherEndpointPath, + resolveAppImageStableLauncherPath +} from './appimage-stable-launcher' + +const created: string[] = [] + +afterEach(async () => { + filePublicationFailures.copy = 0 + filePublicationFailures.link = 0 + filePublicationFailures.replaceBeforeRename = '' + await Promise.all(created.splice(0).map((path) => rm(path, { recursive: true, force: true }))) +}) + +async function makeFixture(): Promise { + const cacheRootPath = await mkdtemp(join(tmpdir(), 'orca-stable-appimage-launcher-')) + created.push(cacheRootPath) + return cacheRootPath +} + +async function writeLauncher(path: string, output: string): Promise { + await mkdir(dirname(path), { recursive: true }) + await writeFile(path, `#!/usr/bin/env bash\nprintf '${output}'`, { mode: 0o755 }) +} + +describe.skipIf(process.platform === 'win32')('AppImage stable launcher', () => { + it('uses only the installed payload and ignores a legacy live endpoint', async () => { + const cacheRootPath = await makeFixture() + const livePath = join(cacheRootPath, 'payloads', 'live') + const installedPath = join(cacheRootPath, 'payloads', 'installed') + await Promise.all([writeLauncher(livePath, 'live'), writeLauncher(installedPath, 'installed')]) + + const launcherPath = publishAppImageLauncherEndpoint(cacheRootPath, 'installed', installedPath)! + await symlink(livePath, resolveAppImageLauncherEndpointPath(cacheRootPath, 'live')) + await expect( + runProcess({ program: launcherPath, args: [], timeoutMs: 3_000 }) + ).resolves.toMatchObject({ code: 0, stdout: 'installed' }) + expect(await readFile(launcherPath, 'utf8')).not.toContain('/live') + }) + + it('observes an endpoint published after the wrapper starts', async () => { + const cacheRootPath = await makeFixture() + const missingPath = join(cacheRootPath, 'payloads', 'missing') + const readyPath = join(cacheRootPath, 'payloads', 'ready') + const launcherPath = publishAppImageLauncherEndpoint(cacheRootPath, 'installed', missingPath)! + const invocation = runProcess({ program: launcherPath, args: [], timeoutMs: 3_000 }) + setTimeout(() => { + void writeLauncher(readyPath, 'ready').then(() => { + publishAppImageLauncherEndpoint(cacheRootPath, 'installed', readyPath) + }) + }, 100) + + await expect(invocation).resolves.toMatchObject({ code: 0, stdout: 'ready' }) + }) + + it('atomically upgrades a stale marker-owned launcher', async () => { + const cacheRootPath = await makeFixture() + const targetPath = join(cacheRootPath, 'payloads', 'target') + await writeLauncher(targetPath, 'target') + const launcherPath = publishAppImageLauncherEndpoint(cacheRootPath, 'installed', targetPath)! + const marker = (await readFile(launcherPath, 'utf8')).split('\n')[1] + await writeFile(launcherPath, `#!/usr/bin/env bash\n${marker}\nprintf stale`, { mode: 0o755 }) + + expect(publishAppImageLauncherEndpoint(cacheRootPath, 'installed', targetPath)).toBe( + launcherPath + ) + expect(await readFile(launcherPath, 'utf8')).toContain('launcher_dir=') + await expect( + runProcess({ program: launcherPath, args: [], timeoutMs: 3_000 }) + ).resolves.toMatchObject({ code: 0, stdout: 'target' }) + }) + + it('publishes on a cache filesystem without hard-link support', async () => { + const cacheRootPath = await makeFixture() + const targetPath = join(cacheRootPath, 'payloads', 'target') + await writeLauncher(targetPath, 'target') + filePublicationFailures.link = 1 + + const launcherPath = publishAppImageLauncherEndpoint(cacheRootPath, 'installed', targetPath) + + expect(launcherPath).toBe(resolveAppImageStableLauncherPath(cacheRootPath)) + await expect(readFile(launcherPath!, 'utf8')).resolves.toContain('launcher_dir=') + }) + + it('restores a displaced owned launcher when its replacement cannot be published', async () => { + const cacheRootPath = await makeFixture() + const targetPath = join(cacheRootPath, 'payloads', 'target') + await writeLauncher(targetPath, 'target') + const launcherPath = publishAppImageLauncherEndpoint(cacheRootPath, 'installed', targetPath)! + const marker = (await readFile(launcherPath, 'utf8')).split('\n')[1] + const staleContent = `#!/usr/bin/env bash\n${marker}\nprintf stale` + await writeFile(launcherPath, staleContent, { mode: 0o755 }) + filePublicationFailures.link = 2 + filePublicationFailures.copy = 2 + + expect(publishAppImageLauncherEndpoint(cacheRootPath, 'installed', targetPath)).toBeNull() + await expect(readFile(launcherPath, 'utf8')).resolves.toBe(staleContent) + }) + + it('restores a displaced foreign launcher when hard links are unsupported', async () => { + const cacheRootPath = await makeFixture() + const targetPath = join(cacheRootPath, 'payloads', 'target') + await writeLauncher(targetPath, 'target') + const launcherPath = publishAppImageLauncherEndpoint(cacheRootPath, 'installed', targetPath)! + const marker = (await readFile(launcherPath, 'utf8')).split('\n')[1] + await writeFile(launcherPath, `#!/usr/bin/env bash\n${marker}\nprintf stale`, { mode: 0o755 }) + const foreignContent = '#!/usr/bin/env bash\nprintf foreign' + filePublicationFailures.replaceBeforeRename = foreignContent + filePublicationFailures.link = 2 + + expect(publishAppImageLauncherEndpoint(cacheRootPath, 'installed', targetPath)).toBeNull() + await expect(readFile(launcherPath, 'utf8')).resolves.toBe(foreignContent) + }) + + it('preserves a foreign launcher and declines endpoint publication', async () => { + const cacheRootPath = await makeFixture() + const launcherPath = resolveAppImageStableLauncherPath(cacheRootPath) + const endpointPath = resolveAppImageLauncherEndpointPath(cacheRootPath, 'installed') + await mkdir(dirname(launcherPath), { recursive: true }) + await writeFile(launcherPath, '#!/usr/bin/env bash\nprintf foreign', { mode: 0o755 }) + + expect( + publishAppImageLauncherEndpoint(cacheRootPath, 'installed', join(cacheRootPath, 'target')) + ).toBeNull() + await expect(readFile(launcherPath, 'utf8')).resolves.toContain('foreign') + expect(existsSync(endpointPath)).toBe(false) + }) + + it('preserves an oversized foreign launcher without treating its marker as ownership', async () => { + const cacheRootPath = await makeFixture() + const launcherPath = resolveAppImageStableLauncherPath(cacheRootPath) + const content = `#!/usr/bin/env bash\n# orca-appimage-stable-launcher\n${'x'.repeat(20_000)}` + await mkdir(dirname(launcherPath), { recursive: true }) + await writeFile(launcherPath, content, { mode: 0o755 }) + + expect( + publishAppImageLauncherEndpoint(cacheRootPath, 'installed', join(cacheRootPath, 'target')) + ).toBeNull() + await expect(readFile(launcherPath, 'utf8')).resolves.toBe(content) + }) +}) diff --git a/src/main/cli/appimage-stable-launcher.ts b/src/main/cli/appimage-stable-launcher.ts new file mode 100644 index 00000000000..9494479abd8 --- /dev/null +++ b/src/main/cli/appimage-stable-launcher.ts @@ -0,0 +1,241 @@ +import { randomUUID } from 'node:crypto' +import { + closeSync, + constants, + copyFileSync, + fstatSync, + linkSync, + lstatSync, + mkdirSync, + openSync, + readSync, + renameSync, + symlinkSync, + unlinkSync, + writeFileSync +} from 'node:fs' +import { dirname, join, resolve } from 'node:path' +import { LINUX_CLI_COMMAND_NAME } from './bundled-cli-launcher-path' +import { quoteShell } from './cli-install-path-format' + +const LAUNCHER_DIRECTORY_NAME = 'launcher' +const LIVE_ENDPOINT_NAME = 'live' +const INSTALLED_ENDPOINT_NAME = 'installed' +const LAUNCHER_MARKER = '# orca-appimage-stable-launcher' +const LAUNCHER_WAIT_SECONDS = 5 +const LAUNCHER_MAX_BYTES = 16 * 1024 + +export type AppImageLauncherEndpoint = 'live' | 'installed' + +export function resolveAppImageStableLauncherPath(cacheRootPath: string): string { + return join(resolve(cacheRootPath), LAUNCHER_DIRECTORY_NAME, LINUX_CLI_COMMAND_NAME) +} + +export function resolveAppImageLauncherEndpointPath( + cacheRootPath: string, + endpoint: AppImageLauncherEndpoint +): string { + return join( + dirname(resolveAppImageStableLauncherPath(cacheRootPath)), + endpoint === 'live' ? LIVE_ENDPOINT_NAME : INSTALLED_ENDPOINT_NAME + ) +} + +export function isAppImageStableLauncherReady(cacheRootPath: string): boolean { + const launcherPath = resolveAppImageStableLauncherPath(cacheRootPath) + return isExactExecutableLauncher(launcherPath, buildStableLauncherScript(cacheRootPath)) +} + +/** Ensures the persistent wrapper exists without publishing an endpoint. */ +export function ensureAppImageStableLauncher(cacheRootPath: string): string | null { + return ensureAppImageStableLauncherFile(cacheRootPath) +} + +/** Removes the legacy live endpoint only when it is a symlink. */ +export function removeAppImageLegacyLiveEndpoint(cacheRootPath: string): void { + const endpointPath = resolveAppImageLauncherEndpointPath(cacheRootPath, 'live') + try { + if (lstatSync(endpointPath).isSymbolicLink()) { + unlinkSync(endpointPath) + } + } catch {} +} + +export function publishAppImageLauncherEndpoint( + cacheRootPath: string, + endpoint: 'installed', + targetPath: string +): string | null { + const launcherPath = ensureAppImageStableLauncherFile(cacheRootPath) + if (!launcherPath) { + return null + } + + const endpointPath = resolveAppImageLauncherEndpointPath(cacheRootPath, endpoint) + const temporaryPath = join(dirname(endpointPath), `.${endpoint}-${process.pid}-${randomUUID()}`) + try { + symlinkSync(targetPath, temporaryPath) + renameSync(temporaryPath, endpointPath) + return launcherPath + } catch { + return null + } finally { + try { + unlinkSync(temporaryPath) + } catch {} + } +} + +function ensureAppImageStableLauncherFile(cacheRootPath: string): string | null { + const launcherPath = resolveAppImageStableLauncherPath(cacheRootPath) + const content = buildStableLauncherScript(cacheRootPath) + try { + mkdirSync(dirname(launcherPath), { recursive: true }) + if (!installLauncher(launcherPath, content)) { + return null + } + return launcherPath + } catch { + return null + } +} + +function installLauncher(launcherPath: string, content: string): boolean { + if (isExactExecutableLauncher(launcherPath, content)) { + return true + } + const temporaryPath = join( + dirname(launcherPath), + `.${LINUX_CLI_COMMAND_NAME}-${process.pid}-${randomUUID()}` + ) + try { + writeFileSync(temporaryPath, content, { encoding: 'utf8', flag: 'wx', mode: 0o755 }) + if (publishLauncherIfVacant(temporaryPath, launcherPath)) { + return true + } + if (!isOwnedLauncher(launcherPath)) { + return false + } + return replaceOwnedLauncher(launcherPath, temporaryPath, content) + } finally { + unlinkIfPresent(temporaryPath) + } +} + +function replaceOwnedLauncher( + launcherPath: string, + replacementPath: string, + replacementContent: string +): boolean { + const displacedPath = join( + dirname(launcherPath), + `.orca-preserved-launcher-${process.pid}-${randomUUID()}` + ) + try { + renameSync(launcherPath, displacedPath) + } catch { + return isExactExecutableLauncher(launcherPath, replacementContent) + } + + if (!isOwnedLauncher(displacedPath)) { + restoreForeignLauncher(displacedPath, launcherPath) + return false + } + + if ( + publishLauncherIfVacant(replacementPath, launcherPath) || + isExactExecutableLauncher(launcherPath, replacementContent) + ) { + unlinkIfPresent(displacedPath) + return true + } + + if (publishLauncherIfVacant(displacedPath, launcherPath)) { + unlinkIfPresent(displacedPath) + } + return isExactExecutableLauncher(launcherPath, replacementContent) +} + +function restoreForeignLauncher(displacedPath: string, launcherPath: string): void { + if (publishLauncherIfVacant(displacedPath, launcherPath)) { + unlinkIfPresent(displacedPath) + } +} + +function publishLauncherIfVacant(sourcePath: string, destinationPath: string): boolean { + try { + linkSync(sourcePath, destinationPath) + return true + } catch {} + try { + copyFileSync(sourcePath, destinationPath, constants.COPYFILE_EXCL) + return true + } catch { + return false + } +} + +function isExactExecutableLauncher(launcherPath: string, content: string): boolean { + const launcher = readLauncherFile(launcherPath) + return launcher?.executable === true && launcher.content === content +} + +function isOwnedLauncher(launcherPath: string): boolean { + return readLauncherFile(launcherPath)?.content.split('\n')[1] === LAUNCHER_MARKER +} + +function readLauncherFile(launcherPath: string): { content: string; executable: boolean } | null { + let fd: number | undefined + try { + fd = openSync(launcherPath, constants.O_RDONLY | constants.O_NONBLOCK | constants.O_NOFOLLOW) + const before = fstatSync(fd) + if (!before.isFile() || before.size > LAUNCHER_MAX_BYTES) { + return null + } + const bytes = Buffer.alloc(before.size) + let offset = 0 + while (offset < bytes.length) { + const count = readSync(fd, bytes, offset, bytes.length - offset, offset) + if (count === 0) { + return null + } + offset += count + } + const after = fstatSync(fd) + if (after.size !== before.size || after.mtimeMs !== before.mtimeMs) { + return null + } + return { content: bytes.toString('utf8'), executable: (before.mode & 0o111) !== 0 } + } catch { + return null + } finally { + if (fd !== undefined) { + closeSync(fd) + } + } +} + +function unlinkIfPresent(candidatePath: string): void { + try { + unlinkSync(candidatePath) + } catch {} +} + +function buildStableLauncherScript(cacheRootPath: string): string { + const launcherDirectory = dirname(resolveAppImageStableLauncherPath(cacheRootPath)) + return `#!/usr/bin/env bash +${LAUNCHER_MARKER} +shopt -s execfail +launcher_dir=${quoteShell(launcherDirectory)} +deadline=$((SECONDS + ${LAUNCHER_WAIT_SECONDS})) +while (( SECONDS <= deadline )); do + launcher="$launcher_dir/${INSTALLED_ENDPOINT_NAME}" + if [[ -f "$launcher" && -x "$launcher" ]]; then + exec "$launcher" "$@" + fi + sleep 0.1 +done +printf 'Orca CLI is not ready; reopen Orca or register the CLI again.\\n' >&2 +exit 1 +` +} diff --git a/src/main/cli/cli-command-filesystem-transaction.ts b/src/main/cli/cli-command-filesystem-transaction.ts new file mode 100644 index 00000000000..b5e29386f8e --- /dev/null +++ b/src/main/cli/cli-command-filesystem-transaction.ts @@ -0,0 +1,209 @@ +import { createHash, randomUUID } from 'node:crypto' +import { lstat, mkdir, readFile, readlink, rename, rmdir } from 'node:fs/promises' +import { basename, dirname, join } from 'node:path' +import type { CliInstallStatus } from '../../shared/cli-install-types' +import { isMissingError } from './cli-install-errors' +import { quoteShell } from './cli-install-path-format' + +export type EntryIdentity = { + dev: bigint + ino: bigint + mode: bigint + size: bigint + ctimeNs: bigint +} + +export type EntrySnapshot = { identity: EntryIdentity; isSymbolicLink: boolean } +export type CommandQuarantine = { + directoryPath: string + heldPath: string + snapshot: EntrySnapshot | null +} +export type StableCommandInspection = { + fileSha256: string | null + rawSymlinkTarget: string | null + snapshot: EntrySnapshot | null + status: CliInstallStatus +} + +const STABLE_INSPECTION_ATTEMPTS = 3 + +export async function readEntrySnapshot(path: string): Promise { + try { + const stats = await lstat(path, { bigint: true }) + return { + identity: { + dev: stats.dev, + ino: stats.ino, + mode: stats.mode, + size: stats.size, + ctimeNs: stats.ctimeNs + }, + isSymbolicLink: stats.isSymbolicLink() + } + } catch (error) { + if (isMissingError(error)) { + return null + } + throw error + } +} + +export function hasSameIdentity(left: EntryIdentity | null, right: EntryIdentity | null): boolean { + return ( + left === right || + (left !== null && right !== null && left.dev === right.dev && left.ino === right.ino) + ) +} + +export function hasSameSnapshot(left: EntrySnapshot | null, right: EntrySnapshot | null): boolean { + return ( + left === right || + (left !== null && + right !== null && + hasSameIdentity(left.identity, right.identity) && + left.identity.mode === right.identity.mode && + left.identity.size === right.identity.size && + left.identity.ctimeNs === right.identity.ctimeNs) + ) +} + +export async function hashCommandFile(path: string): Promise { + return createHash('sha256') + .update(await readFile(path)) + .digest('hex') +} + +export async function inspectStableCommand( + commandPath: string, + inspect: () => Promise +): Promise { + for (let attempt = 0; attempt < STABLE_INSPECTION_ATTEMPTS; attempt += 1) { + const before = await readEntrySnapshot(commandPath) + const status = await inspect() + const afterInspection = await readEntrySnapshot(commandPath) + if ( + !hasSameSnapshot(before, afterInspection) || + (afterInspection === null) !== (status.state === 'not_installed') + ) { + continue + } + let fileSha256: string | null = null + let rawSymlinkTarget: string | null = null + try { + if (afterInspection?.isSymbolicLink) { + rawSymlinkTarget = await readlink(commandPath) + } else if (afterInspection && status.state !== 'conflict') { + fileSha256 = await hashCommandFile(commandPath) + } + } catch { + continue + } + const afterEvidence = await readEntrySnapshot(commandPath) + if (hasSameSnapshot(afterInspection, afterEvidence)) { + return { fileSha256, rawSymlinkTarget, snapshot: afterEvidence, status } + } + } + throw new Error(`The command at ${commandPath} changed while Orca inspected it.`) +} + +export async function quarantineCommandPath(commandPath: string): Promise { + const commandDirectory = dirname(commandPath) + const directoryPath = join(commandDirectory, `.orca-cli-${process.pid}-${randomUUID()}`) + const heldPath = join(directoryPath, basename(commandPath)) + await mkdir(commandDirectory, { recursive: true }) + await mkdir(directoryPath, { mode: 0o700 }) + try { + await rename(commandPath, heldPath) + } catch (error) { + if (!isMissingError(error)) { + await rmdir(directoryPath).catch(() => undefined) + throw error + } + } + return { directoryPath, heldPath, snapshot: await readEntrySnapshot(heldPath) } +} + +export async function capturedExpectedEntry( + quarantine: CommandQuarantine, + inspected: Pick +): Promise { + if (!quarantine.snapshot) { + return true + } + if ( + !inspected.snapshot || + quarantine.snapshot.isSymbolicLink !== inspected.snapshot.isSymbolicLink || + !hasSameIdentity(quarantine.snapshot.identity, inspected.snapshot.identity) + ) { + return false + } + if (inspected.rawSymlinkTarget !== null) { + try { + return (await readlink(quarantine.heldPath)) === inspected.rawSymlinkTarget + } catch { + return false + } + } + if (!inspected.fileSha256) { + return true + } + try { + return (await hashCommandFile(quarantine.heldPath)) === inspected.fileSha256 + } catch { + return false + } +} + +type MacPrivilegedSymlinkTransaction = { + commandPath: string + expected: EntryIdentity | null + expectedFileSha256: string | null + expectedRawSymlinkTarget: string | null +} & ({ action: 'install'; launcherPath: string } | { action: 'remove' }) + +export function buildMacPrivilegedSymlinkTransaction( + args: MacPrivilegedSymlinkTransaction +): string { + const commandDirectory = dirname(args.commandPath) + const transactionDirectory = join(commandDirectory, `.orca-cli-${process.pid}-${randomUUID()}`) + const heldPath = join(transactionDirectory, basename(args.commandPath)) + const publishDirectory = join(transactionDirectory, 'publish') + const publishPath = join(publishDirectory, basename(args.commandPath)) + const recoveryMessage = quoteShell(`The displaced entry is preserved at ${heldPath}.`) + const restore = + `/bin/ln -P ${quoteShell(heldPath)} ${quoteShell(commandDirectory)} && ` + + `/bin/rm ${quoteShell(heldPath)} && /bin/rmdir ${quoteShell(transactionDirectory)}` + const restoreOrPreserve = `if ${restore}; then :; else echo ${recoveryMessage} >&2; exit 74; fi` + const fileMismatch = args.expectedFileSha256 + ? ` || [ "$(/usr/bin/shasum -a 256 ${quoteShell(heldPath)} | /usr/bin/awk '{print $1}')" != ${quoteShell(args.expectedFileSha256)} ]` + : '' + const symlinkMismatch = args.expectedRawSymlinkTarget + ? ` || [ "$(/usr/bin/readlink -n ${quoteShell(heldPath)}; /usr/bin/printf x)" != ${quoteShell(`${args.expectedRawSymlinkTarget}x`)} ]` + : '' + const rejectCaptured = args.expected + ? `if [ "$captured" -eq 1 ] && { [ "$(/usr/bin/stat -f '%d:%i' ${quoteShell(heldPath)})" != ${quoteShell(`${args.expected.dev}:${args.expected.ino}`)} ]${fileMismatch}${symlinkMismatch}; }; then ${restoreOrPreserve}; exit 73; fi` + : `if [ "$captured" -eq 1 ]; then ${restoreOrPreserve}; exit 73; fi` + const capture = + `umask 077; /bin/mkdir -p ${quoteShell(commandDirectory)} || exit $?; ` + + `/bin/mkdir ${quoteShell(transactionDirectory)} || exit $?; captured=0; ` + + `if [ -e ${quoteShell(args.commandPath)} ] || [ -L ${quoteShell(args.commandPath)} ]; then ` + + `/bin/mv ${quoteShell(args.commandPath)} ${quoteShell(heldPath)} && captured=1 || exit $?; fi; ` + + `${rejectCaptured}; ` + + if (args.action === 'remove') { + return `${capture}if [ "$captured" -eq 1 ]; then /bin/rm ${quoteShell(heldPath)}; fi; /bin/rmdir ${quoteShell(transactionDirectory)}` + } + + const rollback = + `/bin/rm -f ${quoteShell(publishPath)}; /bin/rmdir ${quoteShell(publishDirectory)} 2>/dev/null || :; ` + + `if [ "$captured" -eq 1 ]; then ${restoreOrPreserve}; else /bin/rmdir ${quoteShell(transactionDirectory)}; fi; exit 73` + return ( + `${capture}if /bin/mkdir ${quoteShell(publishDirectory)} && ` + + `/bin/ln -s ${quoteShell(args.launcherPath)} ${quoteShell(publishPath)} && ` + + `/bin/ln -P ${quoteShell(publishPath)} ${quoteShell(commandDirectory)}; then ` + + `/bin/rm ${quoteShell(publishPath)}; /bin/rmdir ${quoteShell(publishDirectory)}; ` + + `if [ "$captured" -eq 1 ]; then /bin/rm ${quoteShell(heldPath)}; fi; ` + + `/bin/rmdir ${quoteShell(transactionDirectory)}; else ${rollback}; fi` + ) +} diff --git a/src/main/cli/cli-command-inspection.ts b/src/main/cli/cli-command-inspection.ts index 93712201fc5..c580c597478 100644 --- a/src/main/cli/cli-command-inspection.ts +++ b/src/main/cli/cli-command-inspection.ts @@ -1,62 +1,21 @@ +import { existsSync } from 'node:fs' import { lstat, readFile, readlink } from 'node:fs/promises' import { basename, dirname, resolve } from 'node:path' import type { CliInstallMethod, CliInstallStatus } from '../../shared/cli-install-types' -import { buildAppImageCliWrapper } from './appimage-cli-wrapper' +import { isAppImageExtractedLauncherPath } from './appimage-extracted-root' import { DEV_COMMAND_NAME, DEV_LAUNCHER_DIR } from './cli-install-constants' import { buildWindowsForwarder, extractManagedUnixLauncherTarget } from './cli-dev-launcher' import { isMissingError } from './cli-install-errors' import { CliInstallLocation } from './cli-install-location' import { isPathInsideOrEqual, samePathEntry } from './cli-install-path-format' +import { extractLegacyAppImageCliWrapperTarget } from './legacy-appimage-cli-wrapper' + +// Why: electron-builder's /opt directory name varies with productName sanitization, which is why +// resources/linux/packaging/after-install.sh enumerates all three of these. A symlink into one is a +// previous packaged Orca and is ours to reclaim; anything else stays a conflict. +const PACKAGED_LINUX_LAUNCHER_DIRECTORIES = ['/opt/Orca', '/opt/orca-ide', '/opt/orca'] export class CliCommandInspection extends CliInstallLocation { - protected async inspectAppImageWrapper( - commandPath: string, - appImagePath: string - ): Promise { - try { - const stats = await lstat(commandPath) - if (!stats.isFile()) { - return this.buildStatus({ - commandPath, - launcherPath: appImagePath, - installMethod: 'wrapper', - supported: true, - state: 'conflict', - currentTarget: null, - detail: `${commandPath} exists but is not an Orca launcher script.` - }) - } - - const currentContent = await readFile(commandPath, 'utf8') - const expectedContent = buildAppImageCliWrapper(appImagePath) - return this.buildStatus({ - commandPath, - launcherPath: appImagePath, - installMethod: 'wrapper', - supported: true, - state: currentContent === expectedContent ? 'installed' : 'stale', - currentTarget: appImagePath, - detail: - currentContent === expectedContent - ? `Registered at ${commandPath}.` - : `${commandPath} points to a different launcher.` - }) - } catch (error) { - if (isMissingError(error)) { - return this.buildStatus({ - commandPath, - launcherPath: appImagePath, - installMethod: 'wrapper', - supported: true, - state: 'not_installed', - currentTarget: null, - detail: `Register ${commandPath} to use Orca from the terminal.` - }) - } - throw error - } - } - protected async inspectSymlink( commandPath: string, launcherPath: string @@ -66,7 +25,9 @@ export class CliCommandInspection extends CliInstallLocation { if (!stats.isSymbolicLink()) { if (stats.isFile()) { const currentContent = await readFile(commandPath, 'utf8') - const managedTarget = extractManagedUnixLauncherTarget(currentContent) + const managedTarget = + extractManagedUnixLauncherTarget(currentContent) ?? + extractLegacyAppImageCliWrapperTarget(currentContent) if (managedTarget) { return this.buildStatus({ commandPath, @@ -94,9 +55,11 @@ export class CliCommandInspection extends CliInstallLocation { const currentTarget = await readlink(commandPath) const resolvedCurrentTarget = resolve(dirname(commandPath), currentTarget) const resolvedLauncher = resolve(launcherPath) - const isInstalled = resolvedCurrentTarget === resolvedLauncher + const isInstalled = resolvedCurrentTarget === resolvedLauncher && existsSync(resolvedLauncher) const isManagedStaleTarget = - !isInstalled && this.isManagedSymlinkTarget(resolvedCurrentTarget, launcherPath) + !isInstalled && + (resolvedCurrentTarget === resolvedLauncher || + this.isManagedSymlinkTarget(resolvedCurrentTarget, launcherPath)) return this.buildStatus({ commandPath, launcherPath, @@ -149,12 +112,25 @@ export class CliCommandInspection extends CliInstallLocation { } if (this.platform === 'linux') { - return /(?:^|[/\\])resources[/\\]bin[/\\][^/\\]+$/.test(resolvedTarget) + if (this.isPackagedLinuxLauncherTarget(resolvedTarget, expectedName)) { + return true + } + const extractionOptions = this.appImageExtractionOptions() + return extractionOptions + ? isAppImageExtractedLauncherPath(extractionOptions, resolvedTarget) + : false } return false } + /** A launcher inside a packaged Linux install tree, left behind by a deb/rpm Orca. */ + protected isPackagedLinuxLauncherTarget(resolvedTarget: string, expectedName: string): boolean { + return PACKAGED_LINUX_LAUNCHER_DIRECTORIES.some( + (directory) => resolvedTarget === `${directory}/resources/bin/${expectedName}` + ) + } + protected isSiblingDevLauncherTarget( resolvedTarget: string, packagedLauncherName: string diff --git a/src/main/cli/cli-command-installation-races.test.ts b/src/main/cli/cli-command-installation-races.test.ts new file mode 100644 index 00000000000..34c7f6dfe7d --- /dev/null +++ b/src/main/cli/cli-command-installation-races.test.ts @@ -0,0 +1,386 @@ +import { + lstat, + mkdir, + mkdtemp, + readFile, + readdir, + readlink, + rm, + symlink, + unlink, + writeFile +} from 'node:fs/promises' +import type * as NodeFsPromises from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' + +const legacyReadlinkRace = vi.hoisted(() => ({ commandPath: '', replacementTarget: '' })) +const reusedIdentity = vi.hoisted(() => ({ + path: '', + dev: null as bigint | null, + ino: null as bigint | null +})) + +vi.mock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + lstat: async (...args: Parameters) => { + const stats = await actual.lstat(...args) + if ( + args[0] !== reusedIdentity.path || + reusedIdentity.dev === null || + reusedIdentity.ino === null + ) { + return stats + } + return Object.create(stats, { + dev: { value: reusedIdentity.dev }, + ino: { value: reusedIdentity.ino } + }) as typeof stats + }, + readlink: async (...args: Parameters) => { + const [path] = args + if (path === legacyReadlinkRace.commandPath && legacyReadlinkRace.replacementTarget) { + const replacementTarget = legacyReadlinkRace.replacementTarget + legacyReadlinkRace.commandPath = '' + legacyReadlinkRace.replacementTarget = '' + await actual.unlink(path) + await actual.symlink(replacementTarget, path) + throw Object.assign(new Error('link vanished during inspection'), { code: 'ENOENT' }) + } + return actual.readlink(...args) + } + } +}) + +vi.mock('electron', () => ({ + app: { + isPackaged: false, + getPath: () => tmpdir(), + getAppPath: () => tmpdir() + } +})) + +import { CliInstaller } from './cli-installer' +import type { CommandQuarantine } from './cli-command-filesystem-transaction' + +const createdRoots: string[] = [] + +afterEach(async () => { + legacyReadlinkRace.commandPath = '' + legacyReadlinkRace.replacementTarget = '' + reusedIdentity.path = '' + reusedIdentity.dev = null + reusedIdentity.ino = null + await Promise.all( + createdRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })) + ) +}) + +async function createMacCommandFixture() { + const root = await mkdtemp(join(tmpdir(), 'orca-cli-command-race-')) + createdRoots.push(root) + const commandDirectory = join(root, 'bin') + const commandPath = join(commandDirectory, 'orca') + const resourcesPath = join(root, 'Current.app', 'Contents', 'Resources') + const launcherPath = join(resourcesPath, 'bin', 'orca') + const staleLauncherPath = join(root, 'Old.app', 'Contents', 'Resources', 'bin', 'orca') + await mkdir(commandDirectory, { recursive: true }) + await mkdir(dirname(launcherPath), { recursive: true }) + await writeFile(launcherPath, '#!/usr/bin/env bash\n', { mode: 0o755 }) + return { root, commandDirectory, commandPath, resourcesPath, launcherPath, staleLauncherPath } +} + +function createMacInstaller( + fixture: Awaited>, + hooks: { + quarantine?: (commandPath: string) => Promise + afterQuarantine?: (quarantine: CommandQuarantine) => void + link?: (heldPath: string, commandPath: string) => Promise + } = {} +): CliInstaller { + class RaceInjectedInstaller extends CliInstaller { + protected override async quarantineCommandPath(commandPath: string) { + await hooks.quarantine?.(commandPath) + const quarantine = await super.quarantineCommandPath(commandPath) + hooks.afterQuarantine?.(quarantine) + return quarantine + } + + protected override async linkQuarantinedCommand( + heldPath: string, + commandPath: string + ): Promise { + await hooks.link?.(heldPath, commandPath) + return super.linkQuarantinedCommand(heldPath, commandPath) + } + } + + return new RaceInjectedInstaller({ + platform: 'darwin', + isPackaged: true, + userDataPath: join(fixture.root, 'user-data'), + resourcesPath: fixture.resourcesPath, + execPath: join(fixture.root, 'Current.app', 'Contents', 'MacOS', 'Orca'), + appPath: join(fixture.root, 'Current.app', 'Contents', 'Resources', 'app.asar'), + homePath: join(fixture.root, 'home'), + commandPathOverride: fixture.commandPath, + processPathEnv: fixture.commandDirectory + }) +} + +async function recoveryPath(commandDirectory: string): Promise { + const transactionName = (await readdir(commandDirectory)).find((name) => + name.startsWith('.orca-cli-') + ) + if (!transactionName) { + throw new Error('Expected a preserved CLI command transaction.') + } + return join(commandDirectory, transactionName, 'orca') +} + +async function rejectionFrom(operation: Promise): Promise { + try { + await operation + } catch (error) { + if (error instanceof Error) { + return error + } + throw error + } + throw new Error('Expected the operation to reject.') +} + +describe.skipIf(process.platform === 'win32')('CLI command filesystem races', () => { + it('replaces and removes an unchanged stale symlink through the real filesystem', async () => { + const fixture = await createMacCommandFixture() + await symlink(fixture.staleLauncherPath, fixture.commandPath) + const installer = createMacInstaller(fixture) + + await expect(installer.install()).resolves.toMatchObject({ state: 'installed' }) + await expect(readlink(fixture.commandPath)).resolves.toBe(fixture.launcherPath) + await expect(installer.remove()).resolves.toMatchObject({ state: 'not_installed' }) + await expect(lstat(fixture.commandPath)).rejects.toMatchObject({ code: 'ENOENT' }) + }) + + it('restores a foreign symlink whose quarantined inode identity is reused', async () => { + const fixture = await createMacCommandFixture() + const foreignTarget = join(fixture.root, 'foreign-command') + await symlink(fixture.staleLauncherPath, fixture.commandPath) + const original = await lstat(fixture.commandPath, { bigint: true }) + let raced = false + const installer = createMacInstaller(fixture, { + quarantine: async (commandPath) => { + if (raced) { + return + } + raced = true + await unlink(commandPath) + await symlink(foreignTarget, commandPath) + }, + afterQuarantine: (quarantine) => { + if (quarantine.snapshot) { + reusedIdentity.path = quarantine.heldPath + reusedIdentity.dev = original.dev + reusedIdentity.ino = original.ino + quarantine.snapshot.identity = { + ...quarantine.snapshot.identity, + dev: original.dev, + ino: original.ino + } + } + } + }) + + await expect(installer.install()).rejects.toThrow('Refusing to replace non-Orca command') + await expect(readlink(fixture.commandPath)).resolves.toBe(foreignTarget) + expect( + (await readdir(fixture.commandDirectory)).some((name) => name.startsWith('.orca-cli-')) + ).toBe(false) + }) + + it('preserves a managed file changed in place after its final inspection', async () => { + const fixture = await createMacCommandFixture() + const oldCliPath = join(fixture.root, 'old', 'out', 'cli', 'index.js') + await writeFile( + fixture.commandPath, + [ + '#!/usr/bin/env bash', + `CLI='${oldCliPath}'`, + 'export ORCA_NODE_OPTIONS="${NODE_OPTIONS-}"', + 'export ORCA_NODE_REPL_EXTERNAL_MODULE="${NODE_REPL_EXTERNAL_MODULE-}"', + 'ELECTRON_RUN_AS_NODE=1 exec electron "$CLI" "$@"' + ].join('\n') + ) + let raced = false + const installer = createMacInstaller(fixture, { + quarantine: async (commandPath) => { + if (raced) { + return + } + raced = true + await writeFile(commandPath, 'foreign command written into the inspected inode') + } + }) + + await expect(installer.install()).rejects.toThrow('Refusing to replace non-Orca command') + await expect(readFile(fixture.commandPath, 'utf8')).resolves.toBe( + 'foreign command written into the inspected inode' + ) + }) + + it('restores a foreign symlink raced into command removal', async () => { + const fixture = await createMacCommandFixture() + const foreignTarget = join(fixture.root, 'foreign-command') + await symlink(fixture.launcherPath, fixture.commandPath) + let raced = false + const installer = createMacInstaller(fixture, { + quarantine: async (commandPath) => { + if (raced) { + return + } + raced = true + await unlink(commandPath) + await symlink(foreignTarget, commandPath) + } + }) + + await expect(installer.remove()).rejects.toThrow('Refusing to remove non-Orca command') + await expect(readlink(fixture.commandPath)).resolves.toBe(foreignTarget) + }) + + it('preserves a raced foreign directory at the reported recovery path', async () => { + const fixture = await createMacCommandFixture() + await symlink(fixture.staleLauncherPath, fixture.commandPath) + let raced = false + const installer = createMacInstaller(fixture, { + quarantine: async (commandPath) => { + if (raced) { + return + } + raced = true + await unlink(commandPath) + await mkdir(commandPath) + await writeFile(join(commandPath, 'user-data'), 'preserved') + } + }) + + const error = await rejectionFrom(installer.install()) + const heldPath = await recoveryPath(fixture.commandDirectory) + expect(error.message).toContain(heldPath) + await expect(readFile(join(heldPath, 'user-data'), 'utf8')).resolves.toBe('preserved') + await expect(lstat(fixture.commandPath)).rejects.toMatchObject({ code: 'ENOENT' }) + }) + + it('preserves both entries when the original name is reclaimed during restoration', async () => { + const fixture = await createMacCommandFixture() + const contenderTarget = join(fixture.root, 'contender-command') + await symlink(fixture.staleLauncherPath, fixture.commandPath) + let raced = false + const installer = createMacInstaller(fixture, { + quarantine: async (commandPath) => { + if (raced) { + return + } + raced = true + await unlink(commandPath) + await writeFile(commandPath, 'foreign command') + }, + link: async (_heldPath, commandPath) => { + await symlink(contenderTarget, commandPath) + } + }) + + const error = await rejectionFrom(installer.install()) + const heldPath = await recoveryPath(fixture.commandDirectory) + expect(error.message).toContain(heldPath) + await expect(readFile(heldPath, 'utf8')).resolves.toBe('foreign command') + await expect(readlink(fixture.commandPath)).resolves.toBe(contenderTarget) + }) + + it('keeps a foreign legacy Linux command when readlink loses the inspection race', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-cli-legacy-race-')) + createdRoots.push(root) + const homePath = join(root, 'home') + const commandDirectory = join(homePath, '.local', 'bin') + const resourcesPath = join(root, 'resources') + const launcherPath = join(resourcesPath, 'bin', 'orca-ide') + const legacyPath = join(commandDirectory, 'orca') + const managedLegacyTarget = join(resourcesPath, 'bin', 'orca') + const foreignTarget = join(root, 'foreign-orca') + await mkdir(commandDirectory, { recursive: true }) + await mkdir(dirname(launcherPath), { recursive: true }) + await writeFile(launcherPath, '#!/usr/bin/env bash\n', { mode: 0o755 }) + await symlink(managedLegacyTarget, legacyPath) + + legacyReadlinkRace.commandPath = legacyPath + legacyReadlinkRace.replacementTarget = foreignTarget + const installer = new CliInstaller({ + platform: 'linux', + isPackaged: true, + userDataPath: join(root, 'user-data'), + resourcesPath, + execPath: join(root, 'orca-ide'), + appPath: join(root, 'resources', 'app.asar'), + homePath, + processPathEnv: commandDirectory + }) + + await expect(installer.install()).resolves.toMatchObject({ state: 'installed' }) + await expect(readlink(legacyPath)).resolves.toBe(foreignTarget) + }) + + it('keeps a foreign legacy Linux command whose quarantined inode is reused', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-cli-legacy-identity-race-')) + createdRoots.push(root) + const homePath = join(root, 'home') + const commandDirectory = join(homePath, '.local', 'bin') + const resourcesPath = join(root, 'resources') + const launcherPath = join(resourcesPath, 'bin', 'orca-ide') + const legacyPath = join(commandDirectory, 'orca') + const managedTarget = join(resourcesPath, 'bin', 'orca') + const foreignTarget = join(root, 'foreign-orca') + await mkdir(commandDirectory, { recursive: true }) + await mkdir(dirname(launcherPath), { recursive: true }) + await writeFile(launcherPath, '#!/usr/bin/env bash\n', { mode: 0o755 }) + await symlink(managedTarget, legacyPath) + const original = await lstat(legacyPath, { bigint: true }) + + class LegacyRaceInstaller extends CliInstaller { + protected override async quarantineCommandPath(commandPath: string) { + if (commandPath === legacyPath) { + await unlink(commandPath) + await symlink(foreignTarget, commandPath) + } + const quarantine = await super.quarantineCommandPath(commandPath) + if (commandPath === legacyPath && quarantine.snapshot) { + reusedIdentity.path = quarantine.heldPath + reusedIdentity.dev = original.dev + reusedIdentity.ino = original.ino + quarantine.snapshot.identity = { + ...quarantine.snapshot.identity, + dev: original.dev, + ino: original.ino + } + } + return quarantine + } + } + + const installer = new LegacyRaceInstaller({ + platform: 'linux', + isPackaged: true, + userDataPath: join(root, 'user-data'), + resourcesPath, + execPath: join(root, 'orca-ide'), + appPath: join(root, 'resources', 'app.asar'), + homePath, + processPathEnv: commandDirectory + }) + + await expect(installer.install()).resolves.toMatchObject({ state: 'installed' }) + await expect(readlink(legacyPath)).resolves.toBe(foreignTarget) + }) +}) diff --git a/src/main/cli/cli-command-installation.ts b/src/main/cli/cli-command-installation.ts index a3f38778197..fbabc3bf6dd 100644 --- a/src/main/cli/cli-command-installation.ts +++ b/src/main/cli/cli-command-installation.ts @@ -1,49 +1,103 @@ -import { lstat, mkdir, readlink, symlink, unlink, writeFile } from 'node:fs/promises' -import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path' +import { link, readlink, rmdir, symlink, unlink, writeFile } from 'node:fs/promises' +import { basename, dirname, join, resolve } from 'node:path' import type { CliInstallStatus } from '../../shared/cli-install-types' -import { buildAppImageCliWrapper } from './appimage-cli-wrapper' +import { + ensureAppImageExtractedRoot, + isAppImageExtractedLauncherPath, + type AppImageExtractedRoot +} from './appimage-extracted-root' import { CliCommandInspection } from './cli-command-inspection' +import { + buildMacPrivilegedSymlinkTransaction, + capturedExpectedEntry, + hasSameIdentity, + hasSameSnapshot, + inspectStableCommand, + quarantineCommandPath, + readEntrySnapshot, + type CommandQuarantine, + type StableCommandInspection +} from './cli-command-filesystem-transaction' import { DEV_LAUNCHER_DIR, LEGACY_LINUX_COMMAND_NAME } from './cli-install-constants' import { buildWindowsForwarder } from './cli-dev-launcher' import { isMissingError, isPermissionError } from './cli-install-errors' -import { quoteShell } from './cli-install-path-format' +import { isPathInsideOrEqual } from './cli-install-path-format' + +const STABLE_LEGACY_INSPECTION_ATTEMPTS = 3 export class CliCommandInstallation extends CliCommandInspection { protected async installSymlink(status: CliInstallStatus): Promise { + const commandPath = status.commandPath + const launcherPath = status.launcherPath + if (!commandPath || !launcherPath || status.state === 'installed') { + return + } + + const inspected = await this.inspectStableSymlink(commandPath, launcherPath) + if (inspected.status.state === 'conflict') { + throw new Error( + `Refusing to replace non-Orca command at ${commandPath}. Remove it and register again if it is no longer needed.` + ) + } + if (inspected.status.state === 'installed') { + return + } + + let quarantine: CommandQuarantine try { - if (status.state === 'installed') { - return - } - if (status.state === 'stale') { - await unlink(status.commandPath as string) - } - // Why: mkdir stays here (not install()) so an EACCES falls into the privileged-runner catch below. - await mkdir(dirname(status.commandPath as string), { recursive: true }) - await symlink(status.launcherPath as string, status.commandPath as string) + quarantine = await this.quarantineCommandPath(commandPath) } catch (error) { if (this.platform !== 'darwin' || !isPermissionError(error)) { throw error } + await this.installSymlinkWithPrivileges(commandPath, launcherPath, inspected) + return + } - // Why: fall back to an elevated shell to place the /usr/local/bin symlink (VS Code-style) when direct write is denied. - await this.privilegedRunner( - `mkdir -p ${quoteShell(dirname(status.commandPath as string))} && ` + - `ln -sfn ${quoteShell(status.launcherPath as string)} ${quoteShell(status.commandPath as string)}` + if (!(await capturedExpectedEntry(quarantine, inspected))) { + await this.restoreQuarantinedCommand(quarantine, commandPath) + throw new Error( + `Refusing to replace non-Orca command at ${commandPath}. Remove it and register again if it is no longer needed.` ) } + + try { + await symlink(launcherPath, commandPath) + } catch (error) { + await this.restoreQuarantinedCommand(quarantine, commandPath) + throw error + } + await this.discardQuarantinedCommand(quarantine) } protected async removeSymlink(commandPath: string): Promise { + const launcherPath = await this.resolveLauncherPath() + if (!launcherPath) { + throw new Error('The Orca CLI launcher is no longer available.') + } + const inspected = await this.inspectStableSymlink(commandPath, launcherPath) + if (inspected.status.state === 'not_installed') { + return + } + if (inspected.status.state === 'conflict') { + throw new Error(`Refusing to remove non-Orca command at ${commandPath}.`) + } + + let quarantine: CommandQuarantine try { - await unlink(commandPath) + quarantine = await this.quarantineCommandPath(commandPath) } catch (error) { if (this.platform !== 'darwin' || !isPermissionError(error)) { throw error } - await this.privilegedRunner( - `if [ -L ${quoteShell(commandPath)} ]; then rm ${quoteShell(commandPath)}; fi` - ) + await this.removeSymlinkWithPrivileges(commandPath, inspected) + return } + if (!(await capturedExpectedEntry(quarantine, inspected))) { + await this.restoreQuarantinedCommand(quarantine, commandPath) + throw new Error(`Refusing to remove non-Orca command at ${commandPath}.`) + } + await this.discardQuarantinedCommand(quarantine) } protected async removeLegacyLinuxCommandIfManaged(launcherPath: string | null): Promise { @@ -51,29 +105,35 @@ export class CliCommandInstallation extends CliCommandInspection { return } - const legacyCommandPath = join(this.homePath, '.local', 'bin', LEGACY_LINUX_COMMAND_NAME) + const commandPath = join(this.homePath, '.local', 'bin', LEGACY_LINUX_COMMAND_NAME) try { - const stats = await lstat(legacyCommandPath) - if (!stats.isSymbolicLink()) { + const inspected = await this.inspectStableLegacyCommand(commandPath, launcherPath) + if (!inspected?.managed) { return } - - const currentTarget = await readlink(legacyCommandPath) - const resolvedCurrentTarget = resolve(dirname(legacyCommandPath), currentTarget) - if (!this.isManagedLegacyLinuxTarget(resolvedCurrentTarget, launcherPath)) { + const quarantine = await this.quarantineCommandPath(commandPath) + if (!(await capturedExpectedEntry(quarantine, inspected))) { + await this.restoreQuarantinedCommand(quarantine, commandPath) return } - - // Why: after the Linux command rename, the old `orca` symlink would keep shadowing GNOME Orca. - await unlink(legacyCommandPath) + await this.discardQuarantinedCommand(quarantine) } catch (error) { - if (isMissingError(error)) { - return - } - throw error + // Why: the new command is already registered; leave legacy cleanup for a later attempt. + console.warn( + `[cli] Could not remove the legacy command at ${commandPath}:`, + error instanceof Error ? error.message : String(error) + ) } } + protected async quarantineCommandPath(commandPath: string): Promise { + return quarantineCommandPath(commandPath) + } + + protected async linkQuarantinedCommand(heldPath: string, commandPath: string): Promise { + await link(heldPath, commandPath) + } + protected isManagedLegacyLinuxTarget(resolvedTarget: string, launcherPath: string): boolean { const legacyLauncherPath = resolve(dirname(launcherPath), LEGACY_LINUX_COMMAND_NAME) if (resolvedTarget === legacyLauncherPath) { @@ -84,26 +144,183 @@ export class CliCommandInstallation extends CliCommandInspection { return false } - const devLauncherDir = resolve(this.userDataPath, ...DEV_LAUNCHER_DIR) - const devRelative = relative(devLauncherDir, resolvedTarget) - if (devRelative && !devRelative.startsWith('..') && !isAbsolute(devRelative)) { + if (this.isPackagedLinuxLauncherTarget(resolvedTarget, LEGACY_LINUX_COMMAND_NAME)) { return true } - // Why: AppImage upgrades can strand a legacy symlink into a now-gone FUSE mount that isn't a sibling of the stable path. - return /(?:^|[/\\])resources[/\\]bin[/\\]orca$/.test(resolvedTarget) + const devLauncherDir = resolve(this.userDataPath, ...DEV_LAUNCHER_DIR) + if (isPathInsideOrEqual(devLauncherDir, resolvedTarget)) { + return true + } + + const extractionOptions = this.appImageExtractionOptions() + return extractionOptions + ? isAppImageExtractedLauncherPath( + extractionOptions, + resolvedTarget, + LEGACY_LINUX_COMMAND_NAME + ) + : false } protected async installWindowsWrapper(commandPath: string, launcherPath: string): Promise { await writeFile(commandPath, buildWindowsForwarder(launcherPath), 'utf8') } - protected async installAppImageWrapper(commandPath: string, appImagePath: string): Promise { - // Why: the AppImage command dir is user-writable, so create it before writing the wrapper. - await mkdir(dirname(commandPath), { recursive: true }) - await writeFile(commandPath, buildAppImageCliWrapper(appImagePath), { - encoding: 'utf8', - mode: 0o755 - }) + protected async ensureLinuxAppImagePayload(): Promise { + const extractionOptions = this.appImageExtractionOptions() + if (!this.isLinuxAppImage() || !extractionOptions) { + return null + } + const extractedRoot = await ensureAppImageExtractedRoot(extractionOptions) + if (!extractedRoot) { + throw new Error( + `Could not extract the Orca AppImage at ${this.appImagePath}. Check that it is executable and that ${this.appImageCacheRootPath} has free space.` + ) + } + return extractedRoot + } + + private async inspectStableSymlink( + commandPath: string, + launcherPath: string + ): Promise { + return inspectStableCommand(commandPath, () => this.inspectSymlink(commandPath, launcherPath)) + } + + private async inspectStableLegacyCommand( + commandPath: string, + launcherPath: string + ): Promise< + | (Pick & { + snapshot: NonNullable + managed: boolean + }) + | null + > { + for (let attempt = 0; attempt < STABLE_LEGACY_INSPECTION_ATTEMPTS; attempt += 1) { + const before = await readEntrySnapshot(commandPath) + if (!before) { + return null + } + let target: string | null = null + try { + target = before.isSymbolicLink ? await readlink(commandPath) : null + } catch (error) { + if (isMissingError(error)) { + continue + } + throw error + } + const after = await readEntrySnapshot(commandPath) + if (after && hasSameSnapshot(before, after)) { + const resolvedTarget = target ? resolve(dirname(commandPath), target) : null + return { + fileSha256: null, + rawSymlinkTarget: target, + snapshot: after, + managed: Boolean( + resolvedTarget && this.isManagedLegacyLinuxTarget(resolvedTarget, launcherPath) + ) + } + } + } + throw new Error(`The command at ${commandPath} changed while Orca inspected it.`) + } + + private async restoreQuarantinedCommand( + quarantine: CommandQuarantine, + commandPath: string + ): Promise { + if (!quarantine.snapshot) { + await rmdir(quarantine.directoryPath) + return + } + await this.assertHeldIdentity(quarantine) + try { + await (quarantine.snapshot.isSymbolicLink + ? symlink(await readlink(quarantine.heldPath), commandPath) + : this.linkQuarantinedCommand(quarantine.heldPath, commandPath)) + const restored = await readEntrySnapshot(commandPath) + const restoredSymlink = + restored?.isSymbolicLink && quarantine.snapshot.isSymbolicLink + ? (await readlink(commandPath)) === (await readlink(quarantine.heldPath)) + : false + if ( + !restored || + (!restoredSymlink && !hasSameIdentity(restored.identity, quarantine.snapshot.identity)) + ) { + throw new Error('The restored command identity could not be verified.') + } + await this.discardQuarantinedCommand(quarantine, quarantine.snapshot.isSymbolicLink) + } catch (error) { + throw new Error( + `The displaced entry is preserved at ${quarantine.heldPath}; ${commandPath} could not be restored without overwriting another entry.`, + { cause: error } + ) + } + } + + private async discardQuarantinedCommand( + quarantine: CommandQuarantine, + requireStableMetadata = true + ): Promise { + if (quarantine.snapshot) { + await this.assertHeldIdentity(quarantine, requireStableMetadata) + await unlink(quarantine.heldPath) + } + await rmdir(quarantine.directoryPath) + } + + private async assertHeldIdentity( + quarantine: CommandQuarantine, + requireStableMetadata = true + ): Promise { + const current = await readEntrySnapshot(quarantine.heldPath) + if ( + !current || + !quarantine.snapshot || + !(requireStableMetadata + ? hasSameSnapshot(current, quarantine.snapshot) + : hasSameIdentity(current.identity, quarantine.snapshot.identity)) + ) { + throw new Error(`The quarantined command changed at ${quarantine.heldPath}.`) + } + } + + private async installSymlinkWithPrivileges( + commandPath: string, + launcherPath: string, + inspected: StableCommandInspection + ): Promise { + await this.privilegedRunner( + buildMacPrivilegedSymlinkTransaction({ + action: 'install', + commandPath, + launcherPath, + expected: inspected.snapshot?.identity ?? null, + expectedFileSha256: inspected.fileSha256, + expectedRawSymlinkTarget: inspected.rawSymlinkTarget + }) + ) + const installed = await this.inspectStableSymlink(commandPath, launcherPath) + if (installed.status.state !== 'installed') { + throw new Error(`Could not register the Orca command at ${commandPath}.`) + } + } + + private async removeSymlinkWithPrivileges( + commandPath: string, + inspected: StableCommandInspection + ): Promise { + await this.privilegedRunner( + buildMacPrivilegedSymlinkTransaction({ + action: 'remove', + commandPath, + expected: inspected.snapshot?.identity ?? null, + expectedFileSha256: inspected.fileSha256, + expectedRawSymlinkTarget: inspected.rawSymlinkTarget + }) + ) } } diff --git a/src/main/cli/cli-command-privileged-transaction.test.ts b/src/main/cli/cli-command-privileged-transaction.test.ts new file mode 100644 index 00000000000..63349cb70c9 --- /dev/null +++ b/src/main/cli/cli-command-privileged-transaction.test.ts @@ -0,0 +1,189 @@ +import { + chmod, + lstat, + mkdir, + mkdtemp, + readFile, + readlink, + readdir, + rm, + symlink, + unlink, + writeFile +} from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { runProcess } from '../../shared/child-process/run-process' + +vi.mock('electron', () => ({ + app: { + isPackaged: false, + getPath: () => tmpdir(), + getAppPath: () => tmpdir() + } +})) + +import { CliInstaller } from './cli-installer' +import { buildUnixDevLauncher } from './cli-dev-launcher' + +const createdRoots: string[] = [] +const protectedDirectories: string[] = [] + +afterEach(async () => { + await Promise.all(protectedDirectories.splice(0).map((path) => chmod(path, 0o700))) + await Promise.all( + createdRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })) + ) +}) + +async function createPrivilegedFixture() { + const root = await mkdtemp(join(tmpdir(), 'orca-cli-privileged-transaction-')) + createdRoots.push(root) + const protectedDirectory = join(root, 'protected') + protectedDirectories.push(protectedDirectory) + const commandPath = join(protectedDirectory, 'orca') + const userDataPath = join(root, 'user-data') + const appPath = join(root, 'app') + await mkdir(protectedDirectory) + await mkdir(join(appPath, 'out', 'cli'), { recursive: true }) + await writeFile(join(appPath, 'out', 'cli', 'index.js'), 'console.log("orca")\n') + return { root, protectedDirectory, commandPath, userDataPath, appPath } +} + +async function executePrivilegedShell(command: string): Promise { + const result = await runProcess({ program: '/bin/sh', args: ['-c', command] }) + if (result.code !== 0) { + const error = new Error( + result.stderr || result.stdout || `Privileged shell exited ${result.code}.` + ) + Object.assign(error, { code: result.code, stderr: result.stderr }) + throw error + } +} + +function fixtureInstallerOptions(fixture: Awaited>) { + return { + platform: 'darwin' as const, + isPackaged: false, + userDataPath: fixture.userDataPath, + appPath: fixture.appPath, + execPath: '/Applications/Orca.app/Contents/MacOS/Orca', + commandPathOverride: fixture.commandPath, + processPathEnv: fixture.protectedDirectory + } +} + +describe.skipIf(process.platform !== 'darwin' || process.getuid?.() === 0)( + 'macOS privileged CLI command transaction', + () => { + it('installs and removes through the generated no-overwrite shell transaction', async () => { + const fixture = await createPrivilegedFixture() + const commands: string[] = [] + const installer = new CliInstaller({ + ...fixtureInstallerOptions(fixture), + privilegedRunner: async (command) => { + commands.push(command) + await chmod(fixture.protectedDirectory, 0o700) + await executePrivilegedShell(command) + } + }) + + await chmod(fixture.protectedDirectory, 0o500) + const installed = await installer.install() + expect(installed.state).toBe('installed') + await expect(readlink(fixture.commandPath)).resolves.toBe(installed.launcherPath) + + await chmod(fixture.protectedDirectory, 0o500) + await expect(installer.remove()).resolves.toMatchObject({ state: 'not_installed' }) + expect(commands).toHaveLength(2) + expect(commands.every((command) => command.includes('/bin/ln -P'))).toBe(true) + expect(commands.every((command) => !command.includes('mv -f'))).toBe(true) + }) + + it('restores a trailing-newline symlink inserted after privileged inspection', async () => { + const fixture = await createPrivilegedFixture() + const staleTarget = join(fixture.userDataPath, 'cli', 'bin', 'old', 'orca') + const foreignTarget = `${staleTarget}\n` + await symlink(staleTarget, fixture.commandPath) + const original = await lstat(fixture.commandPath, { bigint: true }) + let raced = false + const installer = new CliInstaller({ + ...fixtureInstallerOptions(fixture), + privilegedRunner: async (command) => { + await chmod(fixture.protectedDirectory, 0o700) + if (!raced) { + raced = true + await unlink(fixture.commandPath) + await symlink(foreignTarget, fixture.commandPath) + } + const replacement = await lstat(fixture.commandPath, { bigint: true }) + await executePrivilegedShell( + command.replace( + `${original.dev}:${original.ino}`, + `${replacement.dev}:${replacement.ino}` + ) + ) + } + }) + + await chmod(fixture.protectedDirectory, 0o500) + await expect(installer.install()).rejects.toThrow() + await expect(readlink(fixture.commandPath)).resolves.toBe(foreignTarget) + expect( + (await readdir(fixture.protectedDirectory)).some((name) => name.startsWith('.orca-cli-')) + ).toBe(false) + }) + + it('restores a managed file changed in place after privileged inspection', async () => { + const fixture = await createPrivilegedFixture() + const oldCliPath = join(fixture.root, 'old', 'out', 'cli', 'index.js') + await writeFile( + fixture.commandPath, + buildUnixDevLauncher('/Applications/Old.app/Contents/MacOS/Orca', oldCliPath, 'user-data') + ) + const foreignContent = 'foreign command written into the inspected inode' + let raced = false + const installer = new CliInstaller({ + ...fixtureInstallerOptions(fixture), + privilegedRunner: async (command) => { + await chmod(fixture.protectedDirectory, 0o700) + if (!raced) { + raced = true + await writeFile(fixture.commandPath, foreignContent) + } + await executePrivilegedShell(command) + } + }) + + await chmod(fixture.protectedDirectory, 0o500) + await expect(installer.install()).rejects.toThrow() + await expect(readFile(fixture.commandPath, 'utf8')).resolves.toBe(foreignContent) + expect( + (await readdir(fixture.protectedDirectory)).some((name) => name.startsWith('.orca-cli-')) + ).toBe(false) + }) + + it('restores the displaced command when publication setup fails', async () => { + const fixture = await createPrivilegedFixture() + const staleTarget = join(fixture.userDataPath, 'cli', 'bin', 'old', 'orca') + await symlink(staleTarget, fixture.commandPath) + const installer = new CliInstaller({ + ...fixtureInstallerOptions(fixture), + privilegedRunner: async (command) => { + await chmod(fixture.protectedDirectory, 0o700) + const sabotaged = command.replace(/\/bin\/mkdir ('[^']*\/publish')/, '/usr/bin/false') + expect(sabotaged).not.toBe(command) + await executePrivilegedShell(sabotaged) + } + }) + + await chmod(fixture.protectedDirectory, 0o500) + await expect(installer.install()).rejects.toThrow() + await expect(readlink(fixture.commandPath)).resolves.toBe(staleTarget) + expect( + (await readdir(fixture.protectedDirectory)).some((name) => name.startsWith('.orca-cli-')) + ).toBe(false) + }) + } +) diff --git a/src/main/cli/cli-install-location.ts b/src/main/cli/cli-install-location.ts index 0d0321df34a..228e574addc 100644 --- a/src/main/cli/cli-install-location.ts +++ b/src/main/cli/cli-install-location.ts @@ -3,6 +3,16 @@ import { homedir } from 'node:os' import { basename, dirname, join } from 'node:path' import { getAppEnvironment } from '../../shared/app-environment' import type { CliInstallStatus } from '../../shared/cli-install-types' +import { + hasAppImagePathEnvironment, + resolveAppImageRuntimeIdentity +} from '../appimage-runtime-identity' +import { + getAppImageCacheRootPath, + resolveAppImageExtractedRoot, + type AppImageExtractionOptions +} from './appimage-extracted-root' +import { getBundledLauncherPath, LINUX_CLI_COMMAND_NAME } from './bundled-cli-launcher-path' import { DEFAULT_MAC_COMMAND_PATH, DEV_COMMAND_NAME } from './cli-install-constants' import { ensureDevLauncher } from './cli-dev-launcher' import type { CliInstallerOptions, InstallSpec } from './cli-installer-contracts' @@ -13,7 +23,6 @@ import { uniquePathEntries } from './cli-install-path-format' import { runMacPrivilegedCommand, writeWindowsUserPath } from './cli-privileged-processes' -import { getBundledLauncherPath, LINUX_CLI_COMMAND_NAME } from './bundled-cli-launcher-path' import { invalidateWindowsUserPathRegistryCache, readFreshWindowsUserPathRegistry, @@ -46,6 +55,9 @@ export abstract class CliInstallLocation { protected readonly userPathCacheInvalidator: () => void protected readonly windowsEnvironment: NodeJS.ProcessEnv protected readonly appImagePath: string | null + protected readonly hasUnverifiedAppImageRuntime: boolean + protected readonly appImageCacheRootPath: string + protected readonly appImageExtractRunner?: (appImagePath: string, cwd: string) => Promise protected get commandName(): string { if (!this.isPackaged && !this.commandPathOverride) { @@ -84,10 +96,27 @@ export abstract class CliInstallLocation { this.userPathCacheInvalidator = options.userPathCacheInvalidator ?? invalidateWindowsUserPathRegistryCache this.windowsEnvironment = options.windowsEnvironment ?? process.env + const hasExplicitAppImagePath = Object.hasOwn(options, 'appImagePath') + const runtimeAppImageIdentity = resolveAppImageRuntimeIdentity({ + platform: this.platform, + execPath: this.execPathValue, + resourcesPath: this.resourcesPath + }) + this.hasUnverifiedAppImageRuntime = + this.platform === 'linux' && + this.isPackaged && + !hasExplicitAppImagePath && + hasAppImagePathEnvironment() && + !runtimeAppImageIdentity this.appImagePath = this.platform === 'linux' && this.isPackaged - ? (options.appImagePath ?? process.env.APPIMAGE ?? null) + ? hasExplicitAppImagePath + ? (options.appImagePath ?? null) + : (runtimeAppImageIdentity?.appImagePath ?? null) : null + this.appImageCacheRootPath = + options.appImageCacheRootPath ?? getAppImageCacheRootPath(this.homePath) + this.appImageExtractRunner = options.appImageExtractRunner } protected resolveInstallSpec(): InstallSpec | null { @@ -99,7 +128,7 @@ export abstract class CliInstallLocation { if (this.platform === 'darwin' || this.platform === 'linux') { return { commandPath, - installMethod: this.isLinuxAppImage() ? 'wrapper' : 'symlink' + installMethod: 'symlink' } } @@ -212,8 +241,18 @@ export abstract class CliInstallLocation { return null } + if (this.hasUnverifiedAppImageRuntime) { + return null + } + if (this.isLinuxAppImage()) { - return this.appImagePath && existsSync(this.appImagePath) ? this.appImagePath : null + if (!this.appImagePath || !existsSync(this.appImagePath)) { + return null + } + const extractionOptions = this.appImageExtractionOptions() + return extractionOptions + ? (resolveAppImageExtractedRoot(extractionOptions)?.stableLauncherPath ?? null) + : null } if (this.isPackaged) { @@ -229,4 +268,14 @@ export abstract class CliInstallLocation { commandName: this.commandName }) } + + protected appImageExtractionOptions(): AppImageExtractionOptions | null { + return this.appImagePath + ? { + appImagePath: this.appImagePath, + cacheRootPath: this.appImageCacheRootPath, + runExtract: this.appImageExtractRunner + } + : null + } } diff --git a/src/main/cli/cli-installer-appimage-ownership.test.ts b/src/main/cli/cli-installer-appimage-ownership.test.ts new file mode 100644 index 00000000000..f3b5107c331 --- /dev/null +++ b/src/main/cli/cli-installer-appimage-ownership.test.ts @@ -0,0 +1,283 @@ +import { + lstat, + mkdir, + mkdtemp, + readlink, + readdir, + rename, + rm, + symlink, + unlink, + writeFile +} from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { CliInstallStatus } from '../../shared/cli-install-types' +import { resolveAppImageExtractedRoot, type AppImageExtractedRoot } from './appimage-extracted-root' + +vi.mock('electron', () => ({ + app: { + isPackaged: false, + getPath: () => tmpdir(), + getAppPath: () => tmpdir() + } +})) + +import { CliInstaller } from './cli-installer' + +const created: string[] = [] + +type Fixture = Awaited> + +afterEach(async () => { + vi.unstubAllEnvs() + await Promise.all(created.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +async function makeFixture() { + const root = await mkdtemp(join(tmpdir(), 'orca-cli-appimage-ownership-')) + created.push(root) + const appImagePath = join(root, 'Orca.AppImage') + const cacheRootPath = join(root, 'cache') + const commandDirectory = join(root, 'home', '.local', 'bin') + const commandPath = join(commandDirectory, 'orca-ide') + await mkdir(commandDirectory, { recursive: true }) + await writeFile(appImagePath, '#!/usr/bin/env bash\n', { mode: 0o755 }) + return { root, appImagePath, cacheRootPath, commandDirectory, commandPath } +} + +async function extractPayload(_appImagePath: string, cwd: string): Promise { + const launcherDirectory = join(cwd, 'squashfs-root', 'resources', 'bin') + await mkdir(launcherDirectory, { recursive: true }) + await writeFile(join(launcherDirectory, 'orca-ide'), '#!/usr/bin/env bash\n', { mode: 0o755 }) +} + +function installerOptions(fixture: Fixture) { + return { + platform: 'linux' as const, + isPackaged: true, + userDataPath: join(fixture.root, 'user-data'), + resourcesPath: join(fixture.root, 'mount', 'resources'), + execPath: join(fixture.root, 'mount', 'orca-ide'), + appPath: join(fixture.root, 'mount', 'resources', 'app.asar'), + homePath: join(fixture.root, 'home'), + processPathEnv: fixture.commandDirectory, + appImagePath: fixture.appImagePath, + appImageCacheRootPath: fixture.cacheRootPath, + appImageExtractRunner: extractPayload + } +} + +describe.skipIf(process.platform === 'win32')('AppImage CLI ownership', () => { + it('uses the mounted bundled launcher when only APPDIR is inherited', async () => { + const fixture = await makeFixture() + const resourcesPath = join(fixture.root, 'mounted', 'resources') + const launcherPath = join(resourcesPath, 'bin', 'orca-ide') + await mkdir(dirname(launcherPath), { recursive: true }) + await writeFile(launcherPath, '#!/usr/bin/env bash\n', { mode: 0o755 }) + vi.stubEnv('APPIMAGE', '') + vi.stubEnv('APPDIR', dirname(resourcesPath)) + + const installer = new CliInstaller({ + platform: 'linux', + isPackaged: true, + userDataPath: join(fixture.root, 'user-data'), + resourcesPath, + execPath: join(dirname(resourcesPath), 'orca-ide'), + appPath: join(resourcesPath, 'app.asar'), + homePath: join(fixture.root, 'home'), + processPathEnv: fixture.commandDirectory, + commandPathOverride: fixture.commandPath + }) + + await expect(installer.getStatus()).resolves.toMatchObject({ + state: 'not_installed', + launcherPath + }) + await expect(installer.install()).resolves.toMatchObject({ + state: 'installed', + launcherPath + }) + await expect(readlink(fixture.commandPath)).resolves.toBe(launcherPath) + }) + + it('ignores inherited APPIMAGE without the matching runtime identity', async () => { + const fixture = await makeFixture() + const resourcesPath = join(fixture.root, 'installed', 'resources') + const launcherPath = join(resourcesPath, 'bin', 'orca-ide') + await mkdir(dirname(launcherPath), { recursive: true }) + await writeFile(launcherPath, '#!/usr/bin/env bash\n', { mode: 0o755 }) + vi.stubEnv('APPIMAGE', fixture.appImagePath) + vi.stubEnv('APPDIR', '') + const extract = vi.fn(extractPayload) + + const installer = new CliInstaller({ + platform: 'linux', + isPackaged: true, + userDataPath: join(fixture.root, 'user-data'), + resourcesPath, + execPath: join(fixture.root, 'installed', 'orca-ide'), + appPath: join(resourcesPath, 'app.asar'), + homePath: join(fixture.root, 'home'), + processPathEnv: fixture.commandDirectory, + appImageCacheRootPath: fixture.cacheRootPath, + appImageExtractRunner: extract + }) + + await expect(installer.getStatus()).resolves.toMatchObject({ + state: 'unsupported', + launcherPath: null, + detail: expect.stringContaining('could not verify') + }) + await expect(installer.install()).rejects.toThrow('could not verify') + expect(extract).not.toHaveBeenCalled() + }) + + it('refuses an arbitrary resources/bin/orca-ide symlink', async () => { + const fixture = await makeFixture() + const foreignTarget = join(fixture.root, 'foreign', 'resources', 'bin', 'orca-ide') + await symlink(foreignTarget, fixture.commandPath) + const extract = vi.fn(extractPayload) + const installer = new CliInstaller({ + ...installerOptions(fixture), + appImageExtractRunner: extract + }) + + await expect(installer.getStatus()).resolves.toMatchObject({ state: 'conflict' }) + await expect(installer.install()).rejects.toThrow('Refusing to replace non-Orca command') + await expect(readlink(fixture.commandPath)).resolves.toBe(foreignTarget) + expect(extract).not.toHaveBeenCalled() + }) + + it('leaves a foreign legacy resources/bin/orca symlink untouched', async () => { + const fixture = await makeFixture() + const legacyCommandPath = join(fixture.commandDirectory, 'orca') + const foreignTarget = join(fixture.root, 'foreign', 'resources', 'bin', 'orca') + await symlink(foreignTarget, legacyCommandPath) + + await expect(new CliInstaller(installerOptions(fixture)).install()).resolves.toMatchObject({ + state: 'installed' + }) + await expect(readlink(legacyCommandPath)).resolves.toBe(foreignTarget) + }) + + it('keeps installation bound to the extracted generation', async () => { + const fixture = await makeFixture() + let extractedRoot: AppImageExtractedRoot | null = null + class ReplacingAppImageInstaller extends CliInstaller { + protected override async ensureLinuxAppImagePayload(): Promise { + extractedRoot = await super.ensureLinuxAppImagePayload() + await writeFile(fixture.appImagePath, '#!/usr/bin/env bash\n# replacement generation\n', { + mode: 0o755 + }) + return extractedRoot + } + } + + const installer = new ReplacingAppImageInstaller(installerOptions(fixture)) + const installed = await installer.install() + const capturedRoot = extractedRoot as AppImageExtractedRoot | null + + expect(capturedRoot).not.toBeNull() + expect(installed).toMatchObject({ + state: 'stale', + launcherPath: capturedRoot!.stableLauncherPath + }) + await expect(readlink(fixture.commandPath)).resolves.toBe(capturedRoot!.stableLauncherPath) + await expect(lstat(capturedRoot!.stableLauncherPath)).resolves.toBeDefined() + await expect(installer.getStatus()).resolves.toMatchObject({ state: 'stale' }) + }) + + it('repairs the stable endpoint without reclaiming the prior path owner', async () => { + const fixture = await makeFixture() + const firstInstaller = new CliInstaller(installerOptions(fixture)) + const first = await firstInstaller.install() + const firstRoot = resolveAppImageExtractedRoot({ + appImagePath: fixture.appImagePath, + cacheRootPath: fixture.cacheRootPath + })! + const relocatedPath = join(fixture.root, 'downloads', 'Orca.AppImage') + await mkdir(dirname(relocatedPath), { recursive: true }) + await rename(fixture.appImagePath, relocatedPath) + const relocatedFixture = { ...fixture, appImagePath: relocatedPath } + const relocatedInstaller = new CliInstaller(installerOptions(relocatedFixture)) + + await expect(relocatedInstaller.getStatus()).resolves.toMatchObject({ state: 'stale' }) + const repaired = await relocatedInstaller.install() + const relocatedRoot = resolveAppImageExtractedRoot({ + appImagePath: relocatedPath, + cacheRootPath: fixture.cacheRootPath + })! + + expect(repaired).toMatchObject({ state: 'installed', launcherPath: first.launcherPath }) + await expect(readlink(fixture.commandPath)).resolves.toBe(first.launcherPath) + await expect(lstat(relocatedRoot.payloadLauncherPath)).resolves.toBeDefined() + // A path namespace is an ownership boundary; the relocated process cannot prove that no + // sibling AppImage still owns the prior namespace. + await expect(lstat(firstRoot.rootPath)).resolves.toBeDefined() + }) + + it('preserves a foreign command that appears at the final ownership fence', async () => { + const fixture = await makeFixture() + const predictedRoot = resolveAppImageExtractedRoot({ + appImagePath: fixture.appImagePath, + cacheRootPath: fixture.cacheRootPath + })! + const ownedOldTarget = join( + dirname(predictedRoot.rootPath), + 'a'.repeat(24), + 'resources', + 'bin', + 'orca-ide' + ) + const foreignTarget = join(fixture.root, 'foreign', 'orca-ide') + await symlink(ownedOldTarget, fixture.commandPath) + + class RacedInstaller extends CliInstaller { + private inspectionCount = 0 + + protected override async inspectSymlink( + commandPath: string, + launcherPath: string + ): Promise { + this.inspectionCount += 1 + if (this.inspectionCount === 3) { + await unlink(commandPath) + await symlink(foreignTarget, commandPath) + } + return super.inspectSymlink(commandPath, launcherPath) + } + } + + await expect(new RacedInstaller(installerOptions(fixture)).install()).rejects.toThrow( + 'Refusing to replace non-Orca command' + ) + await expect(readlink(fixture.commandPath)).resolves.toBe(foreignTarget) + expect((await readdir(fixture.commandDirectory)).some((name) => name.includes('.orca-'))).toBe( + false + ) + }) + + // #15081 review: the Linux reclaim rule was narrowed to extracted-cache launchers, which left a + // deb/rpm -> AppImage migration wedged on its own leftover symlink. + it('reclaims a symlink left by a packaged deb/rpm install', async () => { + for (const directory of ['/opt/Orca', '/opt/orca-ide', '/opt/orca']) { + const fixture = await makeFixture() + await symlink(`${directory}/resources/bin/orca-ide`, fixture.commandPath) + + await expect(new CliInstaller(installerOptions(fixture)).getStatus()).resolves.toMatchObject({ + state: 'stale' + }) + } + }) + + it('still refuses a launcher-named symlink outside the packaged install tree', async () => { + const fixture = await makeFixture() + await symlink('/opt/not-orca/resources/bin/orca-ide', fixture.commandPath) + + await expect(new CliInstaller(installerOptions(fixture)).getStatus()).resolves.toMatchObject({ + state: 'conflict' + }) + }) +}) diff --git a/src/main/cli/cli-installer-appimage-removal.test.ts b/src/main/cli/cli-installer-appimage-removal.test.ts new file mode 100644 index 00000000000..9483141184c --- /dev/null +++ b/src/main/cli/cli-installer-appimage-removal.test.ts @@ -0,0 +1,192 @@ +import { existsSync } from 'node:fs' +import { mkdir, mkdtemp, readlink, rm, symlink, unlink, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' + +vi.mock('electron', () => ({ + app: { isPackaged: false, getPath: () => tmpdir(), getAppPath: () => tmpdir() } +})) + +import { resolveAppImageExtractedRoot } from './appimage-extracted-root' +import { + publishAppImageLauncherEndpoint, + resolveAppImageLauncherEndpointPath, + resolveAppImageStableLauncherPath +} from './appimage-stable-launcher' +import { CliInstaller } from './cli-installer' +import type { CliInstallerOptions } from './cli-installer-contracts' + +const created: string[] = [] + +afterEach(async () => { + await Promise.all(created.splice(0).map((path) => rm(path, { recursive: true, force: true }))) +}) + +describe.skipIf(process.platform === 'win32')('AppImage CLI removal', () => { + it.each([false, true])( + 'removes installed payloads and the legacy live endpoint (command missing: %s)', + async (removeCommandFirst) => { + const root = await mkdtemp(join(tmpdir(), 'orca-appimage-cli-remove-')) + created.push(root) + const appImagePath = join(root, 'Orca.AppImage') + const cacheRootPath = join(root, 'cache') + const commandPath = join(root, 'home', '.local', 'bin', 'orca-ide') + const resourcesPath = join(root, 'mount', 'resources') + const liveLauncherPath = join(resourcesPath, 'bin', 'orca-ide') + await mkdir(join(resourcesPath, 'bin'), { recursive: true }) + await writeFile(appImagePath, '#!/usr/bin/env bash\n', { mode: 0o755 }) + await writeFile(liveLauncherPath, '#!/usr/bin/env bash\nprintf live', { mode: 0o755 }) + const liveEndpointPath = resolveAppImageLauncherEndpointPath(cacheRootPath, 'live') + await mkdir(dirname(liveEndpointPath), { recursive: true }) + await symlink(liveLauncherPath, liveEndpointPath) + + const installer = new CliInstaller({ + platform: 'linux', + isPackaged: true, + userDataPath: join(root, 'user-data'), + resourcesPath, + execPath: join(root, 'mount', 'orca-ide'), + appPath: join(resourcesPath, 'app.asar'), + homePath: join(root, 'home'), + processPathEnv: join(root, 'home', '.local', 'bin'), + commandPathOverride: commandPath, + appImagePath, + appImageCacheRootPath: cacheRootPath, + appImageExtractRunner: async (_path, cwd) => { + const payloadDirectory = join(cwd, 'squashfs-root', 'resources', 'bin') + await mkdir(payloadDirectory, { recursive: true }) + await writeFile( + join(payloadDirectory, 'orca-ide'), + '#!/usr/bin/env bash\nprintf installed', + { + mode: 0o755 + } + ) + } + }) + + const installed = await installer.install() + const extractedRoot = resolveAppImageExtractedRoot({ appImagePath, cacheRootPath })! + expect(await readlink(commandPath)).toBe(installed.launcherPath) + expect(existsSync(extractedRoot.rootPath)).toBe(true) + if (removeCommandFirst) { + await unlink(commandPath) + } + + await expect(installer.remove()).resolves.toMatchObject({ state: 'not_installed' }) + + expect(existsSync(commandPath)).toBe(false) + expect(existsSync(extractedRoot.rootPath)).toBe(false) + expect(existsSync(resolveAppImageLauncherEndpointPath(cacheRootPath, 'installed'))).toBe( + false + ) + expect(existsSync(resolveAppImageLauncherEndpointPath(cacheRootPath, 'live'))).toBe(false) + const stableLauncherPath = resolveAppImageStableLauncherPath(cacheRootPath) + expect(existsSync(stableLauncherPath)).toBe(true) + } + ) + + it('does not remove a sibling AppImage registration or payload', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-appimage-cli-siblings-')) + created.push(root) + const cacheRootPath = join(root, 'cache') + const commandPath = join(root, 'home', '.local', 'bin', 'orca-ide') + const resourcesPath = join(root, 'mount', 'resources') + const firstAppImagePath = join(root, 'Orca-stable.AppImage') + const secondAppImagePath = join(root, 'Orca-nightly.AppImage') + await mkdir(join(resourcesPath, 'bin'), { recursive: true }) + await Promise.all([ + writeFile(firstAppImagePath, '#!/usr/bin/env bash\n', { mode: 0o755 }), + writeFile(secondAppImagePath, '#!/usr/bin/env bash\n# nightly\n', { mode: 0o755 }) + ]) + const installerOptions = (appImagePath: string, content: string): CliInstallerOptions => ({ + platform: 'linux', + isPackaged: true, + userDataPath: join(root, 'user-data'), + resourcesPath, + execPath: join(root, 'mount', 'orca-ide'), + appPath: join(resourcesPath, 'app.asar'), + homePath: join(root, 'home'), + processPathEnv: join(root, 'home', '.local', 'bin'), + commandPathOverride: commandPath, + appImagePath, + appImageCacheRootPath: cacheRootPath, + appImageExtractRunner: async (_path, cwd) => { + const payloadDirectory = join(cwd, 'squashfs-root', 'resources', 'bin') + await mkdir(payloadDirectory, { recursive: true }) + await writeFile(join(payloadDirectory, 'orca-ide'), content, { mode: 0o755 }) + } + }) + class HookedInstaller extends CliInstaller { + afterNextStatus: (() => Promise) | null = null + + override async getStatus() { + const status = await super.getStatus() + const hook = this.afterNextStatus + this.afterNextStatus = null + await hook?.() + return status + } + } + let siblingStatusReads = 0 + let rejectSiblingStatusRead = false + class TrackingInstaller extends CliInstaller { + override async getStatus() { + if (rejectSiblingStatusRead) { + throw new Error('sibling status read before registration lock release') + } + siblingStatusReads += 1 + return super.getStatus() + } + } + const firstInstaller = new HookedInstaller(installerOptions(firstAppImagePath, 'stable')) + const secondInstaller = new TrackingInstaller(installerOptions(secondAppImagePath, 'nightly')) + + await firstInstaller.install() + const firstRoot = resolveAppImageExtractedRoot({ + appImagePath: firstAppImagePath, + cacheRootPath + })! + await secondInstaller.install() + const secondRoot = resolveAppImageExtractedRoot({ + appImagePath: secondAppImagePath, + cacheRootPath + })! + + const siblingStatus = await firstInstaller.getStatus() + expect(firstInstaller.isAppImageRegistrationOwnedBySibling(siblingStatus)).toBe(true) + await rm(resolveAppImageStableLauncherPath(cacheRootPath)) + const brokenLauncherStatus = await firstInstaller.getStatus() + expect(firstInstaller.isAppImageRegistrationOwnedBySibling(brokenLauncherStatus)).toBe(false) + publishAppImageLauncherEndpoint(cacheRootPath, 'installed', secondRoot.payloadLauncherPath) + + await firstInstaller.remove() + + expect(existsSync(firstRoot.rootPath)).toBe(false) + expect(existsSync(secondRoot.rootPath)).toBe(true) + expect(existsSync(commandPath)).toBe(true) + await expect( + readlink(resolveAppImageLauncherEndpointPath(cacheRootPath, 'installed')) + ).resolves.toBe(secondRoot.payloadLauncherPath) + + await firstInstaller.install() + let siblingInstall: Promise | null = null + siblingStatusReads = 0 + firstInstaller.afterNextStatus = async () => { + rejectSiblingStatusRead = true + siblingInstall = secondInstaller.install() + } + await firstInstaller.remove() + rejectSiblingStatusRead = false + expect(siblingInstall).not.toBeNull() + await siblingInstall + + expect(siblingStatusReads).toBeGreaterThan(0) + expect(existsSync(commandPath)).toBe(true) + expect(existsSync(secondRoot.rootPath)).toBe(true) + await expect( + readlink(resolveAppImageLauncherEndpointPath(cacheRootPath, 'installed')) + ).resolves.toBe(secondRoot.payloadLauncherPath) + }) +}) diff --git a/src/main/cli/cli-installer-contracts.ts b/src/main/cli/cli-installer-contracts.ts index 5bb3bb99d7e..40a75cb984f 100644 --- a/src/main/cli/cli-installer-contracts.ts +++ b/src/main/cli/cli-installer-contracts.ts @@ -20,8 +20,10 @@ export type CliInstallerOptions = { userPathWriter?: (value: string) => Promise userPathCacheInvalidator?: () => void windowsEnvironment?: NodeJS.ProcessEnv - /** Why: AppImage reports a stable outer file path via $APPIMAGE while bundled resources live in an ephemeral FUSE mount. */ + /** Trusted caller override; production discovers AppImage only from a complete runtime identity. */ appImagePath?: string | null + appImageCacheRootPath?: string + appImageExtractRunner?: (appImagePath: string, cwd: string) => Promise } export type InstallSpec = { diff --git a/src/main/cli/cli-installer.test.ts b/src/main/cli/cli-installer.test.ts index 7a2e86afb24..1a5279644e9 100644 --- a/src/main/cli/cli-installer.test.ts +++ b/src/main/cli/cli-installer.test.ts @@ -1,6 +1,17 @@ -import { chmod, lstat, mkdir, readFile, readlink, symlink, writeFile } from 'node:fs/promises' +import { existsSync } from 'node:fs' +import { + chmod, + lstat, + mkdir, + readFile, + readdir, + readlink, + rm, + symlink, + writeFile +} from 'node:fs/promises' import { tmpdir } from 'node:os' -import { dirname, join } from 'node:path' +import { dirname, join, relative, sep } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const execFileMock = vi.hoisted(() => vi.fn()) @@ -18,8 +29,20 @@ vi.mock('node:child_process', () => ({ })) import { CliInstaller } from './cli-installer' -import { buildAppImageCliWrapper } from './appimage-cli-wrapper' import { makeFixture } from './cli-installer-test-fixtures' +import { resolveAppImageExtractedRoot } from './appimage-extracted-root' +import { buildLegacyAppImageCliWrapper } from './legacy-appimage-cli-wrapper' + +// Stands in for the AppImage runtime's `--appimage-extract`, which writes the +// payload to ./squashfs-root relative to cwd. +async function fakeAppImageExtractRunner(_appImagePath: string, cwd: string): Promise { + const launcherDir = join(cwd, 'squashfs-root', 'resources', 'bin') + await mkdir(launcherDir, { recursive: true }) + await writeFile(join(launcherDir, 'orca-ide'), '#!/usr/bin/env bash\n', { + encoding: 'utf8', + mode: 0o755 + }) +} describe('CliInstaller', () => { beforeEach(() => { @@ -127,15 +150,18 @@ describe('CliInstaller', () => { } ) - // Why: AppImage resources live under a per-launch FUSE mount, so the - // installed shell command must be a stable wrapper rather than a symlink. + // Why: an AppImage's payload is only reachable through a FUSE mount that its + // own AppRun sets up, and AppRun prepends `--no-sandbox` into node mode on + // userns-restricted hosts (#11609). Extracting once gives the command the + // same plain launcher a deb install ships, so registration is a symlink. it.skipIf(process.platform === 'win32')( - 'creates an AppImage wrapper under the linux command path', + 'symlinks the linux command at the extracted AppImage launcher', async () => { const fixture = await makeFixture() const commandDir = join(fixture.root, '.local', 'bin') const installPath = join(commandDir, 'orca-ide') const appImagePath = join(fixture.root, 'Orca.AppImage') + const cacheRootPath = join(fixture.root, 'cache') await writeFile(appImagePath, '#!/usr/bin/env bash\n', { encoding: 'utf8', mode: 0o755 @@ -144,77 +170,134 @@ describe('CliInstaller', () => { const installer = new CliInstaller({ platform: 'linux', isPackaged: true, + userDataPath: fixture.userDataPath, + appPath: fixture.appPath, appImagePath, + appImageCacheRootPath: cacheRootPath, + appImageExtractRunner: fakeAppImageExtractRunner, commandPathOverride: installPath, processPathEnv: commandDir }) const initial = await installer.getStatus() - expect(initial).toMatchObject({ - state: 'not_installed', - installMethod: 'wrapper', - launcherPath: appImagePath - }) + expect(initial).toMatchObject({ state: 'not_installed', installMethod: 'symlink' }) const installed = await installer.install() expect(installed).toMatchObject({ state: 'installed', commandName: 'orca-ide', - installMethod: 'wrapper', - launcherPath: appImagePath, - currentTarget: appImagePath, + installMethod: 'symlink', pathConfigured: true }) + // The command target remains stable while its cache endpoint advances generations. + expect(relative(cacheRootPath, installed.launcherPath as string).split(sep)).toEqual([ + 'launcher', + 'orca-ide' + ]) + expect(installed.currentTarget).toBe(installed.launcherPath) + await expect(readlink(installPath)).resolves.toBe(installed.launcherPath) - const commandStats = await lstat(installPath) - expect(commandStats.isFile()).toBe(true) - expect(commandStats.mode & 0o111).not.toBe(0) - await expect(readlink(installPath)).rejects.toMatchObject({ code: 'EINVAL' }) - await expect(readFile(installPath, 'utf8')).resolves.toBe( - buildAppImageCliWrapper(appImagePath) - ) + const extractedRoot = resolveAppImageExtractedRoot({ appImagePath, cacheRootPath })! + await rm(extractedRoot.rootPath, { recursive: true, force: true }) + await expect(installer.getStatus()).resolves.toMatchObject({ state: 'stale' }) + const repaired = await installer.install() + expect(repaired.state).toBe('installed') const removed = await installer.remove() expect(removed.state).toBe('not_installed') + await expect(lstat(repaired.launcherPath as string)).resolves.toBeDefined() + expect( + existsSync(resolveAppImageExtractedRoot({ appImagePath, cacheRootPath })!.rootPath) + ).toBe(false) } ) it.skipIf(process.platform === 'win32')( - 'reports a stale AppImage wrapper when the AppImage path changes', + 're-extracts and re-points the command when the AppImage is replaced', async () => { const fixture = await makeFixture() const commandDir = join(fixture.root, '.local', 'bin') const installPath = join(commandDir, 'orca-ide') - const oldAppImagePath = join(fixture.root, 'Old-Orca.AppImage') - const newAppImagePath = join(fixture.root, 'Orca.AppImage') - await mkdir(commandDir, { recursive: true }) - await writeFile(installPath, buildAppImageCliWrapper(oldAppImagePath), { + const appImagePath = join(fixture.root, 'Orca.AppImage') + const cacheRootPath = join(fixture.root, 'cache') + await writeFile(appImagePath, '#!/usr/bin/env bash\n', { encoding: 'utf8', mode: 0o755 }) - await writeFile(newAppImagePath, '#!/usr/bin/env bash\n', { + const makeInstaller = (): CliInstaller => + new CliInstaller({ + platform: 'linux', + isPackaged: true, + userDataPath: fixture.userDataPath, + appPath: fixture.appPath, + appImagePath, + appImageCacheRootPath: cacheRootPath, + appImageExtractRunner: fakeAppImageExtractRunner, + commandPathOverride: installPath, + processPathEnv: commandDir + }) + + const first = await makeInstaller().install() + expect(first.state).toBe('installed') + + // An update replaces the file in place, so size and mtime both change. + await writeFile(appImagePath, '#!/usr/bin/env bash\n# next version\n', { encoding: 'utf8', mode: 0o755 }) + await expect(makeInstaller().getStatus()).resolves.toMatchObject({ state: 'stale' }) + + const second = await makeInstaller().install() + expect(second.state).toBe('installed') + expect(second.launcherPath).toBe(first.launcherPath) + await expect(readlink(installPath)).resolves.toBe(second.launcherPath) + // Only the live payload survives the upgrade. + const liveRoot = resolveAppImageExtractedRoot({ appImagePath, cacheRootPath })!.rootPath + await expect(readdir(dirname(liveRoot))).resolves.toHaveLength(1) + } + ) + + it.skipIf(process.platform === 'win32')( + 'replaces and removes the legacy AppImage wrapper', + async () => { + const fixture = await makeFixture() + const commandDir = join(fixture.root, '.local', 'bin') + const installPath = join(commandDir, 'orca-ide') + const appImagePath = join(fixture.root, "Orca's AppImage.AppImage") + const cacheRootPath = join(fixture.root, 'cache') + await mkdir(commandDir, { recursive: true }) + await writeFile(appImagePath, '#!/usr/bin/env bash\n', { encoding: 'utf8', mode: 0o755 }) + await writeFile(installPath, buildLegacyAppImageCliWrapper(appImagePath), { + encoding: 'utf8', + mode: 0o755 + }) const installer = new CliInstaller({ platform: 'linux', isPackaged: true, - appImagePath: newAppImagePath, + userDataPath: fixture.userDataPath, + appPath: fixture.appPath, + appImagePath, + appImageCacheRootPath: cacheRootPath, + appImageExtractRunner: fakeAppImageExtractRunner, commandPathOverride: installPath, processPathEnv: commandDir }) await expect(installer.getStatus()).resolves.toMatchObject({ state: 'stale', - installMethod: 'wrapper', - currentTarget: newAppImagePath + currentTarget: appImagePath }) - await expect(installer.install()).resolves.toMatchObject({ state: 'installed' }) - await expect(readFile(installPath, 'utf8')).resolves.toBe( - buildAppImageCliWrapper(newAppImagePath) - ) + await expect(readlink(installPath)).resolves.toContain(cacheRootPath) + + await installer.remove() + await writeFile(installPath, buildLegacyAppImageCliWrapper(appImagePath), { + encoding: 'utf8', + mode: 0o755 + }) + await expect(installer.remove()).resolves.toMatchObject({ state: 'not_installed' }) + await expect(lstat(installPath)).rejects.toMatchObject({ code: 'ENOENT' }) } ) @@ -239,6 +322,8 @@ describe('CliInstaller', () => { const installer = new CliInstaller({ platform: 'linux', isPackaged: true, + userDataPath: fixture.userDataPath, + appPath: fixture.appPath, resourcesPath, homePath, processPathEnv: commandDir @@ -251,24 +336,30 @@ describe('CliInstaller', () => { ) it.skipIf(process.platform === 'win32')( - 'removes a legacy linux orca symlink when installing an AppImage wrapper', + 'removes a legacy linux orca symlink when registering from an AppImage', async () => { const fixture = await makeFixture() const homePath = join(fixture.root, 'home') const commandDir = join(homePath, '.local', 'bin') const legacyCommandPath = join(commandDir, 'orca') const appImagePath = join(fixture.root, 'Orca.AppImage') + const cacheRootPath = join(fixture.root, 'cache') await mkdir(commandDir, { recursive: true }) await writeFile(appImagePath, '#!/usr/bin/env bash\n', { encoding: 'utf8', mode: 0o755 }) - await symlink(join('/tmp', '.mount_Orca1234', 'resources', 'bin', 'orca'), legacyCommandPath) + const extractedRoot = resolveAppImageExtractedRoot({ appImagePath, cacheRootPath })! + await symlink(join(dirname(extractedRoot.payloadLauncherPath), 'orca'), legacyCommandPath) const installer = new CliInstaller({ platform: 'linux', isPackaged: true, + userDataPath: fixture.userDataPath, + appPath: fixture.appPath, appImagePath, + appImageCacheRootPath: cacheRootPath, + appImageExtractRunner: fakeAppImageExtractRunner, homePath, processPathEnv: commandDir }) @@ -315,7 +406,9 @@ describe('CliInstaller', () => { expect(installed.pathConfigured).toBe(true) expect(privilegedCommands).toHaveLength(1) expect(privilegedCommands[0]).toContain('mkdir -p') - expect(privilegedCommands[0]).toContain('ln -sfn') + expect(privilegedCommands[0]).toContain('ln -s') + expect(privilegedCommands[0]).toContain('/bin/ln -P') + expect(privilegedCommands[0]).not.toContain('mv -f') await expect(readlink(installPath)).resolves.toBe(installed.launcherPath) } finally { await chmod(protectedDir, 0o700).catch(() => undefined) diff --git a/src/main/cli/cli-installer.ts b/src/main/cli/cli-installer.ts index bc1f8c452b2..d95e1649ac0 100644 --- a/src/main/cli/cli-installer.ts +++ b/src/main/cli/cli-installer.ts @@ -1,10 +1,33 @@ import { mkdir, unlink } from 'node:fs/promises' import { dirname } from 'node:path' import type { CliInstallStatus } from '../../shared/cli-install-types' -import { getBundledLauncherPath } from './bundled-cli-launcher-path' +import { + pruneAppImageExtractedRoots, + removeAppImageInstalledPayloads +} from './appimage-extraction-pruning' +import { withAppImageRegistrationLock } from './appimage-registration-lock' +import { + isAppImageInstalledLauncherCurrent, + isAppImageInstalledLauncherOwnedBySibling, + resolveAppImageNamespacePath +} from './appimage-extracted-root' +import { isAppImageStableLauncherReady } from './appimage-stable-launcher' import { CliPathRegistration } from './cli-path-registration' export class CliInstaller extends CliPathRegistration { + isAppImageRegistrationOwnedBySibling(status: CliInstallStatus): boolean { + if ( + status.currentTarget !== status.launcherPath || + !isAppImageStableLauncherReady(this.appImageCacheRootPath) + ) { + return false + } + const extractionOptions = this.appImageExtractionOptions() + return Boolean( + extractionOptions && isAppImageInstalledLauncherOwnedBySibling(extractionOptions) + ) + } + async getStatus(): Promise { const defaultSpec = this.resolveInstallSpec() if (!defaultSpec) { @@ -26,8 +49,9 @@ export class CliInstaller extends CliPathRegistration { const launcherPath = await this.resolveLauncherPath() if (!launcherPath) { - const detail = - this.isLinuxAppImage() && this.appImagePath + const detail = this.hasUnverifiedAppImageRuntime + ? 'Orca could not verify the inherited AppImage runtime identity, so CLI registration is unavailable.' + : this.isLinuxAppImage() && this.appImagePath ? `The AppImage file at ${this.appImagePath} is missing. Move it back or re-run CLI registration from the current AppImage location.` : this.isPackaged ? 'The bundled CLI launcher is missing from this Orca build.' @@ -48,34 +72,71 @@ export class CliInstaller extends CliPathRegistration { } } + return this.getStatusForLauncher(launcherPath) + } + + private async getStatusForLauncher(launcherPath: string): Promise { + const defaultSpec = this.resolveInstallSpec() + if (!defaultSpec) { + throw new Error('CLI registration is not implemented on this platform.') + } const spec = await this.resolveActiveInstallSpec(defaultSpec, launcherPath) - const baseStatus = + const inspectedStatus = spec.installMethod === 'symlink' ? await this.inspectSymlink(spec.commandPath, launcherPath) - : this.isLinuxAppImage() - ? await this.inspectAppImageWrapper(spec.commandPath, launcherPath) - : await this.inspectWindowsWrapper(spec.commandPath, launcherPath) + : await this.inspectWindowsWrapper(spec.commandPath, launcherPath) + const extractionOptions = this.appImageExtractionOptions() + const baseStatus = + inspectedStatus.state === 'installed' && + extractionOptions && + !isAppImageInstalledLauncherCurrent(extractionOptions) + ? { + ...inspectedStatus, + state: 'stale' as const, + detail: `${spec.commandPath} does not point to the current Orca AppImage payload.` + } + : inspectedStatus const pathDirectory = dirname(spec.commandPath) const pathProbe = await this.probePathConfiguration(pathDirectory) return this.withPathInfo(baseStatus, pathDirectory, pathProbe) } async install(): Promise { - const status = await this.getStatus() + return this.runAppImageRegistrationOperation(() => this.installUnlocked()) + } + + private async installUnlocked(): Promise { + const initialStatus = await this.getStatus() + if ( + !initialStatus.supported || + !initialStatus.commandPath || + !initialStatus.launcherPath || + !initialStatus.installMethod + ) { + throw new Error(initialStatus.detail ?? 'CLI registration is unavailable on this build.') + } + if (initialStatus.state === 'conflict') { + throw new Error( + `Refusing to replace non-Orca command at ${initialStatus.commandPath}. Remove it and register again if it is no longer needed.` + ) + } + const extractedRoot = await this.ensureLinuxAppImagePayload() + const status = extractedRoot + ? await this.getStatusForLauncher(extractedRoot.stableLauncherPath) + : initialStatus if (!status.supported || !status.commandPath || !status.launcherPath || !status.installMethod) { throw new Error(status.detail ?? 'CLI registration is unavailable on this build.') } if (status.state === 'conflict') { - throw new Error(`Refusing to replace non-Orca command at ${status.commandPath}.`) + throw new Error( + `Refusing to replace non-Orca command at ${status.commandPath}. Remove it and register again if it is no longer needed.` + ) } // eslint-disable-next-line unicorn/prefer-ternary -- Why: the install path performs async side effects and is easier to audit as an explicit branch than as an awaited ternary. if (status.installMethod === 'symlink') { await this.installSymlink(status) await this.removeLegacyLinuxCommandIfManaged(status.launcherPath) - } else if (this.isLinuxAppImage()) { - await this.installAppImageWrapper(status.commandPath, status.launcherPath) - await this.removeLegacyLinuxCommandIfManaged(status.launcherPath) } else if (this.isWindowsPackagedBundledCommand(status.commandPath, status.launcherPath)) { // Why: packaged Windows already ships resources/bin/orca.exe; registration only owns the PATH entry. } else { @@ -88,13 +149,23 @@ export class CliInstaller extends CliPathRegistration { // Why: Windows shells find commands via user PATH, so the installer owns that entry, not the desktop installer. await this.ensureWindowsPathEntry(dirname(status.commandPath)) } + if (extractedRoot) { + await pruneAppImageExtractedRoots(extractedRoot.rootPath) + } - return this.getStatus() + return extractedRoot + ? this.getStatusForLauncher(extractedRoot.stableLauncherPath) + : this.getStatus() } async remove(): Promise { + return this.runAppImageRegistrationOperation(() => this.removeUnlocked()) + } + + private async removeUnlocked(): Promise { const status = await this.getStatus() if (!status.supported || !status.commandPath || !status.launcherPath || !status.installMethod) { + await this.removeLinuxAppImagePayloads() return status } if (status.state === 'not_installed') { @@ -103,15 +174,21 @@ export class CliInstaller extends CliPathRegistration { await this.removeWindowsPathEntry(dirname(status.commandPath)) return this.getStatus() } + await this.removeLinuxAppImagePayloads() return status } if (status.state === 'conflict') { throw new Error(`Refusing to remove non-Orca command at ${status.commandPath}.`) } - if (status.state === 'stale') { + if (status.state === 'stale' && status.installMethod !== 'symlink') { throw new Error(`Refusing to remove a command not owned by Orca at ${status.commandPath}.`) } + if (status.state === 'stale' && this.isAppImageRegistrationOwnedBySibling(status)) { + await this.removeLinuxAppImagePayloads() + return this.getStatus() + } + if (status.installMethod === 'symlink') { await this.removeSymlink(status.commandPath) await this.removeLegacyLinuxCommandIfManaged(status.launcherPath) @@ -122,8 +199,20 @@ export class CliInstaller extends CliPathRegistration { await this.removeWindowsPathEntry(dirname(status.commandPath)) } + await this.removeLinuxAppImagePayloads() return this.getStatus() } -} -export { getBundledLauncherPath } + private async removeLinuxAppImagePayloads(): Promise { + const extractionOptions = this.appImageExtractionOptions() + if (this.isLinuxAppImage() && extractionOptions) { + await removeAppImageInstalledPayloads(resolveAppImageNamespacePath(extractionOptions)) + } + } + + private runAppImageRegistrationOperation(operation: () => Promise): Promise { + return this.isLinuxAppImage() + ? withAppImageRegistrationLock(this.appImageCacheRootPath, operation) + : operation() + } +} diff --git a/src/main/cli/legacy-appimage-cli-wrapper.test.ts b/src/main/cli/legacy-appimage-cli-wrapper.test.ts new file mode 100644 index 00000000000..5c4a64f8f3a --- /dev/null +++ b/src/main/cli/legacy-appimage-cli-wrapper.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest' +import { + buildLegacyAppImageCliWrapper, + extractLegacyAppImageCliWrapperTarget +} from './legacy-appimage-cli-wrapper' + +describe('legacy AppImage CLI wrapper', () => { + it('recovers a path containing a newline', () => { + const appImagePath = "/tmp/Orca\nnightly's.AppImage" + expect(extractLegacyAppImageCliWrapperTarget(buildLegacyAppImageCliWrapper(appImagePath))).toBe( + appImagePath + ) + }) + + it('rejects a wrapper with a changed command body', () => { + const wrapper = buildLegacyAppImageCliWrapper('/tmp/Orca.AppImage') + expect( + extractLegacyAppImageCliWrapperTarget(wrapper.replace('set -euo pipefail', 'set -u')) + ).toBe(null) + }) +}) diff --git a/src/main/cli/appimage-cli-wrapper.ts b/src/main/cli/legacy-appimage-cli-wrapper.ts similarity index 59% rename from src/main/cli/appimage-cli-wrapper.ts rename to src/main/cli/legacy-appimage-cli-wrapper.ts index f66ecacfec2..1e22b2dc577 100644 --- a/src/main/cli/appimage-cli-wrapper.ts +++ b/src/main/cli/legacy-appimage-cli-wrapper.ts @@ -1,3 +1,5 @@ +import { quoteShell } from './cli-install-path-format' + const APPIMAGE_CLI_SCRIPT = [ '(async()=>{', 'try{', @@ -12,9 +14,15 @@ const APPIMAGE_CLI_SCRIPT = [ '})();' ].join('') -export function buildAppImageCliWrapper(appImagePath: string): string { - // Why: AppImage mounts resources under a fresh FUSE path per launch, so the - // installed command must call the stable outer AppImage and resolve APPDIR. +export function extractLegacyAppImageCliWrapperTarget(content: string): string | null { + const assignment = /^APPIMAGE=([\s\S]+?)\nif \[ ! -f "\$APPIMAGE" \]; then/mu.exec(content)?.[1] + const appImagePath = assignment ? unquoteShell(assignment) : null + return appImagePath && content === buildLegacyAppImageCliWrapper(appImagePath) + ? appImagePath + : null +} + +export function buildLegacyAppImageCliWrapper(appImagePath: string): string { return `#!/usr/bin/env bash set -euo pipefail APPIMAGE=${quoteShell(appImagePath)} @@ -32,6 +40,10 @@ ELECTRON_RUN_AS_NODE=1 exec "$APPIMAGE" -e ${quoteShell(APPIMAGE_CLI_SCRIPT)} -- ` } -export function quoteShell(value: string): string { - return `'${value.replaceAll("'", `'"'"'`)}'` +function unquoteShell(value: string): string | null { + if (!value.startsWith("'") || !value.endsWith("'")) { + return null + } + const decoded = value.slice(1, -1).split(`'"'"'`).join("'") + return quoteShell(decoded) === value ? decoded : null } diff --git a/src/main/cli/linux-bare-orca-dispatcher.test.ts b/src/main/cli/linux-bare-orca-dispatcher.test.ts index b2bb40e558b..b8031535134 100644 --- a/src/main/cli/linux-bare-orca-dispatcher.test.ts +++ b/src/main/cli/linux-bare-orca-dispatcher.test.ts @@ -1,13 +1,63 @@ +import { existsSync } from 'node:fs' +import type * as NodeFsPromises from 'node:fs/promises' import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' -import { join } from 'node:path' +import { dirname, join, relative, resolve, sep } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' +const { filePublicationFailures } = vi.hoisted(() => ({ + filePublicationFailures: { link: 0, replaceBeforeRename: '' } +})) +const registrationLock = vi.hoisted(() => ({ + completed: null as ((cacheRootPath: string) => void) | null, + entered: null as ((cacheRootPath: string) => void) | null, + pause: null as Promise | null +})) + +vi.mock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + link: async (...args: Parameters) => { + if (filePublicationFailures.link > 0) { + filePublicationFailures.link -= 1 + throw Object.assign(new Error('link unsupported'), { code: 'ENOTSUP' }) + } + return actual.link(...args) + }, + rename: async (...args: Parameters) => { + if (filePublicationFailures.replaceBeforeRename) { + await actual.writeFile(args[0], filePublicationFailures.replaceBeforeRename, { + mode: 0o755 + }) + filePublicationFailures.replaceBeforeRename = '' + } + return actual.rename(...args) + } + } +}) + vi.mock('electron', () => ({ app: { isPackaged: true } })) +vi.mock('./appimage-registration-lock', () => ({ + withAppImageRegistrationLock: async ( + cacheRootPath: string, + operation: () => Promise + ): Promise => { + registrationLock.entered?.(cacheRootPath) + const pause = registrationLock.pause + registrationLock.pause = null + await pause + const result = await operation() + registrationLock.completed?.(cacheRootPath) + return result + } +})) + import { installLinuxBareOrcaDispatcher } from './linux-bare-orca-dispatcher' +import { resolveAppImageExtractedRoot } from './appimage-extracted-root' const created: string[] = [] @@ -22,10 +72,27 @@ async function makeFixture(): Promise<{ homePath: string; resourcesPath: string } afterEach(async () => { + vi.unstubAllEnvs() + filePublicationFailures.link = 0 + filePublicationFailures.replaceBeforeRename = '' + registrationLock.completed = null + registrationLock.entered = null + registrationLock.pause = null await Promise.all(created.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))) }) describe('installLinuxBareOrcaDispatcher', () => { + it('uses the mounted bundled launcher when only APPDIR is inherited', async () => { + const { homePath, resourcesPath } = await makeFixture() + vi.stubEnv('APPIMAGE', '') + vi.stubEnv('APPDIR', resourcesPath) + + const result = await installLinuxBareOrcaDispatcher({ resourcesPath, homePath }) + + expect(result.state).toBe('installed') + expect(result.target).toBe(join(resourcesPath, 'bin', 'orca-ide')) + }) + it('writes an executable bare-orca dispatcher that execs the bundled orca-ide launcher', async () => { const { homePath, resourcesPath } = await makeFixture() @@ -67,6 +134,39 @@ describe('installLinuxBareOrcaDispatcher', () => { expect(second.state).toBe('installed') }) + it('publishes when the home filesystem does not support hard links', async () => { + const { homePath, resourcesPath } = await makeFixture() + filePublicationFailures.link = 1 + + const result = await installLinuxBareOrcaDispatcher({ + resourcesPath, + homePath, + appImagePath: null + }) + + expect(result.state).toBe('installed') + await expect(readFile(result.dispatcherPath, 'utf8')).resolves.toContain( + '# orca-serve-bare-orca-dispatcher' + ) + }) + + it('restores a displaced foreign dispatcher when hard links are unsupported', async () => { + const { homePath, resourcesPath } = await makeFixture() + await installLinuxBareOrcaDispatcher({ resourcesPath, homePath, appImagePath: null }) + const foreignContent = '#!/bin/sh\necho foreign\n' + filePublicationFailures.replaceBeforeRename = foreignContent + filePublicationFailures.link = 2 + + const result = await installLinuxBareOrcaDispatcher({ + resourcesPath, + homePath, + appImagePath: null + }) + + expect(result.state).toBe('skipped-foreign') + await expect(readFile(result.dispatcherPath, 'utf8')).resolves.toBe(foreignContent) + }) + it('quotes a resources path containing spaces so the exec line cannot be split', async () => { const root = await mkdtemp(join(tmpdir(), 'orca-bare-dispatcher-space-')) created.push(root) @@ -84,36 +184,154 @@ describe('installLinuxBareOrcaDispatcher', () => { expect(content).toContain(`exec '${join(resourcesPath, 'bin', 'orca-ide')}' "$@"`) }) - it('execs the stable AppImage (not the ephemeral mount) when running from an AppImage', async () => { - const { homePath, resourcesPath } = await makeFixture() - const appImagePath = join(homePath, 'Applications', 'Orca.AppImage') + // Why: this dispatcher must survive a restart, and an AppImage's resourcesPath + // is a mount that dies with the app. Point it at the extracted payload, which + // also keeps it clear of AppRun's `--no-sandbox` injection (#11609). + it.skipIf(process.platform === 'win32')( + 'execs the extracted payload (not the ephemeral mount) when running from an AppImage', + async () => { + const { homePath, resourcesPath } = await makeFixture() + const appImagePath = join(homePath, 'Orca.AppImage') + await mkdir(homePath, { recursive: true }) + await writeFile(appImagePath, '#!/usr/bin/env bash\n', { encoding: 'utf8', mode: 0o755 }) + const cacheRootPath = join(homePath, 'cache') - const result = await installLinuxBareOrcaDispatcher({ resourcesPath, homePath, appImagePath }) + const result = await installLinuxBareOrcaDispatcher({ + resourcesPath, + homePath, + appImagePath, + appImageCacheRootPath: cacheRootPath, + appImageExtractRunner: async (_appImagePath, cwd) => { + const launcherDir = join(cwd, 'squashfs-root', 'resources', 'bin') + await mkdir(launcherDir, { recursive: true }) + await writeFile(join(launcherDir, 'orca-ide'), '', { encoding: 'utf8', mode: 0o755 }) + } + }) - expect(result.state).toBe('installed') - expect(result.target).toBe(appImagePath) - const content = await readFile(result.dispatcherPath, 'utf8') - // The AppImage wrapper references the stable outer path, never resourcesPath. - expect(content).toContain(appImagePath) - expect(content).not.toContain(resourcesPath) - }) + expect(result.state).toBe('installed') + expect(relative(cacheRootPath, result.target as string).split(sep)).toEqual([ + 'launcher', + 'orca-ide' + ]) + const content = await readFile(result.dispatcherPath, 'utf8') + expect(content).toContain(result.target as string) + expect(content).not.toContain(resourcesPath) + expect(content).not.toContain(appImagePath) + } + ) it('skips (does not clobber) a user-owned orca already at ~/.local/bin', async () => { const { homePath, resourcesPath } = await makeFixture() const dispatcherPath = join(homePath, '.local', 'bin', 'orca') + const appImagePath = join(homePath, 'Orca.AppImage') await mkdir(join(homePath, '.local', 'bin'), { recursive: true }) await writeFile(dispatcherPath, '#!/bin/sh\necho my own orca\n', 'utf8') + await writeFile(appImagePath, '#!/usr/bin/env bash\n', 'utf8') + const extract = vi.fn() const result = await installLinuxBareOrcaDispatcher({ resourcesPath, homePath, - appImagePath: null + appImagePath, + appImageExtractRunner: extract }) expect(result.state).toBe('skipped-foreign') + expect(result.target).toBeNull() + expect(extract).not.toHaveBeenCalled() expect(await readFile(dispatcherPath, 'utf8')).toBe('#!/bin/sh\necho my own orca\n') }) + it('preserves a foreign dispatcher created while AppImage extraction is in flight', async () => { + const { homePath, resourcesPath } = await makeFixture() + const appImagePath = join(homePath, 'Orca.AppImage') + const cacheRootPath = join(homePath, 'cache') + const dispatcherPath = join(homePath, '.local', 'bin', 'orca') + await mkdir(homePath, { recursive: true }) + await writeFile(appImagePath, '#!/usr/bin/env bash\n', { mode: 0o755 }) + let reportStarted!: () => void + let releaseExtraction!: () => void + const started = new Promise((resolve) => { + reportStarted = resolve + }) + const released = new Promise((resolve) => { + releaseExtraction = resolve + }) + + const installation = installLinuxBareOrcaDispatcher({ + resourcesPath, + homePath, + appImagePath, + appImageCacheRootPath: cacheRootPath, + appImageExtractRunner: async (_path, cwd) => { + await writePayload(cwd) + reportStarted() + await released + } + }) + await started + await mkdir(dirname(dispatcherPath), { recursive: true }) + await writeFile(dispatcherPath, '#!/bin/sh\necho foreign\n', { mode: 0o755 }) + releaseExtraction() + + await expect(installation).resolves.toMatchObject({ + state: 'skipped-foreign', + target: null + }) + await expect(readFile(dispatcherPath, 'utf8')).resolves.toBe('#!/bin/sh\necho foreign\n') + }) + + it('prunes old owner generations without touching a sibling namespace', async () => { + const { homePath, resourcesPath } = await makeFixture() + const appImagePath = join(homePath, 'Orca.AppImage') + const cacheRootPath = join(homePath, 'cache', 'unused', '..') + await mkdir(homePath, { recursive: true }) + await writeFile(appImagePath, '#!/usr/bin/env bash\n', { mode: 0o755 }) + const events: string[] = [] + const options = { + resourcesPath, + homePath, + appImagePath, + appImageCacheRootPath: cacheRootPath, + appImageExtractRunner: async (_path: string, cwd: string) => { + events.push('extract') + await writePayload(cwd) + } + } + + await installLinuxBareOrcaDispatcher(options) + const previous = resolveAppImageExtractedRoot({ appImagePath, cacheRootPath })! + const sibling = join(resolve(cacheRootPath), 'f'.repeat(24), 'e'.repeat(24)) + await mkdir(sibling, { recursive: true }) + await writeFile(appImagePath, '#!/usr/bin/env bash\n# next\n', { mode: 0o755 }) + const lockEntered = Promise.withResolvers() + const releaseLock = Promise.withResolvers() + events.length = 0 + registrationLock.pause = releaseLock.promise + registrationLock.entered = (rootPath) => { + events.push('lock-entered') + lockEntered.resolve(rootPath) + } + registrationLock.completed = () => { + events.push( + existsSync(previous.rootPath) ? 'lock-left-before-prune' : 'lock-left-after-prune' + ) + } + + const installation = installLinuxBareOrcaDispatcher(options) + await expect(lockEntered.promise).resolves.toBe(resolve(cacheRootPath)) + expect(events).toEqual(['lock-entered']) + expect(existsSync(previous.rootPath)).toBe(true) + releaseLock.resolve() + await installation + const current = resolveAppImageExtractedRoot({ appImagePath, cacheRootPath })! + + expect(events).toEqual(['lock-entered', 'extract', 'lock-left-after-prune']) + expect(existsSync(previous.rootPath)).toBe(false) + expect(existsSync(current.rootPath)).toBe(true) + expect(existsSync(sibling)).toBe(true) + }) + it('skips when the bundled orca-ide launcher is missing from the build', async () => { const root = await mkdtemp(join(tmpdir(), 'orca-bare-dispatcher-nolauncher-')) created.push(root) @@ -128,3 +346,9 @@ describe('installLinuxBareOrcaDispatcher', () => { expect(result.target).toBeNull() }) }) + +async function writePayload(cwd: string): Promise { + const launcherDirectory = join(cwd, 'squashfs-root', 'resources', 'bin') + await mkdir(launcherDirectory, { recursive: true }) + await writeFile(join(launcherDirectory, 'orca-ide'), '#!/usr/bin/env bash\n', { mode: 0o755 }) +} diff --git a/src/main/cli/linux-bare-orca-dispatcher.ts b/src/main/cli/linux-bare-orca-dispatcher.ts index 3dc8df2906c..6c4b273fcd8 100644 --- a/src/main/cli/linux-bare-orca-dispatcher.ts +++ b/src/main/cli/linux-bare-orca-dispatcher.ts @@ -1,9 +1,20 @@ -import { existsSync } from 'node:fs' -import { chmod, mkdir, readFile, writeFile } from 'node:fs/promises' +import { randomUUID } from 'node:crypto' +import { constants, existsSync } from 'node:fs' +import { copyFile, link, lstat, mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises' import { homedir } from 'node:os' import { dirname, join } from 'node:path' -import { buildAppImageCliWrapper, quoteShell } from './appimage-cli-wrapper' -import { getBundledLauncherPath } from './cli-installer' +import { + hasAppImagePathEnvironment, + resolveAppImageRuntimeIdentity +} from '../appimage-runtime-identity' +import { + ensureAppImageExtractedRoot, + resolveAppImageCacheRootPath +} from './appimage-extracted-root' +import { pruneAppImageExtractedRoots } from './appimage-extraction-pruning' +import { withAppImageRegistrationLock } from './appimage-registration-lock' +import { getBundledLauncherPath } from './bundled-cli-launcher-path' +import { quoteShell } from './cli-install-path-format' // Why: marks a dispatcher this function wrote so repeat serve starts overwrite // our own file idempotently but never clobber a user's own ~/.local/bin/orca. @@ -14,8 +25,12 @@ export type LinuxBareOrcaDispatcherOptions = { resourcesPath: string /** Test seam — defaults to the real home directory. */ homePath?: string - /** Test seam — defaults to $APPIMAGE (set only when running from an AppImage). */ + /** Trusted caller override; production requires the complete AppImage runtime identity. */ appImagePath?: string | null + /** Test seam — defaults to $XDG_CACHE_HOME/orca/appimage. */ + appImageCacheRootPath?: string + /** Test seam — defaults to running the AppImage's own `--appimage-extract`. */ + appImageExtractRunner?: (appImagePath: string, cwd: string) => Promise } export type LinuxBareOrcaDispatcherState = @@ -26,7 +41,7 @@ export type LinuxBareOrcaDispatcherState = export type LinuxBareOrcaDispatcherResult = { state: LinuxBareOrcaDispatcherState dispatcherPath: string - /** What the dispatcher execs: the stable AppImage, or the bundled orca-ide. */ + /** The bundled `orca-ide` launcher the dispatcher execs. */ target: string | null } @@ -41,73 +56,176 @@ export async function installLinuxBareOrcaDispatcher( options: LinuxBareOrcaDispatcherOptions ): Promise { const dispatcherPath = join(options.homePath ?? homedir(), '.local', 'bin', 'orca') - const appImagePath = options.appImagePath ?? process.env.APPIMAGE ?? null + if (existsSync(dispatcherPath) && !(await isOwnedDispatcher(dispatcherPath))) { + return { state: 'skipped-foreign', dispatcherPath, target: null } + } - const resolved = resolveDispatcherScript(options.resourcesPath, appImagePath) - if (!resolved) { + const launcher = await resolveStableLauncherPath(options) + if (!launcher) { return { state: 'skipped-launcher-missing', dispatcherPath, target: null } } - // Why: only (re)write a dispatcher we previously created; leave a user's own - // `orca` untouched rather than silently clobbering it on every serve start. - if (existsSync(dispatcherPath) && !(await isOwnedDispatcher(dispatcherPath))) { - return { state: 'skipped-foreign', dispatcherPath, target: resolved.target } - } - - await mkdir(dirname(dispatcherPath), { recursive: true }) - await writeFile(dispatcherPath, resolved.script, 'utf8') - await chmod(dispatcherPath, 0o755) - return { state: 'installed', dispatcherPath, target: resolved.target } + const installed = await publishDispatcher( + dispatcherPath, + insertDispatcherMarker(buildBareOrcaCliScript(launcher)) + ) + return installed + ? { state: 'installed', dispatcherPath, target: launcher } + : { state: 'skipped-foreign', dispatcherPath, target: null } } -/** Bare-`orca` script that execs the Orca CLI: the stable AppImage when running - * from one, otherwise the bundled `orca-ide` launcher. Shared by the serve - * dispatcher and the managed-terminal PATH shim. */ -export function buildBareOrcaCliScript( - resourcesPath: string, - appImagePath: string | null -): { script: string; target: string } | null { - if (appImagePath) { - // Why: an AppImage mounts resources under an ephemeral FUSE path per launch, - // so the script must exec the stable outer AppImage — reuse the same - // wrapper CliInstaller installs for the AppImage command. - return { script: buildAppImageCliWrapper(appImagePath), target: appImagePath } - } +/** Bare-`orca` script that execs the one Linux CLI launcher. */ +export function buildBareOrcaCliScript(launcherPath: string): string { + return `#!/usr/bin/env bash\nexec ${quoteShell(launcherPath)} "$@"\n` +} - const launcher = getBundledLauncherPath('linux', resourcesPath) +/** + * The launcher path this dispatcher can still reach on a later boot. Under an + * AppImage `process.resourcesPath` is an ephemeral FUSE mount that dies with the + * app, so extract the payload once and point at that stable copy instead. + */ +async function resolveStableLauncherPath( + options: LinuxBareOrcaDispatcherOptions +): Promise { + const hasExplicitAppImagePath = Object.hasOwn(options, 'appImagePath') + const runtimeIdentity = resolveAppImageRuntimeIdentity({ resourcesPath: options.resourcesPath }) + if (!hasExplicitAppImagePath && hasAppImagePathEnvironment() && !runtimeIdentity) { + return null + } + const appImagePath = hasExplicitAppImagePath + ? (options.appImagePath ?? null) + : (runtimeIdentity?.appImagePath ?? null) + if (appImagePath) { + const extractionOptions = { + appImagePath, + cacheRootPath: options.appImageCacheRootPath, + runExtract: options.appImageExtractRunner + } + return withAppImageRegistrationLock( + resolveAppImageCacheRootPath(extractionOptions), + async () => { + const extractedRoot = await ensureAppImageExtractedRoot(extractionOptions) + if (extractedRoot) { + await pruneAppImageExtractedRoots(extractedRoot.rootPath) + } + return extractedRoot?.stableLauncherPath ?? null + } + ) + } + const launcher = getBundledLauncherPath('linux', options.resourcesPath) // Why: getBundledLauncherPath only joins the path; guard existence so we never // write a script pointing at a missing launcher (which would fail at exec // time with a confusing error instead of the command-not-found we fix). - if (!launcher || !existsSync(launcher)) { - return null - } - return { - script: `#!/usr/bin/env bash\nexec ${quoteShell(launcher)} "$@"\n`, - target: launcher - } + return launcher && existsSync(launcher) ? launcher : null } -function resolveDispatcherScript( - resourcesPath: string, - appImagePath: string | null -): { script: string; target: string } | null { - const resolved = buildBareOrcaCliScript(resourcesPath, appImagePath) - return resolved && { script: withMarker(resolved.script), target: resolved.target } -} - -function withMarker(script: string): string { - const firstNewline = script.indexOf('\n') - if (firstNewline === -1) { - return `${script}\n${DISPATCHER_MARKER}\n` - } - // Keep the shebang on line 1; insert the marker immediately after it. - return `${script.slice(0, firstNewline + 1)}${DISPATCHER_MARKER}\n${script.slice(firstNewline + 1)}` +function insertDispatcherMarker(script: string): string { + return script.replace('\n', `\n${DISPATCHER_MARKER}\n`) } async function isOwnedDispatcher(dispatcherPath: string): Promise { try { - return (await readFile(dispatcherPath, 'utf8')).includes(DISPATCHER_MARKER) + return ( + (await lstat(dispatcherPath)).isFile() && + (await readFile(dispatcherPath, 'utf8')).split('\n')[1] === DISPATCHER_MARKER + ) } catch { return false } } + +async function publishDispatcher(dispatcherPath: string, content: string): Promise { + const directoryPath = dirname(dispatcherPath) + const temporaryPath = join(directoryPath, `.orca-dispatcher-${process.pid}-${randomUUID()}`) + await mkdir(directoryPath, { recursive: true }) + await writeFile(temporaryPath, content, { encoding: 'utf8', flag: 'wx', mode: 0o755 }) + try { + if (await publishIfVacant(temporaryPath, dispatcherPath)) { + return true + } + + const displacedPath = join( + directoryPath, + `.orca-preserved-dispatcher-${process.pid}-${randomUUID()}` + ) + try { + await rename(dispatcherPath, displacedPath) + } catch (error) { + if (!hasErrorCode(error, 'ENOENT')) { + throw error + } + return await publishIfVacant(temporaryPath, dispatcherPath) + } + + if (!(await isOwnedDispatcher(displacedPath))) { + await restoreDisplacedDispatcher(displacedPath, dispatcherPath) + return false + } + + try { + if ( + (await publishIfVacant(temporaryPath, dispatcherPath)) || + (await isExactExecutableDispatcher(dispatcherPath, content)) + ) { + await unlink(displacedPath) + return true + } + // A concurrently published foreign command owns the public path now. + await unlink(displacedPath) + return false + } catch (error) { + await restoreDisplacedDispatcher(displacedPath, dispatcherPath) + throw error + } + } finally { + await unlink(temporaryPath).catch(() => {}) + } +} + +async function publishIfVacant(sourcePath: string, destinationPath: string): Promise { + try { + await link(sourcePath, destinationPath) + return true + } catch (error) { + if (hasErrorCode(error, 'EEXIST')) { + return false + } + } + try { + await copyFile(sourcePath, destinationPath, constants.COPYFILE_EXCL) + return true + } catch (error) { + if (hasErrorCode(error, 'EEXIST')) { + return false + } + throw error + } +} + +async function restoreDisplacedDispatcher( + displacedPath: string, + dispatcherPath: string +): Promise { + if (await publishIfVacant(displacedPath, dispatcherPath)) { + await unlink(displacedPath) + } +} + +async function isExactExecutableDispatcher( + dispatcherPath: string, + content: string +): Promise { + try { + const [actual, metadata] = await Promise.all([ + readFile(dispatcherPath, 'utf8'), + lstat(dispatcherPath) + ]) + return metadata.isFile() && (metadata.mode & 0o111) !== 0 && actual === content + } catch { + return false + } +} + +function hasErrorCode(error: unknown, code: string): boolean { + return error instanceof Error && 'code' in error && error.code === code +} diff --git a/src/main/cli/linux-terminal-orca-cli-shim.test.ts b/src/main/cli/linux-terminal-orca-cli-shim.test.ts index 81af17ba779..905d4807368 100644 --- a/src/main/cli/linux-terminal-orca-cli-shim.test.ts +++ b/src/main/cli/linux-terminal-orca-cli-shim.test.ts @@ -1,16 +1,19 @@ -import { chmodSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs' -import { mkdtemp, rm } from 'node:fs/promises' +import { chmodSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs' +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' +import { runProcess } from '../../shared/child-process/run-process' vi.mock('electron', () => ({ app: { isPackaged: true } })) +import { resolveAppImageLauncherEndpointPath } from './appimage-stable-launcher' import { ensureLinuxTerminalOrcaCliShimDir } from './linux-terminal-orca-cli-shim' const created: string[] = [] +const canFenceAppImageRuntime = process.platform === 'linux' && existsSync('/proc/self/stat') async function makeFixture(): Promise<{ userDataPath: string; resourcesPath: string }> { const root = await mkdtemp(join(tmpdir(), 'orca-terminal-cli-shim-')) @@ -23,10 +26,24 @@ async function makeFixture(): Promise<{ userDataPath: string; resourcesPath: str } afterEach(async () => { + vi.unstubAllEnvs() await Promise.all(created.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))) }) describe('ensureLinuxTerminalOrcaCliShimDir', () => { + it('uses the mounted bundled launcher when only APPDIR is inherited', async () => { + const { userDataPath, resourcesPath } = await makeFixture() + vi.stubEnv('APPIMAGE', '') + vi.stubEnv('APPDIR', resourcesPath) + + const shimDir = ensureLinuxTerminalOrcaCliShimDir({ userDataPath, resourcesPath }) + + expect(shimDir).toBe(join(userDataPath, 'linux-orca-cli-shim')) + expect(readFileSync(join(shimDir!, 'orca'), 'utf8')).toContain( + `exec '${join(resourcesPath, 'bin', 'orca-ide')}' "$@"` + ) + }) + it('writes an executable bare-orca shim that execs the bundled orca-ide launcher', async () => { const { userDataPath, resourcesPath } = await makeFixture() @@ -44,7 +61,7 @@ describe('ensureLinuxTerminalOrcaCliShimDir', () => { expect(mode & 0o111).not.toBe(0) }) - it('memoizes per userDataPath and re-asserts the exec bit for a stale shim', async () => { + it('reuses the shim path and re-asserts its exec bit', async () => { const { userDataPath, resourcesPath } = await makeFixture() const options = { userDataPath, resourcesPath, appImagePath: null } @@ -53,10 +70,9 @@ describe('ensureLinuxTerminalOrcaCliShimDir', () => { const shimPath = join(first!, 'orca') chmodSync(shimPath, 0o644) - // A distinct userData path is not memoized, so ensure runs again and heals - // the exec bit lost above only when it actually processes that path. const second = ensureLinuxTerminalOrcaCliShimDir(options) expect(second).toBe(first) + expect(statSync(shimPath).mode & 0o111).not.toBe(0) const root = await mkdtemp(join(tmpdir(), 'orca-terminal-cli-shim-2-')) created.push(root) @@ -76,20 +92,131 @@ describe('ensureLinuxTerminalOrcaCliShimDir', () => { expect(statSync(healedPath).mode & 0o111).not.toBe(0) }) - it('execs the stable AppImage (not the ephemeral mount) when running from an AppImage', async () => { - const { userDataPath, resourcesPath } = await makeFixture() - const appImagePath = join(userDataPath, 'Applications', 'Orca.AppImage') + it.skipIf(!canFenceAppImageRuntime)( + 'routes first-use AppImage terminals through a fenced current mount without a live endpoint', + async () => { + const { userDataPath, resourcesPath } = await makeFixture() + const appImagePath = join(userDataPath, 'Orca.AppImage') + await mkdir(userDataPath, { recursive: true }) + await writeFile(appImagePath, '#!/usr/bin/env bash\n', { encoding: 'utf8', mode: 0o755 }) + const cacheRootPath = join(userDataPath, 'cache') + const liveLauncherPath = join(resourcesPath, 'bin', 'orca-ide') + writeFileSync(liveLauncherPath, '#!/usr/bin/env bash\nprintf live', 'utf8') + chmodSync(liveLauncherPath, 0o755) + const shimDir = ensureLinuxTerminalOrcaCliShimDir({ + userDataPath, + resourcesPath, + appImagePath, + appImageCacheRootPath: cacheRootPath + }) - const shimDir = ensureLinuxTerminalOrcaCliShimDir({ - userDataPath, - resourcesPath, - appImagePath - }) + const shimPath = join(shimDir!, 'orca') + const content = readFileSync(shimPath, 'utf8') + expect(content).toContain(liveLauncherPath) + expect(content).toContain('runtime_pid=') + expect(content).toContain('/proc/$runtime_pid/stat') + expect(existsSync(resolveAppImageLauncherEndpointPath(cacheRootPath, 'live'))).toBe(false) + await expect( + runProcess({ program: shimPath, args: [], timeoutMs: 3_000 }) + ).resolves.toMatchObject({ code: 0, stdout: 'live' }) + } + ) - const content = readFileSync(join(shimDir!, 'orca'), 'utf8') - expect(content).toContain(appImagePath) - expect(content).not.toContain(resourcesPath) - }) + it.skipIf(!canFenceAppImageRuntime)( + 'refreshes restored terminals to the current AppImage mount', + async () => { + const { userDataPath, resourcesPath } = await makeFixture() + const appImagePath = join(userDataPath, 'Orca.AppImage') + await mkdir(userDataPath, { recursive: true }) + await writeFile(appImagePath, '#!/usr/bin/env bash\n', { encoding: 'utf8', mode: 0o755 }) + const cacheRootPath = join(userDataPath, 'cache') + const firstLauncher = join(resourcesPath, 'bin', 'orca-ide') + writeFileSync(firstLauncher, '#!/usr/bin/env bash\nprintf first', 'utf8') + chmodSync(firstLauncher, 0o755) + const options = { + userDataPath, + resourcesPath, + appImagePath, + appImageCacheRootPath: cacheRootPath + } + const shimDir = ensureLinuxTerminalOrcaCliShimDir(options) + const shimPath = join(shimDir!, 'orca') + const originalShim = readFileSync(shimPath, 'utf8') + + const nextResourcesPath = join(userDataPath, 'next-mount', 'resources') + const nextLauncher = join(nextResourcesPath, 'bin', 'orca-ide') + await mkdir(join(nextResourcesPath, 'bin'), { recursive: true }) + await writeFile(nextLauncher, '#!/usr/bin/env bash\nprintf next', { mode: 0o755 }) + await rm(firstLauncher) + expect( + ensureLinuxTerminalOrcaCliShimDir({ ...options, resourcesPath: nextResourcesPath }) + ).toBe(shimDir) + + const refreshedShim = readFileSync(shimPath, 'utf8') + expect(refreshedShim).not.toBe(originalShim) + expect(refreshedShim).toContain(nextLauncher) + expect(existsSync(resolveAppImageLauncherEndpointPath(cacheRootPath, 'live'))).toBe(false) + await expect( + runProcess({ program: shimPath, args: [], timeoutMs: 3_000 }) + ).resolves.toMatchObject({ code: 0, stdout: 'next' }) + } + ) + + it.skipIf(!canFenceAppImageRuntime)( + 'rejects a stale shim when its mount path is removed and reused', + async () => { + const { userDataPath, resourcesPath } = await makeFixture() + const appImagePath = join(userDataPath, 'Orca.AppImage') + const cacheRootPath = join(userDataPath, 'cache') + await mkdir(userDataPath, { recursive: true }) + await writeFile(appImagePath, '#!/usr/bin/env bash\n', { mode: 0o755 }) + const liveLauncher = join(resourcesPath, 'bin', 'orca-ide') + writeFileSync(liveLauncher, '#!/usr/bin/env bash\nprintf original', { mode: 0o755 }) + const shimDir = ensureLinuxTerminalOrcaCliShimDir({ + userDataPath, + resourcesPath, + appImagePath, + appImageCacheRootPath: cacheRootPath + }) + const shimPath = join(shimDir!, 'orca') + await rm(liveLauncher) + await writeFile(liveLauncher, '#!/usr/bin/env bash\nprintf replaced-by-another-mount', { + mode: 0o755 + }) + + await expect( + runProcess({ program: shimPath, args: [], timeoutMs: 3_000 }) + ).resolves.toMatchObject({ code: 1, stdout: '' }) + } + ) + + it.skipIf(!canFenceAppImageRuntime)( + 'rejects a shim after its owning AppImage process generation changes', + async () => { + const { userDataPath, resourcesPath } = await makeFixture() + const appImagePath = join(userDataPath, 'Orca.AppImage') + await mkdir(userDataPath, { recursive: true }) + await writeFile(appImagePath, '#!/usr/bin/env bash\n', { mode: 0o755 }) + const liveLauncher = join(resourcesPath, 'bin', 'orca-ide') + writeFileSync(liveLauncher, '#!/usr/bin/env bash\nprintf original', { mode: 0o755 }) + const shimDir = ensureLinuxTerminalOrcaCliShimDir({ + userDataPath, + resourcesPath, + appImagePath, + appImageCacheRootPath: join(userDataPath, 'cache') + }) + const shimPath = join(shimDir!, 'orca') + const staleContent = readFileSync(shimPath, 'utf8').replace( + /runtime_start_time='[^']*'/, + "runtime_start_time='stale-process'" + ) + writeFileSync(shimPath, staleContent, { mode: 0o755 }) + + await expect( + runProcess({ program: shimPath, args: [], timeoutMs: 3_000 }) + ).resolves.toMatchObject({ code: 1, stdout: '' }) + } + ) it('returns null (and does not memoize) when the bundled launcher is missing', async () => { const root = await mkdtemp(join(tmpdir(), 'orca-terminal-cli-shim-missing-')) diff --git a/src/main/cli/linux-terminal-orca-cli-shim.ts b/src/main/cli/linux-terminal-orca-cli-shim.ts index 56f38df15ce..7697bac01ac 100644 --- a/src/main/cli/linux-terminal-orca-cli-shim.ts +++ b/src/main/cli/linux-terminal-orca-cli-shim.ts @@ -1,20 +1,39 @@ -import { chmodSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' -import { join } from 'node:path' +import { + chmodSync, + existsSync, + mkdirSync, + readFileSync, + statSync, + writeFileSync, + type Stats +} from 'node:fs' +import { dirname, join, resolve } from 'node:path' +import { + hasAppImagePathEnvironment, + resolveAppImageRuntimeIdentity +} from '../appimage-runtime-identity' +import { + getAppImageCacheRootPath, + isAppImageInstalledLauncherCurrent +} from './appimage-extracted-root' +import { + ensureAppImageStableLauncher, + removeAppImageLegacyLiveEndpoint +} from './appimage-stable-launcher' +import { getBundledLauncherPath } from './bundled-cli-launcher-path' import { buildBareOrcaCliScript } from './linux-bare-orca-dispatcher' +import { quoteShell } from './cli-install-path-format' const SHIM_DIR_NAME = 'linux-orca-cli-shim' -// Why: rewriting the shim on every PTY spawn is wasted fs work; the target only -// changes with the install itself, so one successful write per process is enough. -// Failures are NOT cached so a transient fs error retries on the next spawn. -const ensuredShimDirs = new Map() - export type LinuxTerminalOrcaCliShimOptions = { userDataPath: string /** Test seam — defaults to the packaged resources root. */ resourcesPath?: string | null - /** Test seam — defaults to $APPIMAGE (set only when running from an AppImage). */ + /** Trusted caller override; production requires the complete AppImage runtime identity. */ appImagePath?: string | null + /** Test seam — defaults to $XDG_CACHE_HOME/orca/appimage. */ + appImageCacheRootPath?: string } // Why: on Linux the CLI installs as `orca-ide` so it never shadows the GNOME @@ -27,37 +46,185 @@ export type LinuxTerminalOrcaCliShimOptions = { export function ensureLinuxTerminalOrcaCliShimDir( options: LinuxTerminalOrcaCliShimOptions ): string | null { - const cached = ensuredShimDirs.get(options.userDataPath) - if (cached !== undefined) { - return cached + const resourcesPath = + options.resourcesPath === undefined ? process.resourcesPath : options.resourcesPath + const hasExplicitAppImagePath = Object.hasOwn(options, 'appImagePath') + const runtimeIdentity = resolveAppImageRuntimeIdentity({ resourcesPath }) + if (!hasExplicitAppImagePath && hasAppImagePathEnvironment() && !runtimeIdentity) { + return null + } + const appImagePath = hasExplicitAppImagePath + ? (options.appImagePath ?? null) + : (runtimeIdentity?.appImagePath ?? null) + if (appImagePath) { + return ensureAppImageShim(options, resourcesPath, appImagePath) } - const resourcesPath = options.resourcesPath ?? process.resourcesPath if (!resourcesPath) { return null } - const resolved = buildBareOrcaCliScript( - resourcesPath, - options.appImagePath ?? process.env.APPIMAGE ?? null - ) - if (!resolved) { + const launcherPath = getBundledLauncherPath('linux', resourcesPath) + return launcherPath && existsSync(launcherPath) + ? ensureShimForLauncher(options.userDataPath, launcherPath) + : null +} + +function ensureAppImageShim( + options: LinuxTerminalOrcaCliShimOptions, + resourcesPath: string | null, + appImagePath: string +): string | null { + if (!resourcesPath) { + return null + } + const cacheRootPath = options.appImageCacheRootPath ?? getAppImageCacheRootPath() + removeAppImageLegacyLiveEndpoint(cacheRootPath) + + const liveLauncherPath = getBundledLauncherPath('linux', resourcesPath) + if (!liveLauncherPath || !existsSync(liveLauncherPath)) { return null } - const shimDir = join(options.userDataPath, SHIM_DIR_NAME) + const stableLauncherPath = ensureAppImageStableLauncher(cacheRootPath) + if ( + stableLauncherPath && + isAppImageInstalledLauncherCurrent({ + appImagePath, + cacheRootPath + }) + ) { + return ensureShimForLauncher(options.userDataPath, stableLauncherPath) + } + + const fence = captureAppImageRuntimeFence(liveLauncherPath) + return fence + ? ensureShimForScript( + options.userDataPath, + buildAppImageLiveLauncherScript(fence.launcherPath, fence) + ) + : null +} + +type AppImageRuntimeFence = { + pid: number + startTime: string + runtimeRoot: string + runtimeIdentity: string + launcherIdentity: string + launcherPath: string +} + +function captureAppImageRuntimeFence(launcherPath: string): AppImageRuntimeFence | null { + if (process.platform !== 'linux') { + return null + } + const resolvedLauncherPath = resolve(launcherPath) + const runtimeRoot = dirname(dirname(dirname(resolvedLauncherPath))) + const runtimeIdentity = readFileIdentity(runtimeRoot) + const launcherIdentity = readFileIdentity(resolvedLauncherPath) + const startTime = readLinuxProcessStartTime(process.pid) + return runtimeIdentity && launcherIdentity && startTime + ? { + pid: process.pid, + startTime, + runtimeRoot, + runtimeIdentity, + launcherIdentity, + launcherPath: resolvedLauncherPath + } + : null +} + +function readFileIdentity(path: string): string | null { + try { + const stats = statSync(path) + return formatFileIdentity(stats) + } catch { + return null + } +} + +function formatFileIdentity(stats: Stats): string { + return [ + stats.dev, + stats.ino, + stats.size, + Math.floor(stats.mtimeMs / 1000), + Math.floor(stats.ctimeMs / 1000) + ].join(':') +} + +function readLinuxProcessStartTime(pid: number): string | null { + try { + const content = readFileSync(join('/proc', String(pid), 'stat'), 'utf8') + const commandEnd = content.lastIndexOf(') ') + if (commandEnd === -1) { + return null + } + const fields = content + .slice(commandEnd + 2) + .trim() + .split(/\s+/) + return fields[19] ?? null + } catch { + return null + } +} + +function buildAppImageLiveLauncherScript( + launcherPath: string, + fence: AppImageRuntimeFence +): string { + const runtimePid = quoteShell(String(fence.pid)) + const runtimeStartTime = quoteShell(fence.startTime) + const runtimeRoot = quoteShell(fence.runtimeRoot) + const expectedRuntimeIdentity = quoteShell(fence.runtimeIdentity) + const expectedLauncherIdentity = quoteShell(fence.launcherIdentity) + const quotedLauncherPath = quoteShell(launcherPath) + return `#!/usr/bin/env bash +runtime_pid=${runtimePid} +runtime_start_time=${runtimeStartTime} +runtime_root=${runtimeRoot} +launcher=${quotedLauncherPath} +expected_runtime_identity=${expectedRuntimeIdentity} +expected_launcher_identity=${expectedLauncherIdentity} +fail() { + printf 'Orca CLI is unavailable; reopen Orca or register the CLI again.\\n' >&2 + exit 1 +} +proc_stat_path="/proc/$runtime_pid/stat" +[[ -r "$proc_stat_path" ]] || fail +proc_stat="$(<"$proc_stat_path")" || fail +proc_fields=() +read -r -a proc_fields <<< "\${proc_stat##*) }" || fail +[[ "\${proc_fields[19]:-}" == "$runtime_start_time" ]] || fail +runtime_identity="$(stat -Lc '%d:%i:%s:%Y:%Z' -- "$runtime_root" 2>/dev/null)" || fail +[[ "$runtime_identity" == "$expected_runtime_identity" ]] || fail +launcher_identity="$(stat -Lc '%d:%i:%s:%Y:%Z' -- "$launcher" 2>/dev/null)" || fail +[[ "$launcher_identity" == "$expected_launcher_identity" ]] || fail +[[ -f "$launcher" && -x "$launcher" ]] || fail +exec "$launcher" "$@" +` +} + +function ensureShimForLauncher(userDataPath: string, launcherPath: string): string | null { + const script = buildBareOrcaCliScript(launcherPath) + + return ensureShimForScript(userDataPath, script) +} + +function ensureShimForScript(userDataPath: string, script: string): string | null { + const shimDir = join(userDataPath, SHIM_DIR_NAME) const shimPath = join(shimDir, 'orca') try { - if (readShim(shimPath) !== resolved.script) { + if (readShim(shimPath) !== script) { mkdirSync(shimDir, { recursive: true }) - writeFileSync(shimPath, resolved.script, 'utf8') + writeFileSync(shimPath, script, 'utf8') } - // Why: always re-assert the exec bit — a shim written by an older run (or - // restored from backup) with mode stripped would fail every agent CLI call. chmodSync(shimPath, 0o755) } catch { return null } - ensuredShimDirs.set(options.userDataPath, shimDir) return shimDir } diff --git a/src/main/cli/packaged-cli-assets.test.ts b/src/main/cli/packaged-cli-assets.test.ts index 9128f945ef9..d5c17123ec1 100644 --- a/src/main/cli/packaged-cli-assets.test.ts +++ b/src/main/cli/packaged-cli-assets.test.ts @@ -5,7 +5,6 @@ import { tmpdir } from 'node:os' import { dirname, join, sep } from 'node:path' import { promisify } from 'node:util' import { describe, expect, it } from 'vitest' -import { buildAppImageCliWrapper } from './appimage-cli-wrapper' const require = createRequire(import.meta.url) const execFileAsync = promisify(execFile) @@ -246,55 +245,47 @@ printf 'arg=%s\\n' "$@" } ) - itRunsUnixShell('runs the AppImage CLI wrapper through APPDIR at runtime', async () => { - const root = await mkdtemp(join(tmpdir(), 'orca-appimage-cli-')) + // Why: registration on every Linux install method now points at this one + // launcher, so its env sanitation and argv passthrough are the contract the + // AppImage, deb, and extracted-tree commands all depend on. + itRunsUnixShell('sanitizes node env and forwards argv verbatim', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-linux-cli-env-')) try { - const appDir = join(root, 'Orca.AppDir') - const cliDir = join(appDir, 'resources', 'app.asar.unpacked', 'out', 'cli') + const appDir = join(root, 'Orca') + const resourcesDir = join(appDir, 'resources') + const launcherDir = join(resourcesDir, 'bin') + const cliDir = join(resourcesDir, 'app.asar.unpacked', 'out', 'cli') + const launcherPath = join(launcherDir, 'orca-ide') const cliPath = join(cliDir, 'index.js') - const appImagePath = join(root, "Orca's AppImage.AppImage") - const commandPath = join(root, 'orca-ide') + + await mkdir(launcherDir, { recursive: true }) await mkdir(cliDir, { recursive: true }) + await copyFile(linuxLauncherAsset, launcherPath) + await writeFile(cliPath, '', 'utf8') await writeFile( - cliPath, - `exports.main = (argv) => { - console.log(JSON.stringify({ - argv, - appDir: process.env.APPDIR, - runAsNode: process.env.ELECTRON_RUN_AS_NODE, - nodeOptions: process.env.NODE_OPTIONS ?? null, - orcaNodeOptions: process.env.ORCA_NODE_OPTIONS ?? null, - nodeReplExternalModule: process.env.NODE_REPL_EXTERNAL_MODULE ?? null, - orcaNodeReplExternalModule: process.env.ORCA_NODE_REPL_EXTERNAL_MODULE ?? null - })) -} -`, - 'utf8' - ) - await writeFile( - appImagePath, + join(appDir, 'orca-ide'), `#!/usr/bin/env bash -export APPDIR="$FAKE_APPDIR" -exec node "$@" +node -e 'console.log(JSON.stringify({ + argv: process.argv.slice(1), + runAsNode: process.env.ELECTRON_RUN_AS_NODE, + nodeOptions: process.env.NODE_OPTIONS ?? null, + orcaNodeOptions: process.env.ORCA_NODE_OPTIONS ?? null, + nodeReplExternalModule: process.env.NODE_REPL_EXTERNAL_MODULE ?? null, + orcaNodeReplExternalModule: process.env.ORCA_NODE_REPL_EXTERNAL_MODULE ?? null +}))' -- "$@" `, { encoding: 'utf8', mode: 0o755 } ) - await writeFile(commandPath, buildAppImageCliWrapper(appImagePath), { - encoding: 'utf8', - mode: 0o755 - }) - const result = await execFileAsync(commandPath, ['--help', 'two words'], { + const result = await execFileAsync(launcherPath, ['--help', 'two words'], { env: { ...process.env, - FAKE_APPDIR: appDir, NODE_OPTIONS: '--trace-warnings', NODE_REPL_EXTERNAL_MODULE: 'external-loader' } }) const payload = JSON.parse(result.stdout) as { argv: string[] - appDir: string runAsNode: string nodeOptions: string | null orcaNodeOptions: string | null @@ -302,9 +293,10 @@ exec node "$@" orcaNodeReplExternalModule: string | null } - expect(payload.argv).toEqual(['--help', 'two words']) - expect(payload.appDir).toBe(appDir) + expect(payload.argv).toEqual([cliPath, '--help', 'two words']) expect(payload.runAsNode).toBe('1') + // Why: Electron's node bootstrap must not inherit these, but the CLI + // still needs to see what the user set. expect(payload.nodeOptions).toBeNull() expect(payload.orcaNodeOptions).toBe('--trace-warnings') expect(payload.nodeReplExternalModule).toBeNull() diff --git a/src/main/cli/wsl-cli-installer.ts b/src/main/cli/wsl-cli-installer.ts index a3c102e1dfc..484ed4f9bc3 100644 --- a/src/main/cli/wsl-cli-installer.ts +++ b/src/main/cli/wsl-cli-installer.ts @@ -207,7 +207,9 @@ export class WslCliInstaller { throw new Error(status.detail ?? 'WSL CLI registration is unavailable.') } if (status.state === 'conflict') { - throw new Error(`Refusing to replace non-Orca command at ${status.commandPath}.`) + throw new Error( + `Refusing to replace non-Orca command at ${status.commandPath}. Remove it and register again if it is no longer needed.` + ) } await this.run( diff --git a/src/main/codex-accounts/runtime-home-service-auth-core.ts b/src/main/codex-accounts/runtime-home-service-auth-core.ts new file mode 100644 index 00000000000..090c82626cd --- /dev/null +++ b/src/main/codex-accounts/runtime-home-service-auth-core.ts @@ -0,0 +1,174 @@ +import { existsSync, chmodSync, readFileSync, rmSync } from 'node:fs' +import { join } from 'node:path' +import { isDefinitiveAbsence } from '../../shared/definitive-filesystem-absence' +import { getSystemCodexHomePath } from '../codex/codex-home-paths' +import { writeFileAtomically, writeFileAtomicallyIfUnchanged } from './fs-utils' +import type { + CodexRuntimeLogoutMarker, + CodexRuntimeLogoutMarkerStatus, + CodexSharedRuntimeAuthProvenance +} from './runtime-home-service-types' +import { CodexRuntimeHomeLegacyMigration } from './runtime-home-service-legacy-migration' + +export abstract class CodexRuntimeHomeAuthCore extends CodexRuntimeHomeLegacyMigration { + protected readSystemDefaultAuth(): string | null { + const systemDefaultAuthPath = join(getSystemCodexHomePath(), 'auth.json') + return existsSync(systemDefaultAuthPath) ? readFileSync(systemDefaultAuthPath, 'utf-8') : null + } + + protected writeRuntimeAuth( + contents: string, + owner: { owner: 'system-default' } | { owner: 'managed'; accountId: string }, + options?: { expectedContents: string | null } + ): boolean { + // Why: auth.json holds credentials; restrict to owner-only so other users on a shared machine cannot read it. + const runtimeAuthPath = this.getRuntimeAuthPath() + if (options && !this.fileContentsMatchExpected(runtimeAuthPath, options.expectedContents)) { + return false + } + const provenance: CodexSharedRuntimeAuthProvenance = + owner.owner === 'system-default' ? { owner: 'system-default', authJson: contents } : owner + const runtimeAuthComparison = this.compareFileContents(runtimeAuthPath, contents) + if (runtimeAuthComparison === null) { + // Why: an unreadable runtime auth.json may hold a token Codex rotated a + // moment ago. Treating "could not read" as "differs" sent execution to the + // unconditional write below, consuming that rotation and logging the user + // out for good. Refuse; the next sync retries. + return false + } + const runtimeAuthAlreadyMatches = runtimeAuthComparison + if ( + runtimeAuthAlreadyMatches && + this.sharedRuntimeAuthProvenanceMatches( + this.resolveSharedRuntimeAuthProvenanceStatus(), + provenance + ) + ) { + this.ensureOwnerOnlyMode(runtimeAuthPath) + this.lastWrittenAuthJson = contents + this.clearRuntimeLogoutMarker() + return true + } + this.persistSharedRuntimeAuthProvenance({ + owner: 'pending', + next: provenance, + runtimeAuthJson: contents + }) + if (runtimeAuthAlreadyMatches) { + this.ensureOwnerOnlyMode(runtimeAuthPath) + this.lastWrittenAuthJson = contents + this.persistSharedRuntimeAuthProvenance(provenance) + this.clearRuntimeLogoutMarker() + return true + } + const replaced = options + ? writeFileAtomicallyIfUnchanged(runtimeAuthPath, options.expectedContents, contents, { + mode: 0o600 + }) + : (writeFileAtomically(runtimeAuthPath, contents, { mode: 0o600 }), true) + if (!replaced) { + return false + } + this.lastWrittenAuthJson = contents + this.persistSharedRuntimeAuthProvenance(provenance) + this.clearRuntimeLogoutMarker() + return true + } + + /** + * `true`/`false` only when the bytes were actually read; `null` when the file + * could not be read at all. The old `catch { return false }` reported "these + * differ" for a file nobody could open, and every caller reads that as + * permission to write. + */ + protected compareFileContents(targetPath: string, contents: string): boolean | null { + try { + return readFileSync(targetPath, 'utf-8') === contents + } catch (error) { + return isDefinitiveAbsence(error) ? false : null + } + } + + protected fileContentsEqual(targetPath: string, contents: string): boolean { + return this.compareFileContents(targetPath, contents) === true + } + + protected fileContentsMatchExpected( + targetPath: string, + expectedContents: string | null + ): boolean { + if (expectedContents === null) { + // Why: `!existsSync` does report `true` for a locked file, but this branch + // is not where that matters — the write it guards is + // `writeFileAtomicallyIfUnchanged`, whose rename-and-compare re-checks the + // real file and refuses on its own. Classifying here would be a guard no + // test can drive. + return !existsSync(targetPath) + } + return this.fileContentsEqual(targetPath, expectedContents) + } + + protected ensureOwnerOnlyMode(targetPath: string): void { + if (process.platform === 'win32') { + return + } + try { + chmodSync(targetPath, 0o600) + } catch { + /* Best effort: the next atomic write will set the restrictive mode. */ + } + } + + protected getRuntimeLogoutMarkerStatus(): CodexRuntimeLogoutMarkerStatus { + const marker = this.readRuntimeLogoutMarker() + if (!marker) { + return { kind: 'missing' } + } + const systemDefaultAuthJson = this.readSystemDefaultAuth() + if (systemDefaultAuthJson === marker.systemDefaultAuthJson) { + return { kind: 'applies' } + } + this.clearRuntimeLogoutMarker() + return { kind: 'system-default-changed', systemDefaultAuthJson } + } + + protected persistRuntimeLogoutMarker(systemDefaultAuthJson = this.readSystemDefaultAuth()): void { + const marker: CodexRuntimeLogoutMarker = { + systemDefaultAuthJson, + loggedOutAt: Date.now() + } + writeFileAtomically(this.getRuntimeLogoutMarkerPath(), `${JSON.stringify(marker, null, 2)}\n`, { + mode: 0o600 + }) + } + + protected readRuntimeLogoutMarker(): CodexRuntimeLogoutMarker | null { + let parsed: unknown + try { + parsed = JSON.parse(readFileSync(this.getRuntimeLogoutMarkerPath(), 'utf-8')) as unknown + } catch { + return null + } + if ( + !parsed || + typeof parsed !== 'object' || + Array.isArray(parsed) || + !('systemDefaultAuthJson' in parsed) || + !('loggedOutAt' in parsed) + ) { + return null + } + const marker = parsed as { systemDefaultAuthJson: unknown; loggedOutAt: unknown } + if ( + (marker.systemDefaultAuthJson !== null && typeof marker.systemDefaultAuthJson !== 'string') || + typeof marker.loggedOutAt !== 'number' + ) { + return null + } + return marker as CodexRuntimeLogoutMarker + } + + protected clearRuntimeLogoutMarker(): void { + rmSync(this.getRuntimeLogoutMarkerPath(), { force: true }) + } +} diff --git a/src/main/codex-accounts/runtime-home-service-auth-provenance.ts b/src/main/codex-accounts/runtime-home-service-auth-provenance.ts new file mode 100644 index 00000000000..d4018741405 --- /dev/null +++ b/src/main/codex-accounts/runtime-home-service-auth-provenance.ts @@ -0,0 +1,224 @@ +import { existsSync, readFileSync, rmSync } from 'node:fs' +import { writeFileAtomically } from './fs-utils' +import type { + CodexSharedRuntimeAuthPendingProvenance, + CodexSharedRuntimeAuthProvenance, + CodexSharedRuntimeAuthProvenanceFile, + CodexSharedRuntimeAuthProvenanceStatus, + CodexSystemDefaultSnapshot +} from './runtime-home-service-types' +import { CodexRuntimeHomeAuthCore } from './runtime-home-service-auth-core' + +export abstract class CodexRuntimeHomeAuthProvenance extends CodexRuntimeHomeAuthCore { + protected persistSharedRuntimeAuthProvenance( + provenance: CodexSharedRuntimeAuthProvenanceFile + ): void { + writeFileAtomically( + this.getSharedRuntimeAuthProvenancePath(), + `${JSON.stringify(provenance, null, 2)}\n`, + { mode: 0o600 } + ) + } + + protected markSharedRuntimeAuthManaged(accountId: string): void { + const status = this.resolveSharedRuntimeAuthProvenanceStatus() + if ( + status.kind === 'committed' && + status.provenance.owner === 'managed' && + status.provenance.accountId === accountId + ) { + return + } + const runtimeAuthJson = this.readRuntimeAuthForProvenance() + const systemDefaultBaseline = this.getUntouchedSystemDefaultBaseline(status, runtimeAuthJson) + const provenance: CodexSharedRuntimeAuthProvenance = { + owner: 'managed', + accountId, + ...(systemDefaultBaseline ? { systemDefaultBaseline } : {}) + } + this.persistSharedRuntimeAuthProvenance({ + owner: 'pending', + next: provenance, + runtimeAuthJson + }) + if (this.readRuntimeAuthForProvenance() === runtimeAuthJson) { + this.persistSharedRuntimeAuthProvenance(provenance) + } + } + + protected getUntouchedSystemDefaultBaseline( + status: CodexSharedRuntimeAuthProvenanceStatus, + runtimeAuthJson: string | null + ): { authJson: string | null } | null { + if (status.kind !== 'committed') { + return null + } + const baseline = + status.provenance.owner === 'system-default' + ? { authJson: status.provenance.authJson } + : status.provenance.systemDefaultBaseline + return baseline && runtimeAuthJson === baseline.authJson ? baseline : null + } + + protected restoreUntouchedSystemDefaultProvenance( + provenance: Extract + ): Extract | null { + const baseline = provenance.systemDefaultBaseline + if (!baseline || this.readRuntimeAuthForProvenance() !== baseline.authJson) { + return null + } + const restored = { owner: 'system-default' as const, authJson: baseline.authJson } + this.persistSharedRuntimeAuthProvenance({ + owner: 'pending', + next: restored, + runtimeAuthJson: baseline.authJson + }) + if (this.readRuntimeAuthForProvenance() !== baseline.authJson) { + return null + } + this.persistSharedRuntimeAuthProvenance(restored) + return restored + } + + protected sharedRuntimeAuthProvenanceMatches( + status: CodexSharedRuntimeAuthProvenanceStatus, + expected: CodexSharedRuntimeAuthProvenance + ): boolean { + if (status.kind !== 'committed' || status.provenance.owner !== expected.owner) { + return false + } + return expected.owner === 'system-default' + ? status.provenance.owner === 'system-default' && + status.provenance.authJson === expected.authJson + : status.provenance.owner === 'managed' && status.provenance.accountId === expected.accountId + } + + protected resolveSharedRuntimeAuthProvenanceStatus(): CodexSharedRuntimeAuthProvenanceStatus { + const provenancePath = this.getSharedRuntimeAuthProvenancePath() + if (!existsSync(provenancePath)) { + return { kind: 'missing' } + } + let parsed: unknown + try { + parsed = JSON.parse(readFileSync(provenancePath, 'utf-8')) as unknown + } catch { + return { kind: 'fenced' } + } + const committed = this.parseSharedRuntimeAuthProvenance(parsed) + if (committed) { + return { kind: 'committed', provenance: committed } + } + const pending = this.parsePendingSharedRuntimeAuthProvenance(parsed) + if (!pending || this.readRuntimeAuthForProvenance() !== pending.runtimeAuthJson) { + return { kind: 'fenced' } + } + try { + this.persistSharedRuntimeAuthProvenance(pending.next) + return { kind: 'committed', provenance: pending.next } + } catch { + return { kind: 'fenced' } + } + } + + protected parseSharedRuntimeAuthProvenance( + value: unknown + ): CodexSharedRuntimeAuthProvenance | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return null + } + const provenance = value as Record + if ( + provenance.owner === 'system-default' && + (typeof provenance.authJson === 'string' || provenance.authJson === null) + ) { + return { owner: 'system-default', authJson: provenance.authJson } + } + if ( + provenance.owner !== 'managed' || + typeof provenance.accountId !== 'string' || + provenance.accountId.length === 0 + ) { + return null + } + const baseline = this.parseSystemDefaultBaseline(provenance.systemDefaultBaseline) + if ('systemDefaultBaseline' in provenance && !baseline) { + return null + } + return { + owner: 'managed', + accountId: provenance.accountId, + ...(baseline ? { systemDefaultBaseline: baseline } : {}) + } + } + + protected parseSystemDefaultBaseline(value: unknown): { authJson: string | null } | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return null + } + const baseline = value as Record + return typeof baseline.authJson === 'string' || baseline.authJson === null + ? { authJson: baseline.authJson } + : null + } + + protected parsePendingSharedRuntimeAuthProvenance( + value: unknown + ): CodexSharedRuntimeAuthPendingProvenance | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return null + } + const pending = value as Record + const next = this.parseSharedRuntimeAuthProvenance(pending.next) + return pending.owner === 'pending' && + next && + (typeof pending.runtimeAuthJson === 'string' || pending.runtimeAuthJson === null) + ? { owner: 'pending', next, runtimeAuthJson: pending.runtimeAuthJson } + : null + } + + protected readRuntimeAuthForProvenance(): string | null { + try { + return readFileSync(this.getRuntimeAuthPath(), 'utf-8') + } catch { + return null + } + } + + protected readSystemDefaultSnapshot(snapshotPath: string): CodexSystemDefaultSnapshot | null { + let rawContents: string + try { + rawContents = readFileSync(snapshotPath, 'utf-8') + } catch { + return null + } + try { + const parsed = JSON.parse(rawContents) as unknown + if ( + parsed && + typeof parsed === 'object' && + !Array.isArray(parsed) && + 'authJson' in parsed && + (typeof (parsed as { authJson: unknown }).authJson === 'string' || + (parsed as { authJson: unknown }).authJson === null) + ) { + return parsed as CodexSystemDefaultSnapshot + } + // Why: pre-PR snapshots stored raw auth.json; treat objects lacking an authJson wrapper as legacy so upgraders don't lose their auth. + if ( + parsed && + typeof parsed === 'object' && + !Array.isArray(parsed) && + !('authJson' in parsed) + ) { + return { authJson: rawContents } + } + } catch { + return null + } + return null + } + + clearSystemDefaultSnapshot(): void { + rmSync(this.getSystemDefaultSnapshotPath(), { force: true }) + } +} diff --git a/src/main/codex-accounts/runtime-home-service-auth-sync-identity.ts b/src/main/codex-accounts/runtime-home-service-auth-sync-identity.ts new file mode 100644 index 00000000000..d0fa50fa5ec --- /dev/null +++ b/src/main/codex-accounts/runtime-home-service-auth-sync-identity.ts @@ -0,0 +1,37 @@ +import { codexAuthIsFresher } from './codex-auth-identity' + +function readCodexLastRefresh(authJson: string): number | null { + try { + const parsed = JSON.parse(authJson) as unknown + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + return null + } + const value = (parsed as Record).last_refresh + if (typeof value === 'number') { + return Number.isFinite(value) ? value : null + } + if (typeof value !== 'string' || !value.trim()) { + return null + } + const timestamp = Date.parse(value) + return Number.isFinite(timestamp) ? timestamp : null + } catch { + return null + } +} + +export function codexAuthIsMonotonicallyFresher( + candidateAuthJson: string, + baselineAuthJson: string +): boolean { + const candidateLastRefresh = readCodexLastRefresh(candidateAuthJson) + const baselineLastRefresh = readCodexLastRefresh(baselineAuthJson) + if (candidateLastRefresh !== null || baselineLastRefresh !== null) { + return ( + candidateLastRefresh !== null && + baselineLastRefresh !== null && + candidateLastRefresh > baselineLastRefresh + ) + } + return codexAuthIsFresher(candidateAuthJson, baselineAuthJson) +} diff --git a/src/main/codex-accounts/runtime-home-service-auth-sync.ts b/src/main/codex-accounts/runtime-home-service-auth-sync.ts new file mode 100644 index 00000000000..3cb06cac9e7 --- /dev/null +++ b/src/main/codex-accounts/runtime-home-service-auth-sync.ts @@ -0,0 +1,292 @@ +import { existsSync, mkdirSync, readFileSync, rmSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { getSystemCodexHomePath } from '../codex/codex-home-paths' +import { removeFileAtomicallyIfUnchanged, writeFileAtomically } from './fs-utils' +import { CodexRuntimeHomeLaunch } from './runtime-home-service-launch' +import type { CodexSystemDefaultSnapshot } from './runtime-home-service-types' + +export abstract class CodexRuntimeHomeAuthSync extends CodexRuntimeHomeLaunch { + protected captureSystemDefaultSnapshot(options: { force: boolean }): void { + const snapshotPath = this.getSystemDefaultSnapshotPath() + if (!options.force && existsSync(snapshotPath)) { + return + } + + const runtimeAuthPath = join(getSystemCodexHomePath(), 'auth.json') + const snapshot: CodexSystemDefaultSnapshot = { + authJson: existsSync(runtimeAuthPath) ? readFileSync(runtimeAuthPath, 'utf-8') : null + } + writeFileAtomically(snapshotPath, `${JSON.stringify(snapshot, null, 2)}\n`, { mode: 0o600 }) + } + + protected syncRuntimeAuthWithSystemDefault(): void { + const runtimeAuthPath = this.getRuntimeAuthPath() + const systemDefaultAuthPath = join(getSystemCodexHomePath(), 'auth.json') + if (!existsSync(runtimeAuthPath)) { + return + } + + try { + const runtimeAuth = readFileSync(runtimeAuthPath, 'utf-8') + const provenanceStatus = this.resolveSharedRuntimeAuthProvenanceStatus() + const provenance = provenanceStatus.kind === 'committed' ? provenanceStatus.provenance : null + if (provenance?.owner === 'managed') { + this.captureSystemDefaultSnapshot({ force: true }) + if (!existsSync(systemDefaultAuthPath)) { + this.clearRuntimeAuthAfterSystemDefaultLogout(runtimeAuthPath) + return + } + this.writeRuntimeAuth(readFileSync(systemDefaultAuthPath, 'utf-8'), { + owner: 'system-default' + }) + return + } + const { + ownershipProven: systemDefaultOwnershipProven, + mirroredAuthJson: mirroredSystemDefaultAuth + } = this.resolveSystemDefaultMirrorClaim(runtimeAuth, provenanceStatus) + if (!existsSync(systemDefaultAuthPath)) { + if (mirroredSystemDefaultAuth !== null && runtimeAuth === mirroredSystemDefaultAuth) { + this.clearRuntimeAuthAfterSystemDefaultLogout(runtimeAuthPath) + return + } + if ( + systemDefaultOwnershipProven && + mirroredSystemDefaultAuth !== null && + this.runtimeAuthMatchesSystemDefaultIdentity(runtimeAuth, mirroredSystemDefaultAuth) + ) { + this.clearRuntimeAuthAfterSystemDefaultLogout(runtimeAuthPath) + } + return + } + const systemDefaultAuth = readFileSync(systemDefaultAuthPath, 'utf-8') + if (runtimeAuth === systemDefaultAuth) { + this.writeRuntimeAuth(systemDefaultAuth, { owner: 'system-default' }) + return + } + if ( + systemDefaultOwnershipProven && + mirroredSystemDefaultAuth !== null && + systemDefaultAuth === mirroredSystemDefaultAuth && + this.runtimeAuthMatchesSystemDefaultIdentity(runtimeAuth, mirroredSystemDefaultAuth) + ) { + // Why: Codex refreshes tokens in the runtime CODEX_HOME; read that back to ~/.codex so the next sync won't clobber fresh creds with stale ones. + this.writeSystemDefaultAuth(runtimeAuth) + this.captureSystemDefaultSnapshot({ force: true }) + this.writeRuntimeAuth(runtimeAuth, { owner: 'system-default' }) + return + } + // Why: mirror external logins/logouts into Orca's runtime home so unmanaged Codex sessions keep matching the current system-default state. + this.captureSystemDefaultSnapshot({ force: true }) + this.writeRuntimeAuth(systemDefaultAuth, { owner: 'system-default' }) + } catch (error) { + console.warn('[codex-runtime-home] Failed to sync system-default auth:', error) + } + } + + protected syncLegacySharedSystemDefaultAuthForRetainedPanes(): void { + if (this.sharedAuthRefreshBlockedByManagedTransition || this.lastSyncedAccountId !== null) { + this.sharedAuthRefreshBlockedByManagedTransition = false + return + } + const runtimeAuthPath = this.getRuntimeAuthPath() + try { + let provenanceStatus = this.resolveSharedRuntimeAuthProvenanceStatus() + if ( + provenanceStatus.kind === 'committed' && + provenanceStatus.provenance.owner === 'managed' + ) { + const restoredProvenance = this.restoreUntouchedSystemDefaultProvenance( + provenanceStatus.provenance + ) + if (restoredProvenance) { + provenanceStatus = { kind: 'committed', provenance: restoredProvenance } + } + } + if ( + provenanceStatus.kind === 'fenced' || + (provenanceStatus.kind === 'committed' && provenanceStatus.provenance.owner === 'managed') + ) { + return + } + const systemAuth = this.readSystemDefaultAuth() + if (!existsSync(runtimeAuthPath)) { + const logoutMarkerStatus = this.getRuntimeLogoutMarkerStatus() + const snapshot = this.readSystemDefaultSnapshot(this.getSystemDefaultSnapshotPath()) + const knownSystemAuthBaseline = + provenanceStatus.kind === 'committed' && + provenanceStatus.provenance.owner === 'system-default' + ? provenanceStatus.provenance.authJson + : provenanceStatus.kind === 'missing' + ? (this.lastWrittenAuthJson ?? snapshot?.authJson) + : undefined + if (systemAuth === null) { + if ( + provenanceStatus.kind === 'committed' && + provenanceStatus.provenance.owner === 'system-default' && + provenanceStatus.provenance.authJson === null && + logoutMarkerStatus.kind === 'applies' && + snapshot?.authJson === null + ) { + this.lastWrittenAuthJson = null + return + } + // Why: commit a crashed logout before a managed transition can discard its recovery baseline. + this.captureSystemDefaultSnapshot({ force: true }) + this.persistRuntimeLogoutMarker(null) + this.lastWrittenAuthJson = null + this.persistSharedRuntimeAuthProvenance({ owner: 'system-default', authJson: null }) + return + } + if ( + logoutMarkerStatus.kind === 'system-default-changed' || + (knownSystemAuthBaseline !== undefined && knownSystemAuthBaseline !== systemAuth) + ) { + const replaced = this.writeRuntimeAuth( + systemAuth, + { + owner: 'system-default' + }, + { expectedContents: null } + ) + if (replaced) { + this.captureSystemDefaultSnapshot({ force: true }) + } + } + return + } + const runtimeAuthBeforeSync = readFileSync(runtimeAuthPath, 'utf-8') + const snapshot = this.readSystemDefaultSnapshot(this.getSystemDefaultSnapshotPath()) + const provenance = provenanceStatus.kind === 'committed' ? provenanceStatus.provenance : null + const knownSharedAuth = + provenance?.owner === 'system-default' + ? provenance.authJson + : provenanceStatus.kind === 'missing' + ? (this.lastWrittenAuthJson ?? snapshot?.authJson ?? null) + : null + // Why: only bytes Orca can prove it wrote belong to the compatibility + // mirror; retained Codex or a managed transition owns every other value. + if (knownSharedAuth === null) { + return + } + const sharedAuthOwnedBySystemDefault = + runtimeAuthBeforeSync === knownSharedAuth || + (provenance?.owner === 'system-default' && + systemAuth === null && + this.runtimeAuthMatchesSystemDefaultIdentity(runtimeAuthBeforeSync, knownSharedAuth)) + if (!sharedAuthOwnedBySystemDefault) { + return + } + if (systemAuth === null) { + removeFileAtomicallyIfUnchanged(runtimeAuthPath, runtimeAuthBeforeSync) + if (existsSync(runtimeAuthPath)) { + this.persistSharedRuntimeAuthProvenance({ owner: 'fenced' }) + return + } + this.captureSystemDefaultSnapshot({ force: true }) + this.persistRuntimeLogoutMarker(null) + this.lastWrittenAuthJson = null + this.persistSharedRuntimeAuthProvenance({ + owner: 'system-default', + authJson: null + }) + return + } + if (runtimeAuthBeforeSync !== knownSharedAuth) { + return + } + const replaced = this.writeRuntimeAuth( + systemAuth, + { owner: 'system-default' }, + { expectedContents: runtimeAuthBeforeSync } + ) + if (replaced) { + this.captureSystemDefaultSnapshot({ force: true }) + } + } catch (error) { + console.warn('[codex-runtime-home] Failed to refresh retained-pane auth:', error) + } + } + + protected restoreSystemDefaultSnapshot(options: { detectExternalLogin: boolean }): void { + const snapshotPath = this.getSystemDefaultSnapshotPath() + const runtimeAuthPath = this.getRuntimeAuthPath() + const systemDefaultAuthPath = join(getSystemCodexHomePath(), 'auth.json') + if (existsSync(systemDefaultAuthPath)) { + const systemDefaultAuth = readFileSync(systemDefaultAuthPath, 'utf-8') + this.captureSystemDefaultSnapshot({ force: true }) + this.writeRuntimeAuth(systemDefaultAuth, { owner: 'system-default' }) + return + } + + if (options.detectExternalLogin && !existsSync(runtimeAuthPath)) { + // Why: with Orca owning CODEX_HOME, a deleted runtime auth.json is a local logout, not a cue to restore the user's real ~/.codex snapshot. + this.persistRuntimeLogoutMarker() + this.lastWrittenAuthJson = null + this.persistSharedRuntimeAuthProvenance({ owner: 'system-default', authJson: null }) + return + } + + if (options.detectExternalLogin) { + // Why: if ~/.codex/auth.json vanished while a managed account was selected, switching back must preserve that external system-default logout. + rmSync(runtimeAuthPath, { force: true }) + this.captureSystemDefaultSnapshot({ force: true }) + this.persistRuntimeLogoutMarker() + this.lastWrittenAuthJson = null + this.persistSharedRuntimeAuthProvenance({ owner: 'system-default', authJson: null }) + return + } + + if (!existsSync(snapshotPath)) { + this.captureSystemDefaultSnapshot({ force: true }) + } + + const snapshot = this.readSystemDefaultSnapshot(snapshotPath) + if (!snapshot) { + console.warn('[codex-runtime-home] Ignoring invalid system-default auth snapshot') + rmSync(snapshotPath, { force: true }) + this.captureSystemDefaultSnapshot({ force: true }) + const refreshedSnapshot = this.readSystemDefaultSnapshot(snapshotPath) + if (!refreshedSnapshot) { + rmSync(runtimeAuthPath, { force: true }) + this.lastWrittenAuthJson = null + this.persistSharedRuntimeAuthProvenance({ owner: 'system-default', authJson: null }) + return + } + if (refreshedSnapshot.authJson === null) { + rmSync(runtimeAuthPath, { force: true }) + this.lastWrittenAuthJson = null + this.persistSharedRuntimeAuthProvenance({ owner: 'system-default', authJson: null }) + return + } + this.writeRuntimeAuth(refreshedSnapshot.authJson, { owner: 'system-default' }) + return + } + if (snapshot.authJson === null) { + rmSync(runtimeAuthPath, { force: true }) + this.lastWrittenAuthJson = null + this.persistSharedRuntimeAuthProvenance({ owner: 'system-default', authJson: null }) + return + } + this.writeRuntimeAuth(snapshot.authJson, { owner: 'system-default' }) + } + + protected writeSystemDefaultAuth(contents: string): void { + const systemDefaultAuthPath = join(getSystemCodexHomePath(), 'auth.json') + mkdirSync(dirname(systemDefaultAuthPath), { recursive: true }) + writeFileAtomically(systemDefaultAuthPath, contents, { mode: 0o600 }) + this.ensureOwnerOnlyMode(systemDefaultAuthPath) + } + + protected clearRuntimeAuthAfterSystemDefaultLogout(runtimeAuthPath: string): void { + // Why: a vanished ~/.codex auth means external logout for unmanaged sessions, even if runtime auth already refreshed in Orca's CODEX_HOME. + rmSync(runtimeAuthPath, { force: true }) + this.captureSystemDefaultSnapshot({ force: true }) + this.persistRuntimeLogoutMarker() + this.lastWrittenAuthJson = null + this.persistSharedRuntimeAuthProvenance({ + owner: 'system-default', + authJson: null + }) + } +} diff --git a/src/main/codex-accounts/runtime-home-service-home-routing.ts b/src/main/codex-accounts/runtime-home-service-home-routing.ts new file mode 100644 index 00000000000..35573e7f045 --- /dev/null +++ b/src/main/codex-accounts/runtime-home-service-home-routing.ts @@ -0,0 +1,297 @@ +import { posix as pathPosix } from 'node:path' +import { parseWslUncPath, toLinuxPath, toWindowsWslUncPath } from '../../shared/wsl-paths' +import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path' +import { getDefaultWslDistro, getWslHome } from '../wsl' +import { + getSystemCodexHomePath, + syncCodexGlobalInstructionsIntoManagedHome, + syncSystemCodexResourcesIntoManagedHome +} from '../codex/codex-home-paths' +import { syncSystemConfigIntoManagedCodexHome } from '../codex/codex-config-mirror' +import { + getWslSelectionKey, + normalizeCodexRuntimeSelection, + type CodexAccountSelectionTarget +} from './runtime-selection' +import { hasCustomCodexHomeOverrideForLaunch } from '../codex/codex-real-home-path' +import { + hasRecordedLegacySharedCodexPane, + getCodexPaneAccount, + type CodexPaneHomeRoute +} from '../codex/codex-pane-account-registry' +import { isShellStartupEnvProbeSupported } from '../pty/shell-startup-env' +import { ManagedCodexHomeTemporarilyUnavailableError } from './host-codex-managed-home-ownership' +import { syncLegacySharedCodexConfigForRetainedPanes } from './legacy-shared-config-compatibility' +import type { CodexManagedAccount } from '../../shared/managed-account-types' +import type { CodexRateLimitHomeResolution } from './runtime-home-service-types' +import { CodexRuntimeHomeManagedHome } from './runtime-home-service-managed-home' + +export abstract class CodexRuntimeHomeRouting extends CodexRuntimeHomeManagedHome { + getHostCodexHomePathsForSessionDiscovery(): string[] { + const homes = [this.getRuntimeHomePath()] + if (this.isHostSystemDefaultRealHome() || this.getSelfContainedManagedHostAccount()) { + // Why: nested Orca processes can retain an ambient managed CODEX_HOME. + // Per-account lanes no longer bridge real-home history into the shared + // mirror, so include the real root for both directly-routed host lanes. + homes.push(getSystemCodexHomePath()) + } + // Why: account-scoped rollouts live in each account's own home, including WSL. + for (const perAccountHome of this.getManagedAccountHomesForSessionDiscovery()) { + homes.push(perAccountHome) + } + return homes.filter((home, index) => homes.indexOf(home) === index) + } + + /** + * The account-owned CODEX_HOME the current HOST selection runs against, or + * null when the selection is not routed to one (system default, or a WSL + * account, whose home lives inside the distro). + * + * Read-only on purpose: session discovery ranks homes with this before any + * launch prep, so it must create no directories and sync no auth. + */ + getSelectedHostAccountCodexHomePath(): string | null { + const selfContainedAccount = this.getSelfContainedManagedHostAccount() + return selfContainedAccount + ? this.getTrustedSelfContainedManagedHomePath(selfContainedAccount) + : null + } + + /** + * Same selection, but an unreadable home refuses instead of collapsing to + * `null`. Session resume must not read "no managed selection" out of a failed + * marker stat: another account's readable alias would then win the legacy + * rescan and the pane would resume under that account's credentials while the + * UI still shows this one (#STA-4422). + */ + resolveSelectedHostAccountCodexHomePathForResume(): string | null { + const selfContainedAccount = this.getSelfContainedManagedHostAccount() + if (!selfContainedAccount) { + return null + } + const resolved = this.resolveSelfContainedManagedHome(selfContainedAccount) + if (resolved.kind === 'indeterminate') { + throw new ManagedCodexHomeTemporarilyUnavailableError() + } + if (resolved.kind === 'untrusted') { + this.clearSelfContainedManagedSelection(selfContainedAccount) + return null + } + return resolved.homePath + } + + /** Trust-gates host previews without changing WSL routing or durable account state. */ + resolveCodexManagedAccountHomeForInactiveFetch( + account: CodexManagedAccount + ): { kind: 'ready'; homePath: string } | { kind: 'skip' } { + if (account.managedHomeRuntime === 'wsl' || this.getWslManagedHomePath(account)) { + return { kind: 'ready', homePath: account.managedHomePath } + } + const resolved = this.resolveSelfContainedManagedHome(account) + return resolved.kind === 'owned' + ? { kind: 'ready', homePath: resolved.homePath } + : { kind: 'skip' } + } + + getSelectedHostCodexHomeRoute(): CodexPaneHomeRoute { + if (this.getSelfContainedManagedHostAccount()) { + return 'account-home' + } + return this.isHostSystemDefaultRealHome() ? 'real-home' : 'shared-home' + } + + getRetainedHostCodexHookHomePaths(ptyIds: readonly string[]): string[] { + const settings = this.store.getSettings() + const homes = new Map() + for (const ptyId of ptyIds) { + const record = getCodexPaneAccount(ptyId) + if (!record || record.selectionKey !== 'host') { + continue + } + if ( + record.homeRoute === undefined || + record.homeRoute === 'shared-home' || + record.homeRoute === 'custom-home' + ) { + const homePath = this.getRuntimeHomePath() + homes.set(normalizeRuntimePathForComparison(homePath), homePath) + continue + } + if (record.homeRoute !== 'account-home' || !record.accountId) { + continue + } + const account = settings.codexManagedAccounts.find( + (candidate) => candidate.id === record.accountId + ) + if (!account || this.getWslManagedHomePath(account)) { + continue + } + const homePath = this.getTrustedSelfContainedManagedHomePath(account) + if (homePath) { + homes.set(normalizeRuntimePathForComparison(homePath), homePath) + } + } + return [...homes.values()] + } + + // Why: the real-home hook installer flips this gate off when the trust-grant + // client reports the host incapable, keeping that host byte-identical to the + // managed lane instead of shipping status-blind panes. + protected realHomeLaneGate: () => boolean = () => true + + setRealHomeLaneGate(gate: () => boolean): void { + this.realHomeLaneGate = gate + } + + // Why: real-home routing applies only to the host system-default selection. + // Managed accounts run in their own homes; Windows (no shell-startup probe) + // and custom CODEX_HOMEs stay on the mirror until cleanup can be tracked + // across old homes. + isHostSystemDefaultRealHomeSelected(launchEnv?: NodeJS.ProcessEnv): boolean { + const settings = this.store.getSettings() + if ( + normalizeCodexRuntimeSelection(settings).host !== null || + !isShellStartupEnvProbeSupported() + ) { + return false + } + return !hasCustomCodexHomeOverrideForLaunch(launchEnv) + } + + isHostSystemDefaultRealHome(launchEnv?: NodeJS.ProcessEnv): boolean { + return this.isHostSystemDefaultRealHomeSelected(launchEnv) && this.realHomeLaneGate() + } + + reconcileLegacySharedHomeForRetainedPanes(): void { + if (!this.isHostSystemDefaultRealHome() || !hasRecordedLegacySharedCodexPane()) { + return + } + this.syncLegacySharedSystemDefaultAuthForRetainedPanes() + syncLegacySharedCodexConfigForRetainedPanes() + } + + /** Preserve refreshed auth from retained legacy WSL panes before restart. */ + async syncActiveWslSelectionsBeforeRestart(): Promise { + if (process.platform !== 'win32') { + return + } + const settings = this.store.getSettings() + const drains: Promise[] = [] + for (const [selectedDistroKey, accountId] of Object.entries( + normalizeCodexRuntimeSelection(settings).wsl + )) { + if (!accountId) { + continue + } + const account = this.getActiveAccount(settings.codexManagedAccounts, accountId) + if (!account || account.managedHomeRuntime !== 'wsl') { + continue + } + const distro = + selectedDistroKey === getWslSelectionKey(null) + ? account.wslDistro?.trim() || null + : selectedDistroKey.trim() || null + if (distro) { + drains.push(this.startLegacyWslAuthDrain({ runtime: 'wsl', wslDistro: distro })) + } + } + await Promise.all(drains) + } + + protected getWslSystemCodexHomePath(target: CodexAccountSelectionTarget): string | null { + if (process.platform !== 'win32') { + return null + } + const distro = target.wslDistro?.trim() || getDefaultWslDistro() + if (!distro) { + return null + } + const home = getWslHome(distro) + if (home && /^[A-Za-z]:[\\/]/.test(home)) { + const linuxHome = toLinuxPath(home).trim() + return linuxHome.startsWith('/') + ? toWindowsWslUncPath(pathPosix.join(linuxHome, '.codex'), distro) + : null + } + return home ? this.joinWslPath(home, '.codex') : null + } + + protected finishWslLaunchPreparation( + target: CodexAccountSelectionTarget, + homePath: string | null + ): void { + this.syncWslConfigAndGlobalInstructionsForLaunch(target, homePath) + this.startWslSessionBridgeForLaunch(target, homePath) + } + + protected syncWslConfigAndGlobalInstructionsForLaunch( + target: CodexAccountSelectionTarget, + runtimeHomePath: string | null + ): void { + if (!runtimeHomePath) { + return + } + const distro = + parseWslUncPath(runtimeHomePath)?.distro || target.wslDistro?.trim() || getDefaultWslDistro() + if (!distro) { + return + } + const systemHomePath = this.getWslSystemCodexHomePath({ runtime: 'wsl', wslDistro: distro }) + if (!systemHomePath || systemHomePath === runtimeHomePath) { + return + } + // Why: WSL uses a distro-local CODEX_HOME, so host resource mirroring can't provide the distro user's global instructions. + syncCodexGlobalInstructionsIntoManagedHome({ + systemHomePath, + managedHomePath: runtimeHomePath + }) + syncSystemConfigIntoManagedCodexHome({ + runtimeHomePath, + systemHomePath, + systemConfigDir: toLinuxPath(systemHomePath) + }) + } + + // Why: `null` is a real value here — it means "use the system-default lane". + // A skipped poll needs its own channel or the fetcher silently retargets the + // user's real ~/.codex (#STA-4422). + prepareForRateLimitFetch(target?: CodexAccountSelectionTarget): CodexRateLimitHomeResolution { + if (target?.runtime === 'wsl') { + const wslTarget = this.resolveWslDefaultTarget(target) + return { + kind: 'ready', + codexHomePath: this.getPreparedWslRateLimitHomePath(wslTarget) + } + } + const selfContainedAccount = this.getSelfContainedManagedHostAccount() + if (selfContainedAccount) { + const resolved = this.resolveSelfContainedManagedHome(selfContainedAccount) + if (resolved.kind === 'owned') { + // Why: the quota fetch reads the account's own auth.json in place; no + // shared-home hot-swap or per-poll resource relink (that is launch prep). + return { kind: 'ready', codexHomePath: resolved.homePath } + } + if (resolved.kind === 'indeterminate') { + // Why: returning null here would NOT skip — the fetcher maps null to + // ~/.codex and would probe the user's real home with a token-refreshing + // app-server. Skip the poll outright and keep the selection. + return { kind: 'skip' } + } + this.clearSelfContainedManagedSelection(selfContainedAccount) + } + if (this.isHostSystemDefaultRealHome()) { + // Why: null lets the fetcher fall back to the main process's inherited + // CODEX_HOME before ~/.codex. Nested Orca launches can inherit the + // managed home, restarting the background OAuth conflict (#5370), so + // pin this non-interactive lane to the native home explicitly. + if (hasRecordedLegacySharedCodexPane()) { + this.syncLegacySharedSystemDefaultAuthForRetainedPanes() + } + return { kind: 'ready', codexHomePath: getSystemCodexHomePath() } + } + this.syncForCurrentSelection() + syncSystemCodexResourcesIntoManagedHome() + syncSystemConfigIntoManagedCodexHome() + return { kind: 'ready', codexHomePath: this.getRuntimeHomePath() } + } +} diff --git a/src/main/codex-accounts/runtime-home-service-launch.ts b/src/main/codex-accounts/runtime-home-service-launch.ts new file mode 100644 index 00000000000..e25be92abe1 --- /dev/null +++ b/src/main/codex-accounts/runtime-home-service-launch.ts @@ -0,0 +1,154 @@ +import { resolveHostCodexSessionSourceHome } from '../codex/codex-session-source-home' +import { startSystemCodexSessionBridgeInBackground } from '../codex/codex-session-bridge' +import { syncSystemCodexResourcesIntoManagedHome } from '../codex/codex-home-paths' +import { syncSystemConfigIntoManagedCodexHome } from '../codex/codex-config-mirror' +import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path' +import { + normalizeCodexRuntimeSelection, + type CodexAccountSelectionTarget +} from './runtime-selection' +import { hasCustomCodexHomeOverrideForLaunch } from '../codex/codex-real-home-path' +import { markCodexSessionBackfillMarkerPending } from '../codex/codex-session-backfill-marker' +import { getCodexSessionBackfillDate } from '../codex/codex-session-backfill-scan-dates' +import { resolveCodexSessionBackfillPaths } from '../codex/codex-session-backfill' +import type { CodexSessionBackfillDate } from '../codex/codex-session-backfill-types' +import { CodexRuntimeHomeRouting } from './runtime-home-service-home-routing' + +export abstract class CodexRuntimeHomeLaunch extends CodexRuntimeHomeRouting { + protected initializeLastSyncedState(): void { + const settings = this.store.getSettings() + const activeAccount = this.getActiveAccount( + settings.codexManagedAccounts, + normalizeCodexRuntimeSelection(settings).host + ) + // Why: WSL-managed homes never touch host ~/.codex; treating one as "last synced" makes cold start mangle host auth Orca never touched. + this.lastSyncedAccountId = this.getWslManagedHomePath(activeAccount) + ? null + : normalizeCodexRuntimeSelection(settings).host + } + + /** + * Materializes the runtime home needed before launching the CLI. + * + * Historical session bridging is requested in the background so launch setup + * returns as soon as the active runtime home is ready. + */ + prepareForCodexLaunch( + target?: CodexAccountSelectionTarget, + launchEnv?: NodeJS.ProcessEnv, + options?: { unavailableManagedHomePath?: string } + ): string | null { + if (target?.runtime === 'wsl') { + const wslTarget = this.resolveWslDefaultTarget(target) + const homePath = this.getWslCodexHomePathForSelection(wslTarget) + this.startLegacyWslAuthDrain(wslTarget) + this.finishWslLaunchPreparation(wslTarget, homePath) + return homePath + } + const selfContainedAccount = this.getSelfContainedManagedHostAccount() + if (selfContainedAccount) { + const perAccountHome = this.prepareSelfContainedManagedHomeForLaunch( + selfContainedAccount, + options?.unavailableManagedHomePath + ) + if (perAccountHome) { + return perAccountHome + } + // Why: only an untrusted home clears the selection; fall through to the + // system default without injecting a path Orca cannot prove it owns. + } + if (this.isHostSystemDefaultRealHome(launchEnv)) { + // Why: the system default runs Codex on the user's own ~/.codex. + // Returning null tells the PTY/env layer to inject no managed CODEX_HOME; + // the retired mirror is refreshed only for pre-rollout PTYs. + this.reconcileLegacySharedHomeForRetainedPanes() + return null + } + this.invalidateBackfillAfterManagedSystemDefaultLaunch(launchEnv) + this.syncForCurrentSelection(target, launchEnv) + syncSystemCodexResourcesIntoManagedHome() + syncSystemConfigIntoManagedCodexHome() + // Why: sessions can be large; bridge them after launch so starting a fresh TUI never waits on a full tree walk. + void startSystemCodexSessionBridgeInBackground( + {}, + resolveHostCodexSessionSourceHome(this.store.getSettings()) + ) + return this.getRuntimeHomePath() + } + + async prepareForCodexLaunchAsync( + target?: CodexAccountSelectionTarget, + launchEnv?: NodeJS.ProcessEnv, + options?: { unavailableManagedHomePath?: string } + ): Promise { + if (target?.runtime !== 'wsl') { + return this.prepareForCodexLaunch(target, launchEnv, options) + } + const wslTarget = this.resolveWslDefaultTarget(target) + const homePath = this.getWslCodexHomePathForSelection(wslTarget) + // Why: the retired home may hold the freshest credential, so the first + // direct-home Codex spawn must wait for its bounded guest transaction. + await this.startLegacyWslAuthDrain(wslTarget, { throwOnFailure: true }) + this.finishWslLaunchPreparation(wslTarget, homePath) + return homePath + } + + beginHostSystemDefaultSessionMigrationLaunch( + codexHomePath: string | null, + options: { reattached?: boolean; launchEnv?: NodeJS.ProcessEnv } = {} + ): boolean | null { + if ( + !this.isHostSystemDefaultSessionMigrationEligible() || + (!codexHomePath && !options.reattached) || + (codexHomePath && + normalizeRuntimePathForComparison(codexHomePath) !== + normalizeRuntimePathForComparison(this.getRuntimeHomePath())) + ) { + return null + } + // Why: an older pass can clear launch preparation while PTY spawn awaits recovery. + return this.invalidateBackfillAfterManagedSystemDefaultLaunch( + options.reattached && !codexHomePath ? undefined : options.launchEnv + ) + } + + isHostSystemDefaultSessionMigrationEligible(): boolean { + return ( + normalizeCodexRuntimeSelection(this.store.getSettings()).host === null && + !hasCustomCodexHomeOverrideForLaunch() + ) + } + + prepareHostSystemDefaultSessionMigrationPass( + scanDates: readonly CodexSessionBackfillDate[] = [] + ): boolean { + const paths = resolveCodexSessionBackfillPaths( + resolveHostCodexSessionSourceHome(this.store.getSettings()) + ) + const target = normalizeRuntimePathForComparison(paths.systemSessionsRoot) + if ( + this.hostSystemDefaultSessionMigrationPending && + this.pendingHostSystemDefaultSessionMigrationTarget !== target + ) { + this.pendingHostSystemDefaultSessionMigrationNeedsFullScan = true + this.pendingHostSystemDefaultSessionMigrationTarget = target + } + // Why: the launch creates rollouts for these dates; record them durably so a + // force-quit recovers a bounded window instead of re-walking all history. + const markerOwesFullScan = markCodexSessionBackfillMarkerPending( + paths.markerPath, + paths.systemSessionsRoot, + scanDates.length > 0 ? scanDates : [getCodexSessionBackfillDate()] + ) + // Why: the marker is the only place an overflowed pending window survives a + // restart, so its demand has to reach this pass rather than die in the file. + this.pendingHostSystemDefaultSessionMigrationNeedsFullScan ||= markerOwesFullScan + return this.pendingHostSystemDefaultSessionMigrationNeedsFullScan + } + + finishHostSystemDefaultSessionMigrationPass(): void { + this.hostSystemDefaultSessionMigrationPending = false + this.pendingHostSystemDefaultSessionMigrationNeedsFullScan = false + this.pendingHostSystemDefaultSessionMigrationTarget = null + } +} diff --git a/src/main/codex-accounts/runtime-home-service-legacy-migration.ts b/src/main/codex-accounts/runtime-home-service-legacy-migration.ts new file mode 100644 index 00000000000..dcb337cac29 --- /dev/null +++ b/src/main/codex-accounts/runtime-home-service-legacy-migration.ts @@ -0,0 +1,204 @@ +import { + appendFileSync, + copyFileSync, + existsSync, + mkdirSync, + readFileSync, + readdirSync, + statSync +} from 'node:fs' +import { dirname, extname, join, parse, relative } from 'node:path' +import { writeFileAtomically } from './fs-utils' +import { migrateLegacySharedAuthToPerAccountHome } from './legacy-shared-auth-migration' +import { normalizeCodexRuntimeSelection } from './runtime-selection' +import { getSystemCodexHomePath } from '../codex/codex-home-paths' +import { CodexRuntimeHomePaths } from './runtime-home-service-paths' + +export abstract class CodexRuntimeHomeLegacyMigration extends CodexRuntimeHomePaths { + protected safeMigrateLegacySharedAuth(): void { + const settings = this.store.getSettings() + try { + migrateLegacySharedAuthToPerAccountHome({ + activeHostAccountId: normalizeCodexRuntimeSelection(settings).host, + hostAccounts: settings.codexManagedAccounts.filter( + (account) => !this.getWslManagedHomePath(account) + ), + managedAccountsRoot: this.getManagedAccountsRoot(), + metadataDir: this.getRuntimeMetadataDir(), + sharedRuntimeHome: this.getRuntimeHomePath(), + systemCodexHome: getSystemCodexHomePath() + }) + } catch (error) { + // Why: an inconclusive identity, ownership, or filesystem result must + // leave the marker absent so the next startup can retry safely. + console.warn('[codex-runtime-home] Failed to migrate legacy shared Codex auth:', error) + } + } + + protected safeMigrateLegacyManagedState(): void { + try { + this.migrateLegacyManagedStateIfNeeded() + } catch (error) { + console.warn('[codex-runtime-home] Failed to migrate legacy managed Codex state:', error) + } + } + + protected safeMigrateLegacyActiveHomePointer(): void { + try { + const activeHomePath = this.getLegacyHostActiveHomePath() + if (!this.legacyActiveHomePathExists(activeHomePath)) { + return + } + this.repointLegacyActiveHomePointer(activeHomePath, this.getRuntimeHomePath()) + } catch (error) { + console.warn('[codex-runtime-home] Failed to migrate legacy active Codex home:', error) + } + } + + protected migrateLegacyManagedStateIfNeeded(): void { + if (existsSync(this.getMigrationMarkerPath())) { + return + } + + const managedHomes = this.getLegacyManagedHomes() + for (const managedHomePath of managedHomes) { + const accountId = parse(relative(this.getManagedAccountsRoot(), managedHomePath)).dir.split( + /[\\/]/ + )[0] + if (!accountId) { + continue + } + this.migrateLegacyHistory(managedHomePath) + this.migrateLegacySessions(managedHomePath, accountId) + } + + // Why: migration is one-shot; re-importing every startup would replay stale managed-home state into the shared runtime. + writeFileAtomically( + this.getMigrationMarkerPath(), + `${JSON.stringify({ completedAt: Date.now(), migratedHomeCount: managedHomes.length })}\n` + ) + } + + protected getLegacyManagedHomes(): string[] { + const managedAccountsRoot = this.getManagedAccountsRoot() + if (!existsSync(managedAccountsRoot)) { + return [] + } + + const accountEntries = readdirSync(managedAccountsRoot, { withFileTypes: true }) + const managedHomes: string[] = [] + for (const entry of accountEntries) { + if (!entry.isDirectory()) { + continue + } + const managedHomePath = join(managedAccountsRoot, entry.name, 'home') + if (existsSync(join(managedHomePath, '.orca-managed-home'))) { + managedHomes.push(managedHomePath) + } + } + return managedHomes.sort() + } + + protected migrateLegacyHistory(managedHomePath: string): void { + const legacyHistoryPath = join(managedHomePath, 'history.jsonl') + if (!existsSync(legacyHistoryPath)) { + return + } + + const runtimeHistoryPath = join(this.getRuntimeHomePath(), 'history.jsonl') + const existingLines = existsSync(runtimeHistoryPath) + ? readFileSync(runtimeHistoryPath, 'utf-8').split('\n').filter(Boolean) + : [] + const mergedLines = [...existingLines] + const seenLines = new Set(existingLines) + for (const line of readFileSync(legacyHistoryPath, 'utf-8').split('\n')) { + if (!line || seenLines.has(line)) { + continue + } + seenLines.add(line) + mergedLines.push(line) + } + + if (mergedLines.length === 0) { + return + } + writeFileAtomically(runtimeHistoryPath, `${mergedLines.join('\n')}\n`) + } + + protected migrateLegacySessions(managedHomePath: string, accountId: string): void { + const legacySessionsRoot = join(managedHomePath, 'sessions') + if (!existsSync(legacySessionsRoot)) { + return + } + + const runtimeSessionsRoot = join(this.getRuntimeHomePath(), 'sessions') + mkdirSync(runtimeSessionsRoot, { recursive: true }) + for (const legacyFilePath of this.listFilesRecursively(legacySessionsRoot)) { + const relativePath = relative(legacySessionsRoot, legacyFilePath) + const runtimeFilePath = join(runtimeSessionsRoot, relativePath) + mkdirSync(dirname(runtimeFilePath), { recursive: true }) + if (!existsSync(runtimeFilePath)) { + copyFileSync(legacyFilePath, runtimeFilePath) + continue + } + + const legacyContents = readFileSync(legacyFilePath) + const runtimeContents = readFileSync(runtimeFilePath) + if (runtimeContents.equals(legacyContents)) { + continue + } + + const preservedPath = this.getPreservedLegacySessionPath(runtimeFilePath, accountId) + copyFileSync(legacyFilePath, preservedPath) + this.appendMigrationDiagnostic({ + type: 'session-conflict', + accountId, + runtimeFilePath, + preservedPath + }) + } + } + + protected listFilesRecursively(rootPath: string): string[] { + const stat = statSync(rootPath) + if (!stat.isDirectory()) { + return [rootPath] + } + + const files: string[] = [] + for (const entry of readdirSync(rootPath, { withFileTypes: true })) { + const childPath = join(rootPath, entry.name) + if (entry.isDirectory()) { + this.appendListedFiles(files, this.listFilesRecursively(childPath)) + continue + } + if (entry.isFile()) { + files.push(childPath) + } + } + return files.sort() + } + + protected appendListedFiles(target: string[], source: readonly string[]): void { + // Why: tolerate directories larger than V8's argument limit for spread calls. + for (const filePath of source) { + target.push(filePath) + } + } + + protected getPreservedLegacySessionPath(runtimeFilePath: string, accountId: string): string { + const extension = extname(runtimeFilePath) + const basename = runtimeFilePath.slice(0, runtimeFilePath.length - extension.length) + return `${basename}.orca-legacy-${accountId}${extension}` + } + + protected appendMigrationDiagnostic(record: Record): void { + const diagnosticsPath = this.getMigrationDiagnosticsPath() + try { + appendFileSync(diagnosticsPath, `${JSON.stringify(record)}\n`, { encoding: 'utf-8' }) + } catch (error) { + // Why: diagnostics must not fail the one-shot migration after the session file is already preserved. + console.warn('[codex-runtime-home] Failed to append migration diagnostic:', error) + } + } +} diff --git a/src/main/codex-accounts/runtime-home-service-managed-home.ts b/src/main/codex-accounts/runtime-home-service-managed-home.ts new file mode 100644 index 00000000000..817b2f7adc4 --- /dev/null +++ b/src/main/codex-accounts/runtime-home-service-managed-home.ts @@ -0,0 +1,278 @@ +import { join } from 'node:path' +import { + syncSystemCodexResourcesIntoManagedHome, + getSystemCodexHomePath, + resolveOrcaManagedCodexHomePath +} from '../codex/codex-home-paths' +import { syncSystemConfigIntoManagedCodexHome } from '../codex/codex-config-mirror' +import { startCodexAccountSessionBridgeInBackground } from '../codex/codex-account-session-bridge' +import { + resolveHostCodexSessionSourceHome, + resolveWslCodexSessionSourceHome +} from '../codex/codex-session-source-home' +import { parseWslUncPath } from '../../shared/wsl-paths' +import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path' +import { normalizeCodexRuntimeSelection } from './runtime-selection' +import { + resolveHostCodexManagedHomeVerdict, + ManagedCodexHomeTemporarilyUnavailableError +} from './host-codex-managed-home-ownership' +import { hasCustomCodexHomeOverrideForLaunch } from '../codex/codex-real-home-path' +import { resolveCodexSessionBackfillPaths } from '../codex/codex-session-backfill' +import { hasCompletedCodexSessionBackfillMarker } from '../codex/codex-session-backfill-marker' +import { getDefaultWslDistro } from '../wsl' +import { startWslCodexSessionBridgeInBackground } from '../codex/wsl-codex-session-bridge' +import type { CodexAccountSelectionTarget } from './runtime-selection' +import type { CodexManagedAccount } from '../../shared/managed-account-types' +import { CodexRuntimeHomeSync } from './runtime-home-service-sync' + +export abstract class CodexRuntimeHomeManagedHome extends CodexRuntimeHomeSync { + // Why: a managed HOST account runs against its own self-contained CODEX_HOME + // (codex-accounts//home) rather than the shared runtime mirror. Its + // auth.json lives there and codex refreshes it in place, so two accounts never + // race one auth.json. WSL accounts keep their per-distro lane. + protected getSelfContainedManagedHostAccount(): CodexManagedAccount | null { + const settings = this.store.getSettings() + const account = this.getActiveAccount( + settings.codexManagedAccounts, + normalizeCodexRuntimeSelection(settings).host + ) + if (!account || this.getWslManagedHomePath(account)) { + return null + } + return account + } + + // Why: session discovery must surface every account's own rollouts wherever they live. + protected getManagedAccountHomesForSessionDiscovery(): string[] { + const settings = this.store.getSettings() + const homes: string[] = [] + for (const account of settings.codexManagedAccounts) { + const wslHome = this.getWslManagedHomePath(account) + if (wslHome) { + homes.push(wslHome) + continue + } + const trustedHome = this.getTrustedSelfContainedManagedHomePath(account) + if (trustedHome) { + homes.push(trustedHome) + } + } + return homes + } + + protected getManagedHostAccountHomesForSessionDiscovery(): string[] { + const settings = this.store.getSettings() + const homes: string[] = [] + for (const account of settings.codexManagedAccounts) { + if (this.getWslManagedHomePath(account)) { + continue + } + const trustedHome = this.getTrustedSelfContainedManagedHomePath(account) + if (trustedHome) { + homes.push(trustedHome) + } + } + return homes + } + + protected prepareSelfContainedManagedHomeForLaunch( + account: CodexManagedAccount, + unavailableManagedHomePath?: string + ): string | null { + const resolved = this.resolveSelfContainedManagedHome(account) + if (resolved.kind === 'indeterminate') { + // Why: refuse the launch rather than silently falling through to the + // system default, which would run a different account behind a UI still + // showing this one. The selection stays put; a later read may succeed. + throw new ManagedCodexHomeTemporarilyUnavailableError() + } + if (resolved.kind === 'untrusted') { + this.clearSelfContainedManagedSelection(account) + return null + } + const perAccountHome = resolved.homePath + if ( + unavailableManagedHomePath && + normalizeRuntimePathForComparison(unavailableManagedHomePath) === + normalizeRuntimePathForComparison(perAccountHome) + ) { + const absence = this.credentialAbsenceGrace.assess(join(perAccountHome, 'auth.json')) + if (absence.state !== 'present' && absence.durable) { + this.clearSelfContainedManagedSelection(account, 'credential remained unavailable') + return null + } + // Why: a transient missing/unreadable auth.json is usually codex rotating + // it; keep the selection and launch — the CLI re-reads the settled file. + } + // Why: link the user's real ~/.codex resources and mirror config into THIS + // home (never symlinking into or mutating ~/.codex), so the per-account home + // is a complete CODEX_HOME. Hooks/trust are installed by the launch caller. + this.lastSyncedAccountId = account.id + this.lastHostAccountUsedSelfContainedHome = true + this.sharedAuthRefreshBlockedByManagedTransition = true + this.markSharedRuntimeAuthManaged(account.id) + syncSystemCodexResourcesIntoManagedHome(perAccountHome) + syncSystemConfigIntoManagedCodexHome({ + runtimeHomePath: perAccountHome, + systemHomePath: getSystemCodexHomePath() + }) + this.startSelfContainedSessionBridgeForLaunch(perAccountHome) + return perAccountHome + } + + // Why: Codex's own `/resume` picker only lists rollouts under the launch + // CODEX_HOME, so a self-contained account home starts out with no history at + // all. Hardlink every other Orca-visible home's rollouts in — after launch, + // since history trees can be large — so switching accounts no longer hides + // the user's conversations. + protected startSelfContainedSessionBridgeForLaunch(perAccountHome: string): void { + void startCodexAccountSessionBridgeInBackground({ + targetCodexHomePath: perAccountHome, + sourceCodexHomePaths: this.getSelfContainedSessionBridgeSourceHomes() + }) + } + + protected getSelfContainedSessionBridgeSourceHomes(): string[] { + return [ + // Why: history-only override lets custom-CODEX_HOME users bridge from the + // home they actually record sessions in; falls back to the real ~/.codex. + resolveHostCodexSessionSourceHome(this.store.getSettings()) ?? getSystemCodexHomePath(), + // Why: path only — a per-account install must not materialize the mirror. + resolveOrcaManagedCodexHomePath(), + ...this.getManagedHostAccountHomesForSessionDiscovery() + ] + } + + // Why: the per-account home is both the launch CODEX_HOME and the credential + // store, so codex reads/refreshes auth.json in place — there is no shared-home + // hot-swap or token read-back to reconcile. A trusted home remains selected + // while Codex atomically replaces auth.json. + protected syncSelfContainedManagedSelection(account: CodexManagedAccount): void { + const resolved = this.resolveSelfContainedManagedHome(account) + if (resolved.kind === 'indeterminate') { + // Why: a sync runs on every app start, exactly when antivirus is busiest. + // An unreadable home must not deselect the account (#STA-4422). + return + } + const perAccountHome = resolved.kind === 'owned' ? resolved.homePath : null + if (perAccountHome) { + this.lastSyncedAccountId = account.id + this.lastHostAccountUsedSelfContainedHome = true + this.sharedAuthRefreshBlockedByManagedTransition = true + this.markSharedRuntimeAuthManaged(account.id) + // Why: selection runs well before the user restarts a pane, so history is + // already linked in by the time the newly launched Codex opens /resume. + this.startSelfContainedSessionBridgeForLaunch(perAccountHome) + return + } + this.clearSelfContainedManagedSelection(account) + } + + /** + * Why: an unreadable home and an untrustworthy one demand opposite responses. + * Only `untrusted` may clear the user's selection; `indeterminate` means we + * could not tell, so callers refuse the operation and leave durable state + * alone (#STA-4422). + */ + protected resolveSelfContainedManagedHome( + account: CodexManagedAccount + ): { kind: 'owned'; homePath: string } | { kind: 'untrusted' } | { kind: 'indeterminate' } { + const verdict = resolveHostCodexManagedHomeVerdict({ + candidatePath: account.managedHomePath, + managedAccountsRoot: this.getManagedAccountsRoot(), + systemCodexHomePath: getSystemCodexHomePath(), + expectedAccountId: account.id + }) + if (verdict.kind === 'owned') { + // Preserve the persisted path spelling (notably /var vs /private/var on + // macOS) so injected CODEX_HOME stays stable across the rollout. + return { kind: 'owned', homePath: account.managedHomePath } + } + if (verdict.kind === 'untrusted') { + console.warn('[codex-runtime-home] Refusing untrusted managed account home:', verdict.reason) + return { kind: 'untrusted' } + } + console.warn( + '[codex-runtime-home] Managed account home is temporarily unreadable; keeping selection:', + verdict.error + ) + return { kind: 'indeterminate' } + } + + /** Read-only callers that mutate nothing and simply skip an unusable home. */ + protected getTrustedSelfContainedManagedHomePath(account: CodexManagedAccount): string | null { + const resolved = this.resolveSelfContainedManagedHome(account) + return resolved.kind === 'owned' ? resolved.homePath : null + } + + protected clearSelfContainedManagedSelection( + account: CodexManagedAccount, + reason = 'home is invalid' + ): void { + console.warn(`[codex-runtime-home] Active managed account ${reason}, clearing selection`) + const settings = this.store.getSettings() + if (normalizeCodexRuntimeSelection(settings).host !== account.id) { + return + } + this.store.updateSettings({ + activeCodexManagedAccountId: null, + activeCodexManagedAccountIdsByRuntime: { + ...normalizeCodexRuntimeSelection(settings), + host: null + } + }) + this.lastSyncedAccountId = null + this.lastHostAccountUsedSelfContainedHome = false + } + + protected invalidateBackfillAfterManagedSystemDefaultLaunch( + launchEnv?: NodeJS.ProcessEnv + ): boolean | null { + const settings = this.store.getSettings() + if ( + normalizeCodexRuntimeSelection(settings).host !== null || + hasCustomCodexHomeOverrideForLaunch(launchEnv) + ) { + return null + } + if (!this.hostSystemDefaultSessionMigrationPending) { + const paths = resolveCodexSessionBackfillPaths( + resolveHostCodexSessionSourceHome(this.store.getSettings()) + ) + this.pendingHostSystemDefaultSessionMigrationNeedsFullScan = + !hasCompletedCodexSessionBackfillMarker(paths.markerPath, paths.systemSessionsRoot) + this.pendingHostSystemDefaultSessionMigrationTarget = normalizeRuntimePathForComparison( + paths.systemSessionsRoot + ) + this.hostSystemDefaultSessionMigrationPending = true + } + return this.prepareHostSystemDefaultSessionMigrationPass() + } + + protected startWslSessionBridgeForLaunch( + target: CodexAccountSelectionTarget, + runtimeHomePath: string | null + ): void { + if (process.platform !== 'win32' || !runtimeHomePath) { + return + } + const runtimeHomeWsl = parseWslUncPath(runtimeHomePath) + const distro = target.wslDistro?.trim() || runtimeHomeWsl?.distro || getDefaultWslDistro() + if (!distro) { + return + } + // Why: history-only override lets custom-CODEX_HOME users bridge from their real home; falls back to /.codex. + const systemCodexHomePath = + resolveWslCodexSessionSourceHome(this.store.getSettings(), distro) ?? + this.getWslSystemCodexHomePath({ runtime: 'wsl', wslDistro: distro }) + if (systemCodexHomePath && systemCodexHomePath !== runtimeHomePath) { + // Why: WSL history must be hardlinked inside the distro; host-side links can't bridge Windows and WSL filesystems in a resume-visible way. + void startWslCodexSessionBridgeInBackground({ + distro, + systemCodexHomePath, + managedCodexHomePath: runtimeHomePath + }) + } + } +} diff --git a/src/main/codex-accounts/runtime-home-service-paths.ts b/src/main/codex-accounts/runtime-home-service-paths.ts new file mode 100644 index 00000000000..87ef3cedeaa --- /dev/null +++ b/src/main/codex-accounts/runtime-home-service-paths.ts @@ -0,0 +1,187 @@ +import { + lstatSync, + mkdirSync, + readlinkSync, + renameSync, + rmdirSync, + symlinkSync, + unlinkSync +} from 'node:fs' +import { app } from 'electron' +import { dirname, isAbsolute, join, resolve } from 'node:path' +import { getOrcaManagedCodexHomePath, getOrcaUserDataPath } from '../codex/codex-home-paths' +import type { CodexMirroredHomeStatus } from './runtime-home-service-types' +import { CodexRuntimeHomeState } from './runtime-home-service-state' + +export abstract class CodexRuntimeHomePaths extends CodexRuntimeHomeState { + protected getRuntimeHomePath(): string { + return getOrcaManagedCodexHomePath() + } + + /** + * Resolves the managed home the config mirror actually targets for the + * current HOST selection, or null when no mirror runs for it. + * + * Read-only on purpose: unlike the launch and quota-fetch paths this prepares + * nothing and creates no directories, so surfacing sync health cannot alter + * the state it is reporting on. Returns null for the system default on the + * real-home lane, which runs Codex directly against ~/.codex — there is no + * mirror there, so there is nothing that can fall behind. + */ + getMirroredHostHomePathForStatus(): CodexMirroredHomeStatus { + const selfContainedAccount = this.getSelfContainedManagedHostAccount() + if (selfContainedAccount) { + const resolved = this.resolveSelfContainedManagedHome(selfContainedAccount) + if (resolved.kind === 'indeterminate') { + // Why: `null` here is a positive claim that no mirror exists, which the + // status channel reports as healthy. An unreadable home is not that. + return { kind: 'unavailable' } + } + return { kind: 'ready', homePath: resolved.kind === 'owned' ? resolved.homePath : null } + } + if (this.isHostSystemDefaultRealHome()) { + return { kind: 'ready', homePath: null } + } + return { + kind: 'ready', + homePath: join(getOrcaUserDataPath(), 'codex-runtime-home', 'home') + } + } + + protected getRuntimeAuthPath(): string { + return join(this.getRuntimeHomePath(), 'auth.json') + } + + protected getSystemDefaultSnapshotPath(): string { + return join(this.getRuntimeMetadataDir(), 'system-default-auth.json') + } + + protected getRuntimeLogoutMarkerPath(): string { + return join(this.getRuntimeMetadataDir(), 'system-default-runtime-logout.json') + } + + protected getSharedRuntimeAuthProvenancePath(): string { + return join(this.getRuntimeMetadataDir(), 'shared-runtime-auth-provenance.json') + } + + protected getRuntimeMetadataDir(): string { + const metadataDir = join(app.getPath('userData'), 'codex-runtime-home') + mkdirSync(metadataDir, { recursive: true }) + return metadataDir + } + + protected getLegacyHostActiveHomePath(): string { + return join(this.getRuntimeMetadataDir(), 'active', 'host', 'home') + } + + protected getMigrationMarkerPath(): string { + return join(this.getRuntimeMetadataDir(), 'migration-v1.json') + } + + protected getMigrationDiagnosticsPath(): string { + return join(this.getRuntimeMetadataDir(), 'migration-diagnostics.jsonl') + } + + protected getManagedAccountsRoot(): string { + return join(app.getPath('userData'), 'codex-accounts') + } + + protected repointLegacyActiveHomePointer(activeHomePath: string, runtimeHomePath: string): void { + if (this.activeHomeAlreadyPointsToRuntimeHome(activeHomePath, runtimeHomePath)) { + return + } + if (!this.legacyActiveHomeLinkIsReplaceable(activeHomePath)) { + return + } + + mkdirSync(runtimeHomePath, { recursive: true }) + mkdirSync(dirname(activeHomePath), { recursive: true }) + const nextLinkPath = `${activeHomePath}.next-${process.pid}-${Date.now()}` + this.removeLegacyActiveHomeLinkIfOwned(nextLinkPath) + try { + symlinkSync( + runtimeHomePath, + nextLinkPath, + process.platform === 'win32' && lstatSync(runtimeHomePath).isDirectory() + ? 'junction' + : undefined + ) + try { + renameSync(nextLinkPath, activeHomePath) + } catch (error) { + if (!this.legacyActiveHomeLinkIsReplaceable(activeHomePath)) { + throw error + } + this.removeLegacyActiveHomeLinkIfOwned(activeHomePath) + renameSync(nextLinkPath, activeHomePath) + } + } finally { + this.removeLegacyActiveHomeLinkIfOwned(nextLinkPath) + } + } + + protected activeHomeAlreadyPointsToRuntimeHome( + activeHomePath: string, + runtimeHomePath: string + ): boolean { + try { + return this.linkTargetsMatch(readlinkSync(activeHomePath), activeHomePath, runtimeHomePath) + } catch { + return false + } + } + + protected linkTargetsMatch( + linkTarget: string, + linkPath: string, + expectedTargetPath: string + ): boolean { + const resolvedLinkTarget = isAbsolute(linkTarget) + ? resolve(linkTarget) + : resolve(dirname(linkPath), linkTarget) + return resolvedLinkTarget === resolve(expectedTargetPath) + } + + protected legacyActiveHomeLinkIsReplaceable(activeHomePath: string): boolean { + try { + const stat = lstatSync(activeHomePath) + return stat.isSymbolicLink() || this.isWindowsReadableLink(activeHomePath) + } catch { + return true + } + } + + protected legacyActiveHomePathExists(activeHomePath: string): boolean { + try { + lstatSync(activeHomePath) + return true + } catch { + return false + } + } + + protected removeLegacyActiveHomeLinkIfOwned(activeHomePath: string): void { + try { + const stat = lstatSync(activeHomePath) + if (stat.isSymbolicLink()) { + unlinkSync(activeHomePath) + } else if (this.isWindowsReadableLink(activeHomePath)) { + rmdirSync(activeHomePath) + } + } catch { + // Missing or inaccessible temporary links are handled by the caller. + } + } + + protected isWindowsReadableLink(targetPath: string): boolean { + if (process.platform !== 'win32') { + return false + } + try { + readlinkSync(targetPath) + return true + } catch { + return false + } + } +} diff --git a/src/main/codex-accounts/runtime-home-service-state.ts b/src/main/codex-accounts/runtime-home-service-state.ts new file mode 100644 index 00000000000..d6a6691fbb4 --- /dev/null +++ b/src/main/codex-accounts/runtime-home-service-state.ts @@ -0,0 +1,264 @@ +import type { CodexManagedAccount } from '../../shared/managed-account-types' +import type { Store } from '../persistence' +import { CodexCredentialAbsenceGrace } from './codex-credential-absence-grace' +import type { + CodexMirroredHomeStatus, + CodexRateLimitHomeResolution, + CodexReadBackMatch, + CodexRuntimeLogoutMarker, + CodexRuntimeLogoutMarkerStatus, + CodexSharedRuntimeAuthPendingProvenance, + CodexSelfContainedManagedHomeResolution, + CodexSharedRuntimeAuthProvenance, + CodexSharedRuntimeAuthProvenanceFile, + CodexSharedRuntimeAuthProvenanceStatus, + CodexSystemDefaultSnapshot +} from './runtime-home-service-types' +import type { CodexSessionBackfillDate } from '../codex/codex-session-backfill-types' +import type { CodexPaneHomeRoute } from '../codex/codex-pane-account-registry' +import type { CodexAccountSelectionTarget } from './runtime-selection' +import type { LegacyWslRuntimeAuthDestination } from './legacy-wsl-runtime-auth-drain' +import type { WslCodexAuthRead } from './wsl-codex-auth-batch-reader' + +/** Shared state and method contracts for the focused runtime-home layers. */ +export abstract class CodexRuntimeHomeState { + // Which managed account runtime auth.json mirrors; null means it follows system-default ~/.codex instead of a managed account. + protected lastSyncedAccountId: string | null = null + // Last auth.json Orca wrote to the runtime home; a later diff signals an out-of-band change (Codex token refresh, or external login to adopt). + protected lastWrittenAuthJson: string | null = null + // Why: a managed host account refreshes auth in its own home. Remember that provenance so a later deselect never adopts stale shared bytes. + protected lastHostAccountUsedSelfContainedHome = false + protected sharedAuthRefreshBlockedByManagedTransition = false + // Why: transient auth.json read/parse failures must not deselect an account. + protected readonly credentialAbsenceGrace = new CodexCredentialAbsenceGrace() + protected hostSystemDefaultSessionMigrationPending = false + protected pendingHostSystemDefaultSessionMigrationNeedsFullScan = false + protected pendingHostSystemDefaultSessionMigrationTarget: string | null = null + + protected constructor(protected readonly store: Store) {} + + protected abstract initializeLastSyncedState(): void + abstract prepareForCodexLaunch( + target?: CodexAccountSelectionTarget, + launchEnv?: NodeJS.ProcessEnv, + options?: { unavailableManagedHomePath?: string } + ): string | null + abstract prepareForCodexLaunchAsync( + target?: CodexAccountSelectionTarget, + launchEnv?: NodeJS.ProcessEnv, + options?: { unavailableManagedHomePath?: string } + ): Promise + abstract beginHostSystemDefaultSessionMigrationLaunch( + codexHomePath: string | null, + options?: { reattached?: boolean; launchEnv?: NodeJS.ProcessEnv } + ): boolean | null + abstract isHostSystemDefaultSessionMigrationEligible(): boolean + abstract prepareHostSystemDefaultSessionMigrationPass( + scanDates?: readonly CodexSessionBackfillDate[] + ): boolean + abstract finishHostSystemDefaultSessionMigrationPass(): void + + protected abstract getSelfContainedManagedHostAccount(): CodexManagedAccount | null + protected abstract getManagedAccountHomesForSessionDiscovery(): string[] + protected abstract getManagedHostAccountHomesForSessionDiscovery(): string[] + protected abstract prepareSelfContainedManagedHomeForLaunch( + account: CodexManagedAccount, + unavailableManagedHomePath?: string + ): string | null + protected abstract startSelfContainedSessionBridgeForLaunch(perAccountHome: string): void + protected abstract getSelfContainedSessionBridgeSourceHomes(): string[] + protected abstract syncSelfContainedManagedSelection(account: CodexManagedAccount): void + protected abstract resolveSelfContainedManagedHome( + account: CodexManagedAccount + ): CodexSelfContainedManagedHomeResolution + protected abstract getTrustedSelfContainedManagedHomePath( + account: CodexManagedAccount + ): string | null + protected abstract clearSelfContainedManagedSelection( + account: CodexManagedAccount, + reason?: string + ): void + protected abstract invalidateBackfillAfterManagedSystemDefaultLaunch( + launchEnv?: NodeJS.ProcessEnv + ): boolean | null + protected abstract startWslSessionBridgeForLaunch( + target: CodexAccountSelectionTarget, + runtimeHomePath: string | null + ): void + + abstract getHostCodexHomePathsForSessionDiscovery(): string[] + abstract getSelectedHostAccountCodexHomePath(): string | null + abstract resolveSelectedHostAccountCodexHomePathForResume(): string | null + abstract resolveCodexManagedAccountHomeForInactiveFetch( + account: CodexManagedAccount + ): { kind: 'ready'; homePath: string } | { kind: 'skip' } + abstract getSelectedHostCodexHomeRoute(): CodexPaneHomeRoute + abstract getRetainedHostCodexHookHomePaths(ptyIds: readonly string[]): string[] + abstract setRealHomeLaneGate(gate: () => boolean): void + abstract isHostSystemDefaultRealHomeSelected(launchEnv?: NodeJS.ProcessEnv): boolean + abstract isHostSystemDefaultRealHome(launchEnv?: NodeJS.ProcessEnv): boolean + abstract reconcileLegacySharedHomeForRetainedPanes(): void + abstract syncActiveWslSelectionsBeforeRestart(): Promise + + protected abstract getWslSystemCodexHomePath(target: CodexAccountSelectionTarget): string | null + protected abstract finishWslLaunchPreparation( + target: CodexAccountSelectionTarget, + homePath: string | null + ): void + protected abstract syncWslConfigAndGlobalInstructionsForLaunch( + target: CodexAccountSelectionTarget, + runtimeHomePath: string | null + ): void + abstract prepareForRateLimitFetch( + target?: CodexAccountSelectionTarget + ): CodexRateLimitHomeResolution + abstract syncForCurrentSelection( + target?: CodexAccountSelectionTarget, + launchEnv?: NodeJS.ProcessEnv + ): void + abstract clearLastWrittenAuthJson(accountId?: string | null): void + + protected abstract resolveSystemDefaultMirrorClaim( + runtimeAuth: string, + provenanceStatus: CodexSharedRuntimeAuthProvenanceStatus + ): { ownershipProven: boolean; mirroredAuthJson: string | null } + protected abstract safeSyncForCurrentSelection(): void + protected abstract safeRecoverInterruptedRuntimeAuthOperation(): void + protected abstract getActiveAccount( + accounts: CodexManagedAccount[], + activeAccountId: string | null + ): CodexManagedAccount | null + protected abstract getWslManagedHomePath(account: CodexManagedAccount | null): string | null + protected abstract getWslManagedHomeIdentity( + account: CodexManagedAccount | null + ): { distro: string; linuxHomePath: string } | null + protected abstract getPreparedWslRateLimitHomePath( + target: CodexAccountSelectionTarget + ): string | null + protected abstract getWslCodexHomePathForSelection( + target: CodexAccountSelectionTarget + ): string | null + protected abstract getWslLaunchCodexHomePath( + account: CodexManagedAccount, + targetDistro: string | undefined + ): string | null + protected abstract startLegacyWslAuthDrain( + target: CodexAccountSelectionTarget, + options?: { throwOnFailure?: boolean } + ): Promise + protected abstract resolveLegacyWslAuthDestination( + distro: string, + runtimeAuthContents: string + ): Promise + protected abstract joinWslPath(basePath: string, ...segments: string[]): string + protected abstract resolveWslDefaultTarget( + target: CodexAccountSelectionTarget + ): CodexAccountSelectionTarget + protected abstract findManagedAccountForRuntimeAuth( + runtimeAuthContents: string, + expectedAccountId?: string, + options?: { + accounts: readonly CodexManagedAccount[] + authReads: ReadonlyMap + } + ): CodexReadBackMatch + protected abstract runtimeAuthMatchesSystemDefaultIdentity( + runtimeAuthContents: string, + systemDefaultAuthContents: string + ): boolean + + protected abstract safeMigrateLegacySharedAuth(): void + protected abstract safeMigrateLegacyManagedState(): void + protected abstract safeMigrateLegacyActiveHomePointer(): void + protected abstract getRuntimeHomePath(): string + abstract getMirroredHostHomePathForStatus(): CodexMirroredHomeStatus + protected abstract getRuntimeAuthPath(): string + protected abstract getSystemDefaultSnapshotPath(): string + protected abstract getRuntimeLogoutMarkerPath(): string + protected abstract getSharedRuntimeAuthProvenancePath(): string + protected abstract getRuntimeMetadataDir(): string + protected abstract getLegacyHostActiveHomePath(): string + protected abstract getMigrationMarkerPath(): string + protected abstract getMigrationDiagnosticsPath(): string + protected abstract getManagedAccountsRoot(): string + protected abstract repointLegacyActiveHomePointer( + activeHomePath: string, + runtimeHomePath: string + ): void + protected abstract activeHomeAlreadyPointsToRuntimeHome( + activeHomePath: string, + runtimeHomePath: string + ): boolean + protected abstract linkTargetsMatch( + linkTarget: string, + linkPath: string, + expectedTargetPath: string + ): boolean + protected abstract legacyActiveHomeLinkIsReplaceable(activeHomePath: string): boolean + protected abstract legacyActiveHomePathExists(activeHomePath: string): boolean + protected abstract removeLegacyActiveHomeLinkIfOwned(activeHomePath: string): void + protected abstract isWindowsReadableLink(targetPath: string): boolean + protected abstract migrateLegacyManagedStateIfNeeded(): void + protected abstract getLegacyManagedHomes(): string[] + protected abstract migrateLegacyHistory(managedHomePath: string): void + protected abstract migrateLegacySessions(managedHomePath: string, accountId: string): void + protected abstract listFilesRecursively(rootPath: string): string[] + protected abstract appendListedFiles(target: string[], source: readonly string[]): void + protected abstract getPreservedLegacySessionPath( + runtimeFilePath: string, + accountId: string + ): string + protected abstract appendMigrationDiagnostic(record: Record): void + + protected abstract captureSystemDefaultSnapshot(options: { force: boolean }): void + protected abstract syncRuntimeAuthWithSystemDefault(): void + protected abstract syncLegacySharedSystemDefaultAuthForRetainedPanes(): void + protected abstract restoreSystemDefaultSnapshot(options: { detectExternalLogin: boolean }): void + protected abstract writeSystemDefaultAuth(contents: string): void + protected abstract clearRuntimeAuthAfterSystemDefaultLogout(runtimeAuthPath: string): void + protected abstract readSystemDefaultAuth(): string | null + protected abstract writeRuntimeAuth( + contents: string, + owner: { owner: 'system-default' } | { owner: 'managed'; accountId: string }, + options?: { expectedContents: string | null } + ): boolean + protected abstract compareFileContents(targetPath: string, contents: string): boolean | null + protected abstract fileContentsEqual(targetPath: string, contents: string): boolean + protected abstract fileContentsMatchExpected( + targetPath: string, + expectedContents: string | null + ): boolean + protected abstract ensureOwnerOnlyMode(targetPath: string): void + protected abstract getRuntimeLogoutMarkerStatus(): CodexRuntimeLogoutMarkerStatus + protected abstract persistRuntimeLogoutMarker(systemDefaultAuthJson?: string | null): void + protected abstract readRuntimeLogoutMarker(): CodexRuntimeLogoutMarker | null + protected abstract clearRuntimeLogoutMarker(): void + protected abstract persistSharedRuntimeAuthProvenance( + provenance: CodexSharedRuntimeAuthProvenanceFile + ): void + protected abstract markSharedRuntimeAuthManaged(accountId: string): void + protected abstract getUntouchedSystemDefaultBaseline( + status: CodexSharedRuntimeAuthProvenanceStatus, + runtimeAuthJson: string | null + ): { authJson: string | null } | null + protected abstract restoreUntouchedSystemDefaultProvenance( + provenance: Extract + ): Extract | null + protected abstract sharedRuntimeAuthProvenanceMatches( + status: CodexSharedRuntimeAuthProvenanceStatus, + expected: CodexSharedRuntimeAuthProvenance + ): boolean + protected abstract resolveSharedRuntimeAuthProvenanceStatus(): CodexSharedRuntimeAuthProvenanceStatus + protected abstract parseSharedRuntimeAuthProvenance( + value: unknown + ): CodexSharedRuntimeAuthProvenance | null + protected abstract parseSystemDefaultBaseline(value: unknown): { authJson: string | null } | null + protected abstract parsePendingSharedRuntimeAuthProvenance( + value: unknown + ): CodexSharedRuntimeAuthPendingProvenance | null + protected abstract readRuntimeAuthForProvenance(): string | null + protected abstract readSystemDefaultSnapshot( + snapshotPath: string + ): CodexSystemDefaultSnapshot | null + abstract clearSystemDefaultSnapshot(): void +} diff --git a/src/main/codex-accounts/runtime-home-service-sync.ts b/src/main/codex-accounts/runtime-home-service-sync.ts new file mode 100644 index 00000000000..184faeab241 --- /dev/null +++ b/src/main/codex-accounts/runtime-home-service-sync.ts @@ -0,0 +1,154 @@ +import { existsSync } from 'node:fs' +import { + normalizeCodexRuntimeSelection, + type CodexAccountSelectionTarget +} from './runtime-selection' +import { recoverInterruptedGuardedFileOperation } from './fs-utils' +import type { CodexSharedRuntimeAuthProvenanceStatus } from './runtime-home-service-types' +import { codexAuthIsMonotonicallyFresher } from './runtime-home-service-auth-sync-identity' +import { CodexRuntimeHomeWsl } from './runtime-home-service-wsl' + +export abstract class CodexRuntimeHomeSync extends CodexRuntimeHomeWsl { + syncForCurrentSelection( + target?: CodexAccountSelectionTarget, + launchEnv?: NodeJS.ProcessEnv + ): void { + if (target?.runtime === 'wsl') { + this.startLegacyWslAuthDrain(this.resolveWslDefaultTarget(target)) + return + } + + const selfContainedAccount = this.getSelfContainedManagedHostAccount() + if (selfContainedAccount) { + // Why: self-contained managed homes hold their own auth, so the shared + // runtime home's snapshot/hot-swap/read-back machinery below must not run. + this.syncSelfContainedManagedSelection(selfContainedAccount) + return + } + const settings = this.store.getSettings() + if (this.lastHostAccountUsedSelfContainedHome) { + // Why: the account's auth is already canonical in its own home. Reset the + // legacy mirror baseline without reading it; a real-home deselect needs no + // further sync, and the mirror lane below re-seeds from canonical storage. + this.lastHostAccountUsedSelfContainedHome = false + this.lastSyncedAccountId = null + this.lastWrittenAuthJson = null + if (this.isHostSystemDefaultRealHome(launchEnv)) { + return + } + } + if (this.isHostSystemDefaultRealHome(launchEnv)) { + // Why: retained daemon panes may own shared auth from a managed launch; + // compatibility reconciliation runs later with durable provenance. + if (this.lastSyncedAccountId !== null) { + this.sharedAuthRefreshBlockedByManagedTransition = true + this.lastSyncedAccountId = null + this.lastWrittenAuthJson = null + } + return + } + const runtimeAuthExistedBeforeSync = existsSync(this.getRuntimeAuthPath()) + if (this.lastSyncedAccountId === null) { + this.captureSystemDefaultSnapshot({ force: false }) + } + const activeAccount = this.getActiveAccount( + settings.codexManagedAccounts, + normalizeCodexRuntimeSelection(settings).host + ) + if (activeAccount) { + // Why: only a WSL-managed account can reach here — every host account was + // routed to its own self-contained home above. Its auth lives in the + // distro-local runtime home, so the host mirror only drops its baseline. + this.lastSyncedAccountId = null + this.lastWrittenAuthJson = null + return + } + if (normalizeCodexRuntimeSelection(settings).host) { + this.store.updateSettings({ + activeCodexManagedAccountId: null, + activeCodexManagedAccountIdsByRuntime: { + ...normalizeCodexRuntimeSelection(settings), + host: null + } + }) + } + // Why: only restore the system-default mirror when leaving a managed account; otherwise later syncs mirror current ~/.codex instead of replaying an old snapshot. + if (this.lastSyncedAccountId !== null) { + this.restoreSystemDefaultSnapshot({ detectExternalLogin: true }) + this.lastSyncedAccountId = null + } else if (!runtimeAuthExistedBeforeSync) { + const logoutMarkerStatus = this.getRuntimeLogoutMarkerStatus() + if (logoutMarkerStatus.kind === 'applies') { + this.lastWrittenAuthJson = null + } else if ( + logoutMarkerStatus.kind === 'system-default-changed' && + logoutMarkerStatus.systemDefaultAuthJson !== null + ) { + this.restoreSystemDefaultSnapshot({ detectExternalLogin: false }) + } else if (logoutMarkerStatus.kind === 'system-default-changed') { + // Why: a real ~/.codex logout after a local runtime logout should keep runtime auth absent, not restore the stale snapshot. + this.captureSystemDefaultSnapshot({ force: true }) + this.persistRuntimeLogoutMarker(null) + this.lastWrittenAuthJson = null + } else if (this.lastWrittenAuthJson === null) { + // Why: unmanaged sessions use an Orca-owned CODEX_HOME; seed it once from system-default auth so terminals stay logged in without mutating ~/.codex. + this.restoreSystemDefaultSnapshot({ detectExternalLogin: false }) + } else { + this.persistRuntimeLogoutMarker() + } + } else { + this.clearRuntimeLogoutMarker() + this.syncRuntimeAuthWithSystemDefault() + } + } + + // Why: re-auth/add-account writes fresh host tokens, invalidating the shared mirror baseline. + clearLastWrittenAuthJson( + accountId = normalizeCodexRuntimeSelection(this.store.getSettings()).host + ): void { + if (accountId === normalizeCodexRuntimeSelection(this.store.getSettings()).host) { + this.lastWrittenAuthJson = null + } + } + + // Why: which ~/.codex bytes the mirror was seeded from, and whether the system + // default can be proven to own the mirror at all. + protected resolveSystemDefaultMirrorClaim( + runtimeAuth: string, + provenanceStatus: CodexSharedRuntimeAuthProvenanceStatus + ): { ownershipProven: boolean; mirroredAuthJson: string | null } { + const provenance = provenanceStatus.kind === 'committed' ? provenanceStatus.provenance : null + const snapshotAuth = + this.readSystemDefaultSnapshot(this.getSystemDefaultSnapshotPath())?.authJson ?? null + const preProvenanceRuntimeRefreshProven = + provenanceStatus.kind === 'missing' && + snapshotAuth !== null && + this.runtimeAuthMatchesSystemDefaultIdentity(runtimeAuth, snapshotAuth) && + codexAuthIsMonotonicallyFresher(runtimeAuth, snapshotAuth) + return { + ownershipProven: provenance?.owner === 'system-default' || preProvenanceRuntimeRefreshProven, + mirroredAuthJson: + provenance?.owner === 'system-default' + ? provenance.authJson + : provenanceStatus.kind === 'missing' + ? (this.lastWrittenAuthJson ?? snapshotAuth) + : null + } + } + + protected safeSyncForCurrentSelection(): void { + try { + this.syncForCurrentSelection() + } catch (error) { + console.warn('[codex-runtime-home] Failed to sync runtime auth state:', error) + } + } + + protected safeRecoverInterruptedRuntimeAuthOperation(): void { + try { + recoverInterruptedGuardedFileOperation(this.getRuntimeAuthPath()) + } catch (error) { + console.warn('[codex-runtime-home] Failed to recover interrupted auth update:', error) + } + } +} diff --git a/src/main/codex-accounts/runtime-home-service-types.ts b/src/main/codex-accounts/runtime-home-service-types.ts new file mode 100644 index 00000000000..1cf9c773fd2 --- /dev/null +++ b/src/main/codex-accounts/runtime-home-service-types.ts @@ -0,0 +1,62 @@ +import type { CodexManagedAccount } from '../../shared/managed-account-types' + +export type CodexSystemDefaultSnapshot = { + authJson: string | null +} + +export type CodexRuntimeLogoutMarker = { + systemDefaultAuthJson: string | null + loggedOutAt: number +} + +export type CodexSharedRuntimeAuthProvenance = + | { owner: 'system-default'; authJson: string | null } + | { + owner: 'managed' + accountId: string + systemDefaultBaseline?: { authJson: string | null } + } + +export type CodexSharedRuntimeAuthPendingProvenance = { + owner: 'pending' + next: CodexSharedRuntimeAuthProvenance + runtimeAuthJson: string | null +} + +export type CodexSharedRuntimeAuthProvenanceFile = + | CodexSharedRuntimeAuthProvenance + | CodexSharedRuntimeAuthPendingProvenance + | { owner: 'fenced' } + +export type CodexSharedRuntimeAuthProvenanceStatus = + | { kind: 'missing' | 'fenced' } + | { kind: 'committed'; provenance: CodexSharedRuntimeAuthProvenance } + +export type CodexRuntimeLogoutMarkerStatus = + | { kind: 'missing' } + | { kind: 'applies' } + | { kind: 'system-default-changed'; systemDefaultAuthJson: string | null } + +export type CodexReadBackMatch = + | { + kind: 'matched' + account: CodexManagedAccount + managedAuthPath: string + managedAuthContents: string + } + | { kind: 'none' | 'ambiguous' } + +export type CodexSelfContainedManagedHomeResolution = + | { kind: 'owned'; homePath: string } + | { kind: 'untrusted' } + | { kind: 'indeterminate' } + +/** Status used by the config-sync surface; `unavailable` is not a healthy null lane. */ +export type CodexMirroredHomeStatus = + | { kind: 'ready'; homePath: string | null } + | { kind: 'unavailable' } + +/** Result used by quota polling, where `skip` means no process should be spawned. */ +export type CodexRateLimitHomeResolution = + | { kind: 'ready'; codexHomePath: string | null } + | { kind: 'skip' } diff --git a/src/main/codex-accounts/runtime-home-service-wsl-core.ts b/src/main/codex-accounts/runtime-home-service-wsl-core.ts new file mode 100644 index 00000000000..4ace9d8dd33 --- /dev/null +++ b/src/main/codex-accounts/runtime-home-service-wsl-core.ts @@ -0,0 +1,119 @@ +import { existsSync, readFileSync } from 'node:fs' +import { join } from 'node:path' +import { + codexAuthCouldBelongToManagedAccount, + codexAuthMatchesManagedAccount, + codexAuthMatchesSystemDefaultIdentity +} from './codex-auth-identity' +import { parseWslUncPath } from '../../shared/wsl-paths' +import type { CodexManagedAccount } from '../../shared/managed-account-types' +import type { CodexReadBackMatch } from './runtime-home-service-types' +import type { WslCodexAuthRead } from './wsl-codex-auth-batch-reader' +import { CodexRuntimeHomeAuthProvenance } from './runtime-home-service-auth-provenance' + +export abstract class CodexRuntimeHomeWslCore extends CodexRuntimeHomeAuthProvenance { + protected getActiveAccount( + accounts: CodexManagedAccount[], + activeAccountId: string | null + ): CodexManagedAccount | null { + if (!activeAccountId) { + return null + } + return accounts.find((account) => account.id === activeAccountId) ?? null + } + + protected getWslManagedHomePath(account: CodexManagedAccount | null): string | null { + return this.getWslManagedHomeIdentity(account) ? (account?.managedHomePath ?? null) : null + } + + protected getWslManagedHomeIdentity( + account: CodexManagedAccount | null + ): { distro: string; linuxHomePath: string } | null { + if (!account) { + return null + } + const distro = account.wslDistro?.trim() + const linuxHomePath = account.wslLinuxHomePath?.trim() + if (account.managedHomeRuntime === 'wsl' && distro && linuxHomePath?.startsWith('/')) { + return { distro, linuxHomePath } + } + const legacyHome = parseWslUncPath(account.managedHomePath) + return legacyHome ? { distro: legacyHome.distro, linuxHomePath: legacyHome.linuxPath } : null + } + + protected findManagedAccountForRuntimeAuth( + runtimeAuthContents: string, + expectedAccountId?: string, + options?: { + accounts: readonly CodexManagedAccount[] + authReads: ReadonlyMap + } + ): CodexReadBackMatch { + const matches: { + account: CodexManagedAccount + managedAuthPath: string + managedAuthContents: string + }[] = [] + let unreadableHomeCouldOwnRuntimeAuth = false + for (const account of options?.accounts ?? this.store.getSettings().codexManagedAccounts) { + if (expectedAccountId && account.id !== expectedAccountId) { + continue + } + const managedAuthPath = join(account.managedHomePath, 'auth.json') + let managedAuthContents: string + const suppliedRead = options?.authReads.get(account.id) + if (suppliedRead?.kind === 'missing') { + continue + } + if (suppliedRead?.kind === 'unreadable') { + // Why: an unreadable home can never be compared, but letting the read + // throw abandons the scan for every other account — dropping a refresh + // the runtime home holds for one of them. Only its record can rule it + // out as the owner; when it cannot, the scan is no longer unambiguous. + if ( + !expectedAccountId && + codexAuthCouldBelongToManagedAccount(runtimeAuthContents, account) + ) { + unreadableHomeCouldOwnRuntimeAuth = true + } + continue + } + if (suppliedRead?.kind === 'present') { + managedAuthContents = suppliedRead.contents + } else { + if (!existsSync(managedAuthPath)) { + continue + } + try { + managedAuthContents = readFileSync(managedAuthPath, 'utf-8') + } catch { + if ( + !expectedAccountId && + codexAuthCouldBelongToManagedAccount(runtimeAuthContents, account) + ) { + unreadableHomeCouldOwnRuntimeAuth = true + } + continue + } + } + if (codexAuthMatchesManagedAccount(runtimeAuthContents, account, managedAuthContents)) { + matches.push({ account, managedAuthPath, managedAuthContents }) + } + } + + if (unreadableHomeCouldOwnRuntimeAuth) { + return { kind: 'ambiguous' } + } + if (matches.length === 1) { + return { kind: 'matched', ...matches[0] } + } + return { kind: matches.length === 0 ? 'none' : 'ambiguous' } + } + + protected runtimeAuthMatchesSystemDefaultIdentity( + runtimeAuthContents: string, + systemDefaultAuthContents: string + ): boolean { + return codexAuthMatchesSystemDefaultIdentity(runtimeAuthContents, systemDefaultAuthContents) + } +} diff --git a/src/main/codex-accounts/runtime-home-service-wsl.ts b/src/main/codex-accounts/runtime-home-service-wsl.ts new file mode 100644 index 00000000000..cf0ec32a23f --- /dev/null +++ b/src/main/codex-accounts/runtime-home-service-wsl.ts @@ -0,0 +1,166 @@ +import { join, win32 as pathWin32 } from 'node:path' +import { parseWslUncPath, toLinuxPath, toWindowsWslUncPath } from '../../shared/wsl-paths' +import { + getCodexSelectionLaneKey, + getSelectedCodexAccountIdForTarget, + type CodexAccountSelectionTarget +} from './runtime-selection' +import { getDefaultWslDistro, getWslHome } from '../wsl' +import { hasRecordedLegacyWslCodexPane } from '../codex/codex-pane-account-registry' +import { + startLegacyWslRuntimeAuthDrain, + type LegacyWslRuntimeAuthDestination +} from './legacy-wsl-runtime-auth-drain' +import { readWslCodexAuths, type WslCodexAuthRead } from './wsl-codex-auth-batch-reader' +import type { CodexManagedAccount } from '../../shared/managed-account-types' +import { CodexRuntimeHomeWslCore } from './runtime-home-service-wsl-core' + +export abstract class CodexRuntimeHomeWsl extends CodexRuntimeHomeWslCore { + protected getPreparedWslRateLimitHomePath(target: CodexAccountSelectionTarget): string | null { + return this.getWslCodexHomePathForSelection(target) + } + + protected getWslCodexHomePathForSelection(target: CodexAccountSelectionTarget): string | null { + const settings = this.store.getSettings() + const account = this.getActiveAccount( + settings.codexManagedAccounts, + getSelectedCodexAccountIdForTarget(settings, target) + ) + if (account) { + const targetDistro = this.resolveWslDefaultTarget(target).wslDistro?.trim() + const accountHome = this.getWslLaunchCodexHomePath(account, targetDistro) + if (accountHome) { + return accountHome + } + } + return this.getWslSystemCodexHomePath(target) + } + + protected getWslLaunchCodexHomePath( + account: CodexManagedAccount, + targetDistro: string | undefined + ): string | null { + const wslHome = this.getWslManagedHomeIdentity(account) + if (!wslHome) { + return null + } + const accountDistro = wslHome.distro + if (targetDistro && accountDistro.toLowerCase() !== targetDistro.toLowerCase()) { + return null + } + if (/^[A-Za-z]:[\\/]/.test(account.managedHomePath)) { + return toWindowsWslUncPath(wslHome.linuxHomePath, accountDistro) + } + return account.managedHomePath || toWindowsWslUncPath(wslHome.linuxHomePath, accountDistro) + } + + protected startLegacyWslAuthDrain( + target: CodexAccountSelectionTarget, + options: { throwOnFailure?: boolean } = {} + ): Promise { + if (process.platform !== 'win32') { + return Promise.resolve() + } + const distro = target.wslDistro?.trim() || getDefaultWslDistro() + if (!distro) { + return Promise.resolve() + } + const guestHome = getWslHome(distro) + const guestHomeLinuxPath = guestHome ? toLinuxPath(guestHome).trim() : '' + if (!guestHomeLinuxPath.startsWith('/')) { + return Promise.resolve() + } + let legacyPanePresent = true + try { + legacyPanePresent = hasRecordedLegacyWslCodexPane(getCodexSelectionLaneKey(target)) + } catch (error) { + // Why: unknown pane liveness must preserve the source, but promotion can + // still keep the direct home from launching stale auth. + console.warn('[codex-wsl-auth-drain] Pane registry unavailable; preserving source:', error) + } + return startLegacyWslRuntimeAuthDrain( + { + distro, + guestHomeLinuxPath, + legacyPanePresent, + resolveDestination: (runtimeAuthContents) => + this.resolveLegacyWslAuthDestination(distro, runtimeAuthContents) + }, + options + ) + } + + protected async resolveLegacyWslAuthDestination( + distro: string, + runtimeAuthContents: string + ): Promise { + const accountHomes = this.store.getSettings().codexManagedAccounts.flatMap((account) => { + const wslHome = this.getWslManagedHomeIdentity(account) + return wslHome?.distro.toLowerCase() === distro.toLowerCase() + ? [{ account, linuxPath: wslHome.linuxHomePath }] + : [] + }) + const accounts = accountHomes.map(({ account }) => account) + const systemHome = this.getWslSystemCodexHomePath({ runtime: 'wsl', wslDistro: distro }) + const parsedSystemHome = systemHome ? parseWslUncPath(systemHome) : null + let reads: WslCodexAuthRead[] + try { + reads = await readWslCodexAuths(distro, [ + ...accountHomes.map(({ linuxPath }) => linuxPath), + ...(parsedSystemHome ? [parsedSystemHome.linuxPath] : []) + ]) + } catch { + reads = accountHomes.map(() => ({ kind: 'unreadable' })) + if (parsedSystemHome) { + reads.push({ kind: 'unreadable' }) + } + } + const authReads = new Map( + accountHomes.map(({ account }, index) => [account.id, reads[index] ?? { kind: 'unreadable' }]) + ) + const match = this.findManagedAccountForRuntimeAuth(runtimeAuthContents, undefined, { + accounts, + authReads + }) + if (match.kind === 'ambiguous') { + return null + } + if (match.kind === 'matched') { + const accountHome = accountHomes.find(({ account }) => account.id === match.account.id) + if (!accountHome) { + return null + } + return { + authContents: match.managedAuthContents, + linuxHomePath: accountHome.linuxPath + } + } + + if (!systemHome || !parsedSystemHome) { + return null + } + const systemAuth = reads[accountHomes.length] ?? { kind: 'unreadable' } + if (systemAuth.kind !== 'present') { + return null + } + return this.runtimeAuthMatchesSystemDefaultIdentity(runtimeAuthContents, systemAuth.contents) + ? { authContents: systemAuth.contents, linuxHomePath: parsedSystemHome.linuxPath } + : null + } + + protected joinWslPath(basePath: string, ...segments: string[]): string { + return parseWslUncPath(basePath) + ? pathWin32.join(basePath, ...segments) + : join(basePath, ...segments) + } + + protected resolveWslDefaultTarget( + target: CodexAccountSelectionTarget + ): CodexAccountSelectionTarget { + if (target.runtime !== 'wsl' || target.wslDistro?.trim()) { + return target + } + const defaultDistro = getDefaultWslDistro() + return defaultDistro ? { runtime: 'wsl', wslDistro: defaultDistro } : target + } +} diff --git a/src/main/codex-accounts/runtime-home-service.ts b/src/main/codex-accounts/runtime-home-service.ts index 2d246bf480d..4f08fb0a64c 100644 --- a/src/main/codex-accounts/runtime-home-service.ts +++ b/src/main/codex-accounts/runtime-home-service.ts @@ -1,210 +1,14 @@ -/* eslint-disable max-lines -- Why: keeps Codex's whole runtime-home contract in one place so account-switch semantics don't drift across launch/login/quota paths. */ -import { - appendFileSync, - copyFileSync, - existsSync, - chmodSync, - lstatSync, - mkdirSync, - readlinkSync, - readdirSync, - readFileSync, - renameSync, - rmdirSync, - rmSync, - statSync, - symlinkSync, - unlinkSync -} from 'node:fs' -import { isDefinitiveAbsence } from '../../shared/definitive-filesystem-absence' -import { - dirname, - extname, - isAbsolute, - join, - parse, - posix as pathPosix, - relative, - resolve, - win32 as pathWin32 -} from 'node:path' -import { app } from 'electron' -import type { CodexManagedAccount } from '../../shared/managed-account-types' -import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path' import type { Store } from '../persistence' -import { - recoverInterruptedGuardedFileOperation, - removeFileAtomicallyIfUnchanged, - writeFileAtomically, - writeFileAtomicallyIfUnchanged -} from './fs-utils' -import { - getOrcaManagedCodexHomePath, - getOrcaUserDataPath, - getSystemCodexHomePath, - resolveOrcaManagedCodexHomePath, - syncCodexGlobalInstructionsIntoManagedHome, - syncSystemCodexResourcesIntoManagedHome -} from '../codex/codex-home-paths' -import { startCodexAccountSessionBridgeInBackground } from '../codex/codex-account-session-bridge' -import { startSystemCodexSessionBridgeInBackground } from '../codex/codex-session-bridge' -import { - resolveHostCodexSessionSourceHome, - resolveWslCodexSessionSourceHome -} from '../codex/codex-session-source-home' -import { startWslCodexSessionBridgeInBackground } from '../codex/wsl-codex-session-bridge' -import { syncSystemConfigIntoManagedCodexHome } from '../codex/codex-config-mirror' -import { parseWslUncPath, toLinuxPath, toWindowsWslUncPath } from '../../shared/wsl-paths' -import { - getCodexSelectionLaneKey, - getWslSelectionKey, - getSelectedCodexAccountIdForTarget, - normalizeCodexRuntimeSelection, - type CodexAccountSelectionTarget -} from './runtime-selection' -import { getDefaultWslDistro, getWslHome } from '../wsl' -import { hasCustomCodexHomeOverrideForLaunch } from '../codex/codex-real-home-path' -import { - hasCompletedCodexSessionBackfillMarker, - markCodexSessionBackfillMarkerPending -} from '../codex/codex-session-backfill-marker' -import { getCodexSessionBackfillDate } from '../codex/codex-session-backfill-scan-dates' -import type { CodexSessionBackfillDate } from '../codex/codex-session-backfill-types' -import { resolveCodexSessionBackfillPaths } from '../codex/codex-session-backfill' -import { - ManagedCodexHomeTemporarilyUnavailableError, - resolveHostCodexManagedHomeVerdict -} from './host-codex-managed-home-ownership' -import { - codexAuthCouldBelongToManagedAccount, - codexAuthIsFresher, - codexAuthMatchesManagedAccount, - codexAuthMatchesSystemDefaultIdentity -} from './codex-auth-identity' -import { migrateLegacySharedAuthToPerAccountHome } from './legacy-shared-auth-migration' -import { CodexCredentialAbsenceGrace } from './codex-credential-absence-grace' -import { syncLegacySharedCodexConfigForRetainedPanes } from './legacy-shared-config-compatibility' -import { - getCodexPaneAccount, - hasRecordedLegacyWslCodexPane, - hasRecordedLegacySharedCodexPane, - type CodexPaneHomeRoute -} from '../codex/codex-pane-account-registry' -import { isShellStartupEnvProbeSupported } from '../pty/shell-startup-env' -import { - startLegacyWslRuntimeAuthDrain, - type LegacyWslRuntimeAuthDestination -} from './legacy-wsl-runtime-auth-drain' -import { readWslCodexAuths, type WslCodexAuthRead } from './wsl-codex-auth-batch-reader' +import { CodexRuntimeHomeAuthSync } from './runtime-home-service-auth-sync' -type CodexSystemDefaultSnapshot = { - authJson: string | null -} +export type { + CodexMirroredHomeStatus, + CodexRateLimitHomeResolution +} from './runtime-home-service-types' -type CodexRuntimeLogoutMarker = { - systemDefaultAuthJson: string | null - loggedOutAt: number -} - -type CodexSharedRuntimeAuthProvenance = - | { owner: 'system-default'; authJson: string | null } - | { - owner: 'managed' - accountId: string - systemDefaultBaseline?: { authJson: string | null } - } -type CodexSharedRuntimeAuthPendingProvenance = { - owner: 'pending' - next: CodexSharedRuntimeAuthProvenance - runtimeAuthJson: string | null -} -type CodexSharedRuntimeAuthProvenanceFile = - | CodexSharedRuntimeAuthProvenance - | CodexSharedRuntimeAuthPendingProvenance - | { owner: 'fenced' } -type CodexSharedRuntimeAuthProvenanceStatus = - | { kind: 'missing' | 'fenced' } - | { kind: 'committed'; provenance: CodexSharedRuntimeAuthProvenance } - -type CodexRuntimeLogoutMarkerStatus = - | { kind: 'missing' } - | { kind: 'applies' } - | { kind: 'system-default-changed'; systemDefaultAuthJson: string | null } - -type CodexReadBackMatch = - | { - kind: 'matched' - account: CodexManagedAccount - managedAuthPath: string - managedAuthContents: string - } - | { kind: 'none' | 'ambiguous' } - -function readCodexLastRefresh(authJson: string): number | null { - try { - const parsed = JSON.parse(authJson) as unknown - if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { - return null - } - const value = (parsed as Record).last_refresh - if (typeof value === 'number') { - return Number.isFinite(value) ? value : null - } - if (typeof value !== 'string' || !value.trim()) { - return null - } - const timestamp = Date.parse(value) - return Number.isFinite(timestamp) ? timestamp : null - } catch { - return null - } -} - -function codexAuthIsMonotonicallyFresher( - candidateAuthJson: string, - baselineAuthJson: string -): boolean { - const candidateLastRefresh = readCodexLastRefresh(candidateAuthJson) - const baselineLastRefresh = readCodexLastRefresh(baselineAuthJson) - if (candidateLastRefresh !== null || baselineLastRefresh !== null) { - return ( - candidateLastRefresh !== null && - baselineLastRefresh !== null && - candidateLastRefresh > baselineLastRefresh - ) - } - return codexAuthIsFresher(candidateAuthJson, baselineAuthJson) -} - -/** - * Why: a skipped Codex quota poll must be distinguishable from "use the - * system-default home". `null` inside `ready` still means the system lane; - * `skip` means do not fetch at all this cycle (#STA-4422). - */ -/** Mirror path for the config-sync status channel; `unavailable` is not `null`. */ -export type CodexMirroredHomeStatus = - | { kind: 'ready'; homePath: string | null } - | { kind: 'unavailable' } - -export type CodexRateLimitHomeResolution = - | { kind: 'ready'; codexHomePath: string | null } - | { kind: 'skip' } - -export class CodexRuntimeHomeService { - // Which managed account runtime auth.json mirrors; null means it follows system-default ~/.codex instead of a managed account. - private lastSyncedAccountId: string | null = null - // Last auth.json Orca wrote to the runtime home; a later diff signals an out-of-band change (Codex token refresh, or external login to adopt). - private lastWrittenAuthJson: string | null = null - // Why: a managed host account refreshes auth in its own home. Remember that - // provenance so a later deselect never adopts stale shared bytes. - private lastHostAccountUsedSelfContainedHome = false - private sharedAuthRefreshBlockedByManagedTransition = false - // Why: transient auth.json read/parse failures must not deselect an account. - private readonly credentialAbsenceGrace = new CodexCredentialAbsenceGrace() - private hostSystemDefaultSessionMigrationPending = false - private pendingHostSystemDefaultSessionMigrationNeedsFullScan = false - private pendingHostSystemDefaultSessionMigrationTarget: string | null = null - constructor(private readonly store: Store) { +export class CodexRuntimeHomeService extends CodexRuntimeHomeAuthSync { + constructor(store: Store) { + super(store) this.safeRecoverInterruptedRuntimeAuthOperation() this.safeMigrateLegacySharedAuth() this.safeMigrateLegacyManagedState() @@ -212,2066 +16,4 @@ export class CodexRuntimeHomeService { this.initializeLastSyncedState() this.safeSyncForCurrentSelection() } - - private initializeLastSyncedState(): void { - const settings = this.store.getSettings() - const activeAccount = this.getActiveAccount( - settings.codexManagedAccounts, - normalizeCodexRuntimeSelection(settings).host - ) - // Why: WSL-managed homes never touch host ~/.codex; treating one as "last synced" makes cold start mangle host auth Orca never touched. - this.lastSyncedAccountId = this.getWslManagedHomePath(activeAccount) - ? null - : normalizeCodexRuntimeSelection(settings).host - } - - /** - * Materializes the runtime home needed before launching the CLI. - * - * Historical session bridging is requested in the background so launch setup - * returns as soon as the active runtime home is ready. - */ - prepareForCodexLaunch( - target?: CodexAccountSelectionTarget, - launchEnv?: NodeJS.ProcessEnv, - options?: { unavailableManagedHomePath?: string } - ): string | null { - if (target?.runtime === 'wsl') { - const wslTarget = this.resolveWslDefaultTarget(target) - const homePath = this.getWslCodexHomePathForSelection(wslTarget) - this.startLegacyWslAuthDrain(wslTarget) - this.finishWslLaunchPreparation(wslTarget, homePath) - return homePath - } - const selfContainedAccount = this.getSelfContainedManagedHostAccount() - if (selfContainedAccount) { - const perAccountHome = this.prepareSelfContainedManagedHomeForLaunch( - selfContainedAccount, - options?.unavailableManagedHomePath - ) - if (perAccountHome) { - return perAccountHome - } - // Why: only an untrusted home clears the selection; fall through to the - // system default without injecting a path Orca cannot prove it owns. - } - if (this.isHostSystemDefaultRealHome(launchEnv)) { - // Why: the system default runs Codex on the user's own ~/.codex. - // Returning null tells the PTY/env layer to inject no managed CODEX_HOME; - // the retired mirror is refreshed only for pre-rollout PTYs. - this.reconcileLegacySharedHomeForRetainedPanes() - return null - } - this.invalidateBackfillAfterManagedSystemDefaultLaunch(launchEnv) - this.syncForCurrentSelection(target, launchEnv) - syncSystemCodexResourcesIntoManagedHome() - syncSystemConfigIntoManagedCodexHome() - // Why: sessions can be large; bridge them after launch so starting a fresh TUI never waits on a full tree walk. - void startSystemCodexSessionBridgeInBackground( - {}, - resolveHostCodexSessionSourceHome(this.store.getSettings()) - ) - return this.getRuntimeHomePath() - } - - async prepareForCodexLaunchAsync( - target?: CodexAccountSelectionTarget, - launchEnv?: NodeJS.ProcessEnv, - options?: { unavailableManagedHomePath?: string } - ): Promise { - if (target?.runtime !== 'wsl') { - return this.prepareForCodexLaunch(target, launchEnv, options) - } - const wslTarget = this.resolveWslDefaultTarget(target) - const homePath = this.getWslCodexHomePathForSelection(wslTarget) - // Why: the retired home may hold the freshest credential, so the first - // direct-home Codex spawn must wait for its bounded guest transaction. - await this.startLegacyWslAuthDrain(wslTarget, { throwOnFailure: true }) - this.finishWslLaunchPreparation(wslTarget, homePath) - return homePath - } - - beginHostSystemDefaultSessionMigrationLaunch( - codexHomePath: string | null, - options: { reattached?: boolean; launchEnv?: NodeJS.ProcessEnv } = {} - ): boolean | null { - if ( - !this.isHostSystemDefaultSessionMigrationEligible() || - (!codexHomePath && !options.reattached) || - (codexHomePath && - normalizeRuntimePathForComparison(codexHomePath) !== - normalizeRuntimePathForComparison(this.getRuntimeHomePath())) - ) { - return null - } - // Why: an older pass can clear launch preparation while PTY spawn awaits recovery. - return this.invalidateBackfillAfterManagedSystemDefaultLaunch( - options.reattached && !codexHomePath ? undefined : options.launchEnv - ) - } - - isHostSystemDefaultSessionMigrationEligible(): boolean { - return ( - normalizeCodexRuntimeSelection(this.store.getSettings()).host === null && - !hasCustomCodexHomeOverrideForLaunch() - ) - } - - prepareHostSystemDefaultSessionMigrationPass( - scanDates: readonly CodexSessionBackfillDate[] = [] - ): boolean { - const paths = resolveCodexSessionBackfillPaths( - resolveHostCodexSessionSourceHome(this.store.getSettings()) - ) - const target = normalizeRuntimePathForComparison(paths.systemSessionsRoot) - if ( - this.hostSystemDefaultSessionMigrationPending && - this.pendingHostSystemDefaultSessionMigrationTarget !== target - ) { - this.pendingHostSystemDefaultSessionMigrationNeedsFullScan = true - this.pendingHostSystemDefaultSessionMigrationTarget = target - } - // Why: the launch creates rollouts for these dates; record them durably so a - // force-quit recovers a bounded window instead of re-walking all history. - const markerOwesFullScan = markCodexSessionBackfillMarkerPending( - paths.markerPath, - paths.systemSessionsRoot, - scanDates.length > 0 ? scanDates : [getCodexSessionBackfillDate()] - ) - // Why: the marker is the only place an overflowed pending window survives a - // restart, so its demand has to reach this pass rather than die in the file. - this.pendingHostSystemDefaultSessionMigrationNeedsFullScan ||= markerOwesFullScan - return this.pendingHostSystemDefaultSessionMigrationNeedsFullScan - } - - finishHostSystemDefaultSessionMigrationPass(): void { - this.hostSystemDefaultSessionMigrationPending = false - this.pendingHostSystemDefaultSessionMigrationNeedsFullScan = false - this.pendingHostSystemDefaultSessionMigrationTarget = null - } - - // Why: a managed HOST account runs against its own self-contained CODEX_HOME - // (codex-accounts//home) rather than the shared runtime mirror. Its - // auth.json lives there and codex refreshes it in place, so two accounts never - // race one auth.json. WSL accounts keep their per-distro lane. - private getSelfContainedManagedHostAccount(): CodexManagedAccount | null { - const settings = this.store.getSettings() - const account = this.getActiveAccount( - settings.codexManagedAccounts, - normalizeCodexRuntimeSelection(settings).host - ) - if (!account || this.getWslManagedHomePath(account)) { - return null - } - return account - } - - // Why: session discovery must surface every account's own rollouts wherever they live. - private getManagedAccountHomesForSessionDiscovery(): string[] { - const settings = this.store.getSettings() - const homes: string[] = [] - for (const account of settings.codexManagedAccounts) { - const wslHome = this.getWslManagedHomePath(account) - if (wslHome) { - homes.push(wslHome) - continue - } - const trustedHome = this.getTrustedSelfContainedManagedHomePath(account) - if (trustedHome) { - homes.push(trustedHome) - } - } - return homes - } - - private getManagedHostAccountHomesForSessionDiscovery(): string[] { - const settings = this.store.getSettings() - const homes: string[] = [] - for (const account of settings.codexManagedAccounts) { - if (this.getWslManagedHomePath(account)) { - continue - } - const trustedHome = this.getTrustedSelfContainedManagedHomePath(account) - if (trustedHome) { - homes.push(trustedHome) - } - } - return homes - } - - private prepareSelfContainedManagedHomeForLaunch( - account: CodexManagedAccount, - unavailableManagedHomePath?: string - ): string | null { - const resolved = this.resolveSelfContainedManagedHome(account) - if (resolved.kind === 'indeterminate') { - // Why: refuse the launch rather than silently falling through to the - // system default, which would run a different account behind a UI still - // showing this one. The selection stays put; a later read may succeed. - throw new ManagedCodexHomeTemporarilyUnavailableError() - } - if (resolved.kind === 'untrusted') { - this.clearSelfContainedManagedSelection(account) - return null - } - const perAccountHome = resolved.homePath - if ( - unavailableManagedHomePath && - normalizeRuntimePathForComparison(unavailableManagedHomePath) === - normalizeRuntimePathForComparison(perAccountHome) - ) { - const absence = this.credentialAbsenceGrace.assess(join(perAccountHome, 'auth.json')) - if (absence.state !== 'present' && absence.durable) { - this.clearSelfContainedManagedSelection(account, 'credential remained unavailable') - return null - } - // Why: a transient missing/unreadable auth.json is usually codex rotating - // it; keep the selection and launch — the CLI re-reads the settled file. - } - // Why: link the user's real ~/.codex resources and mirror config into THIS - // home (never symlinking into or mutating ~/.codex), so the per-account home - // is a complete CODEX_HOME. Hooks/trust are installed by the launch caller. - this.lastSyncedAccountId = account.id - this.lastHostAccountUsedSelfContainedHome = true - this.sharedAuthRefreshBlockedByManagedTransition = true - this.markSharedRuntimeAuthManaged(account.id) - syncSystemCodexResourcesIntoManagedHome(perAccountHome) - syncSystemConfigIntoManagedCodexHome({ - runtimeHomePath: perAccountHome, - systemHomePath: getSystemCodexHomePath() - }) - this.startSelfContainedSessionBridgeForLaunch(perAccountHome) - return perAccountHome - } - - // Why: Codex's own `/resume` picker only lists rollouts under the launch - // CODEX_HOME, so a self-contained account home starts out with no history at - // all. Hardlink every other Orca-visible home's rollouts in — after launch, - // since history trees can be large — so switching accounts no longer hides - // the user's conversations. - private startSelfContainedSessionBridgeForLaunch(perAccountHome: string): void { - void startCodexAccountSessionBridgeInBackground({ - targetCodexHomePath: perAccountHome, - sourceCodexHomePaths: this.getSelfContainedSessionBridgeSourceHomes() - }) - } - - private getSelfContainedSessionBridgeSourceHomes(): string[] { - return [ - // Why: history-only override lets custom-CODEX_HOME users bridge from the - // home they actually record sessions in; falls back to the real ~/.codex. - resolveHostCodexSessionSourceHome(this.store.getSettings()) ?? getSystemCodexHomePath(), - // Why: path only — a per-account install must not materialize the mirror. - resolveOrcaManagedCodexHomePath(), - ...this.getManagedHostAccountHomesForSessionDiscovery() - ] - } - - // Why: the per-account home is both the launch CODEX_HOME and the credential - // store, so codex reads/refreshes auth.json in place — there is no shared-home - // hot-swap or token read-back to reconcile. A trusted home remains selected - // while Codex atomically replaces auth.json. - private syncSelfContainedManagedSelection(account: CodexManagedAccount): void { - const resolved = this.resolveSelfContainedManagedHome(account) - if (resolved.kind === 'indeterminate') { - // Why: a sync runs on every app start, exactly when antivirus is busiest. - // An unreadable home must not deselect the account (#STA-4422). - return - } - const perAccountHome = resolved.kind === 'owned' ? resolved.homePath : null - if (perAccountHome) { - this.lastSyncedAccountId = account.id - this.lastHostAccountUsedSelfContainedHome = true - this.sharedAuthRefreshBlockedByManagedTransition = true - this.markSharedRuntimeAuthManaged(account.id) - // Why: selection runs well before the user restarts a pane, so history is - // already linked in by the time the newly launched Codex opens /resume. - this.startSelfContainedSessionBridgeForLaunch(perAccountHome) - return - } - this.clearSelfContainedManagedSelection(account) - } - - /** - * Why: an unreadable home and an untrustworthy one demand opposite responses. - * Only `untrusted` may clear the user's selection; `indeterminate` means we - * could not tell, so callers refuse the operation and leave durable state - * alone (#STA-4422). - */ - private resolveSelfContainedManagedHome( - account: CodexManagedAccount - ): { kind: 'owned'; homePath: string } | { kind: 'untrusted' } | { kind: 'indeterminate' } { - const verdict = resolveHostCodexManagedHomeVerdict({ - candidatePath: account.managedHomePath, - managedAccountsRoot: this.getManagedAccountsRoot(), - systemCodexHomePath: getSystemCodexHomePath(), - expectedAccountId: account.id - }) - if (verdict.kind === 'owned') { - // Preserve the persisted path spelling (notably /var vs /private/var on - // macOS) so injected CODEX_HOME stays stable across the rollout. - return { kind: 'owned', homePath: account.managedHomePath } - } - if (verdict.kind === 'untrusted') { - console.warn('[codex-runtime-home] Refusing untrusted managed account home:', verdict.reason) - return { kind: 'untrusted' } - } - console.warn( - '[codex-runtime-home] Managed account home is temporarily unreadable; keeping selection:', - verdict.error - ) - return { kind: 'indeterminate' } - } - - /** Read-only callers that mutate nothing and simply skip an unusable home. */ - private getTrustedSelfContainedManagedHomePath(account: CodexManagedAccount): string | null { - const resolved = this.resolveSelfContainedManagedHome(account) - return resolved.kind === 'owned' ? resolved.homePath : null - } - - private clearSelfContainedManagedSelection( - account: CodexManagedAccount, - reason = 'home is invalid' - ): void { - console.warn(`[codex-runtime-home] Active managed account ${reason}, clearing selection`) - const settings = this.store.getSettings() - if (normalizeCodexRuntimeSelection(settings).host !== account.id) { - return - } - this.store.updateSettings({ - activeCodexManagedAccountId: null, - activeCodexManagedAccountIdsByRuntime: { - ...normalizeCodexRuntimeSelection(settings), - host: null - } - }) - this.lastSyncedAccountId = null - this.lastHostAccountUsedSelfContainedHome = false - } - - private invalidateBackfillAfterManagedSystemDefaultLaunch( - launchEnv?: NodeJS.ProcessEnv - ): boolean | null { - const settings = this.store.getSettings() - if ( - normalizeCodexRuntimeSelection(settings).host !== null || - hasCustomCodexHomeOverrideForLaunch(launchEnv) - ) { - return null - } - if (!this.hostSystemDefaultSessionMigrationPending) { - const paths = resolveCodexSessionBackfillPaths( - resolveHostCodexSessionSourceHome(this.store.getSettings()) - ) - this.pendingHostSystemDefaultSessionMigrationNeedsFullScan = - !hasCompletedCodexSessionBackfillMarker(paths.markerPath, paths.systemSessionsRoot) - this.pendingHostSystemDefaultSessionMigrationTarget = normalizeRuntimePathForComparison( - paths.systemSessionsRoot - ) - this.hostSystemDefaultSessionMigrationPending = true - } - return this.prepareHostSystemDefaultSessionMigrationPass() - } - - private startWslSessionBridgeForLaunch( - target: CodexAccountSelectionTarget, - runtimeHomePath: string | null - ): void { - if (process.platform !== 'win32' || !runtimeHomePath) { - return - } - const runtimeHomeWsl = parseWslUncPath(runtimeHomePath) - const distro = target.wslDistro?.trim() || runtimeHomeWsl?.distro || getDefaultWslDistro() - if (!distro) { - return - } - // Why: history-only override lets custom-CODEX_HOME users bridge from their real home; falls back to /.codex. - const systemCodexHomePath = - resolveWslCodexSessionSourceHome(this.store.getSettings(), distro) ?? - this.getWslSystemCodexHomePath({ runtime: 'wsl', wslDistro: distro }) - if (systemCodexHomePath && systemCodexHomePath !== runtimeHomePath) { - // Why: WSL history must be hardlinked inside the distro; host-side links can't bridge Windows and WSL filesystems in a resume-visible way. - void startWslCodexSessionBridgeInBackground({ - distro, - systemCodexHomePath, - managedCodexHomePath: runtimeHomePath - }) - } - } - - getHostCodexHomePathsForSessionDiscovery(): string[] { - const homes = [this.getRuntimeHomePath()] - if (this.isHostSystemDefaultRealHome() || this.getSelfContainedManagedHostAccount()) { - // Why: nested Orca processes can retain an ambient managed CODEX_HOME. - // Per-account lanes no longer bridge real-home history into the shared - // mirror, so include the real root for both directly-routed host lanes. - homes.push(getSystemCodexHomePath()) - } - // Why: account-scoped rollouts live in each account's own home, including WSL. - for (const perAccountHome of this.getManagedAccountHomesForSessionDiscovery()) { - homes.push(perAccountHome) - } - return homes.filter((home, index) => homes.indexOf(home) === index) - } - - /** - * The account-owned CODEX_HOME the current HOST selection runs against, or - * null when the selection is not routed to one (system default, or a WSL - * account, whose home lives inside the distro). - * - * Read-only on purpose: session discovery ranks homes with this before any - * launch prep, so it must create no directories and sync no auth. - */ - getSelectedHostAccountCodexHomePath(): string | null { - const selfContainedAccount = this.getSelfContainedManagedHostAccount() - return selfContainedAccount - ? this.getTrustedSelfContainedManagedHomePath(selfContainedAccount) - : null - } - - /** - * Same selection, but an unreadable home refuses instead of collapsing to - * `null`. Session resume must not read "no managed selection" out of a failed - * marker stat: another account's readable alias would then win the legacy - * rescan and the pane would resume under that account's credentials while the - * UI still shows this one (#STA-4422). - */ - resolveSelectedHostAccountCodexHomePathForResume(): string | null { - const selfContainedAccount = this.getSelfContainedManagedHostAccount() - if (!selfContainedAccount) { - return null - } - const resolved = this.resolveSelfContainedManagedHome(selfContainedAccount) - if (resolved.kind === 'indeterminate') { - throw new ManagedCodexHomeTemporarilyUnavailableError() - } - if (resolved.kind === 'untrusted') { - this.clearSelfContainedManagedSelection(selfContainedAccount) - return null - } - return resolved.homePath - } - - /** Trust-gates host previews without changing WSL routing or durable account state. */ - resolveCodexManagedAccountHomeForInactiveFetch( - account: CodexManagedAccount - ): { kind: 'ready'; homePath: string } | { kind: 'skip' } { - if (account.managedHomeRuntime === 'wsl' || this.getWslManagedHomePath(account)) { - return { kind: 'ready', homePath: account.managedHomePath } - } - const resolved = this.resolveSelfContainedManagedHome(account) - return resolved.kind === 'owned' - ? { kind: 'ready', homePath: resolved.homePath } - : { kind: 'skip' } - } - - getSelectedHostCodexHomeRoute(): CodexPaneHomeRoute { - if (this.getSelfContainedManagedHostAccount()) { - return 'account-home' - } - return this.isHostSystemDefaultRealHome() ? 'real-home' : 'shared-home' - } - - getRetainedHostCodexHookHomePaths(ptyIds: readonly string[]): string[] { - const settings = this.store.getSettings() - const homes = new Map() - for (const ptyId of ptyIds) { - const record = getCodexPaneAccount(ptyId) - if (!record || record.selectionKey !== 'host') { - continue - } - if ( - record.homeRoute === undefined || - record.homeRoute === 'shared-home' || - record.homeRoute === 'custom-home' - ) { - const homePath = this.getRuntimeHomePath() - homes.set(normalizeRuntimePathForComparison(homePath), homePath) - continue - } - if (record.homeRoute !== 'account-home' || !record.accountId) { - continue - } - const account = settings.codexManagedAccounts.find( - (candidate) => candidate.id === record.accountId - ) - if (!account || this.getWslManagedHomePath(account)) { - continue - } - const homePath = this.getTrustedSelfContainedManagedHomePath(account) - if (homePath) { - homes.set(normalizeRuntimePathForComparison(homePath), homePath) - } - } - return [...homes.values()] - } - - // Why: the real-home hook installer flips this gate off when the trust-grant - // client reports the host incapable, keeping that host byte-identical to the - // managed lane instead of shipping status-blind panes. - private realHomeLaneGate: () => boolean = () => true - - setRealHomeLaneGate(gate: () => boolean): void { - this.realHomeLaneGate = gate - } - - // Why: real-home routing applies only to the host system-default selection. - // Managed accounts run in their own homes; Windows (no shell-startup probe) - // and custom CODEX_HOMEs stay on the mirror until cleanup can be tracked - // across old homes. - isHostSystemDefaultRealHomeSelected(launchEnv?: NodeJS.ProcessEnv): boolean { - const settings = this.store.getSettings() - if ( - normalizeCodexRuntimeSelection(settings).host !== null || - !isShellStartupEnvProbeSupported() - ) { - return false - } - return !hasCustomCodexHomeOverrideForLaunch(launchEnv) - } - - isHostSystemDefaultRealHome(launchEnv?: NodeJS.ProcessEnv): boolean { - return this.isHostSystemDefaultRealHomeSelected(launchEnv) && this.realHomeLaneGate() - } - - reconcileLegacySharedHomeForRetainedPanes(): void { - if (!this.isHostSystemDefaultRealHome() || !hasRecordedLegacySharedCodexPane()) { - return - } - this.syncLegacySharedSystemDefaultAuthForRetainedPanes() - syncLegacySharedCodexConfigForRetainedPanes() - } - - private getWslSystemCodexHomePath(target: CodexAccountSelectionTarget): string | null { - if (process.platform !== 'win32') { - return null - } - const distro = target.wslDistro?.trim() || getDefaultWslDistro() - if (!distro) { - return null - } - const home = getWslHome(distro) - if (home && /^[A-Za-z]:[\\/]/.test(home)) { - const linuxHome = toLinuxPath(home).trim() - return linuxHome.startsWith('/') - ? toWindowsWslUncPath(pathPosix.join(linuxHome, '.codex'), distro) - : null - } - return home ? this.joinWslPath(home, '.codex') : null - } - - private finishWslLaunchPreparation( - target: CodexAccountSelectionTarget, - homePath: string | null - ): void { - this.syncWslConfigAndGlobalInstructionsForLaunch(target, homePath) - this.startWslSessionBridgeForLaunch(target, homePath) - } - - private syncWslConfigAndGlobalInstructionsForLaunch( - target: CodexAccountSelectionTarget, - runtimeHomePath: string | null - ): void { - if (!runtimeHomePath) { - return - } - const distro = - parseWslUncPath(runtimeHomePath)?.distro || target.wslDistro?.trim() || getDefaultWslDistro() - if (!distro) { - return - } - const systemHomePath = this.getWslSystemCodexHomePath({ runtime: 'wsl', wslDistro: distro }) - if (!systemHomePath || systemHomePath === runtimeHomePath) { - return - } - // Why: WSL uses a distro-local CODEX_HOME, so host resource mirroring can't provide the distro user's global instructions. - syncCodexGlobalInstructionsIntoManagedHome({ - systemHomePath, - managedHomePath: runtimeHomePath - }) - syncSystemConfigIntoManagedCodexHome({ - runtimeHomePath, - systemHomePath, - systemConfigDir: toLinuxPath(systemHomePath) - }) - } - - // Why: `null` is a real value here — it means "use the system-default lane". - // A skipped poll needs its own channel or the fetcher silently retargets the - // user's real ~/.codex (#STA-4422). - prepareForRateLimitFetch(target?: CodexAccountSelectionTarget): CodexRateLimitHomeResolution { - if (target?.runtime === 'wsl') { - const wslTarget = this.resolveWslDefaultTarget(target) - return { - kind: 'ready', - codexHomePath: this.getPreparedWslRateLimitHomePath(wslTarget) - } - } - const selfContainedAccount = this.getSelfContainedManagedHostAccount() - if (selfContainedAccount) { - const resolved = this.resolveSelfContainedManagedHome(selfContainedAccount) - if (resolved.kind === 'owned') { - // Why: the quota fetch reads the account's own auth.json in place; no - // shared-home hot-swap or per-poll resource relink (that is launch prep). - return { kind: 'ready', codexHomePath: resolved.homePath } - } - if (resolved.kind === 'indeterminate') { - // Why: returning null here would NOT skip — the fetcher maps null to - // ~/.codex and would probe the user's real home with a token-refreshing - // app-server. Skip the poll outright and keep the selection. - return { kind: 'skip' } - } - this.clearSelfContainedManagedSelection(selfContainedAccount) - } - if (this.isHostSystemDefaultRealHome()) { - // Why: null lets the fetcher fall back to the main process's inherited - // CODEX_HOME before ~/.codex. Nested Orca launches can inherit the - // managed home, restarting the background OAuth conflict (#5370), so - // pin this non-interactive lane to the native home explicitly. - if (hasRecordedLegacySharedCodexPane()) { - this.syncLegacySharedSystemDefaultAuthForRetainedPanes() - } - return { kind: 'ready', codexHomePath: getSystemCodexHomePath() } - } - this.syncForCurrentSelection() - syncSystemCodexResourcesIntoManagedHome() - syncSystemConfigIntoManagedCodexHome() - return { kind: 'ready', codexHomePath: this.getRuntimeHomePath() } - } - - syncForCurrentSelection( - target?: CodexAccountSelectionTarget, - launchEnv?: NodeJS.ProcessEnv - ): void { - if (target?.runtime === 'wsl') { - this.startLegacyWslAuthDrain(this.resolveWslDefaultTarget(target)) - return - } - - const selfContainedAccount = this.getSelfContainedManagedHostAccount() - if (selfContainedAccount) { - // Why: self-contained managed homes hold their own auth, so the shared - // runtime home's snapshot/hot-swap/read-back machinery below must not run. - this.syncSelfContainedManagedSelection(selfContainedAccount) - return - } - const settings = this.store.getSettings() - if (this.lastHostAccountUsedSelfContainedHome) { - // Why: the account's auth is already canonical in its own home. Reset the - // legacy mirror baseline without reading it; a real-home deselect needs no - // further sync, and the mirror lane below re-seeds from canonical storage. - this.lastHostAccountUsedSelfContainedHome = false - this.lastSyncedAccountId = null - this.lastWrittenAuthJson = null - if (this.isHostSystemDefaultRealHome(launchEnv)) { - return - } - } - if (this.isHostSystemDefaultRealHome(launchEnv)) { - // Why: retained daemon panes may own shared auth from a managed launch; - // compatibility reconciliation runs later with durable provenance. - if (this.lastSyncedAccountId !== null) { - this.sharedAuthRefreshBlockedByManagedTransition = true - this.lastSyncedAccountId = null - this.lastWrittenAuthJson = null - } - return - } - const runtimeAuthExistedBeforeSync = existsSync(this.getRuntimeAuthPath()) - if (this.lastSyncedAccountId === null) { - this.captureSystemDefaultSnapshot({ force: false }) - } - const activeAccount = this.getActiveAccount( - settings.codexManagedAccounts, - normalizeCodexRuntimeSelection(settings).host - ) - if (activeAccount) { - // Why: only a WSL-managed account can reach here — every host account was - // routed to its own self-contained home above. Its auth lives in the - // distro-local runtime home, so the host mirror only drops its baseline. - this.lastSyncedAccountId = null - this.lastWrittenAuthJson = null - return - } - if (normalizeCodexRuntimeSelection(settings).host) { - this.store.updateSettings({ - activeCodexManagedAccountId: null, - activeCodexManagedAccountIdsByRuntime: { - ...normalizeCodexRuntimeSelection(settings), - host: null - } - }) - } - // Why: only restore the system-default mirror when leaving a managed account; otherwise later syncs mirror current ~/.codex instead of replaying an old snapshot. - if (this.lastSyncedAccountId !== null) { - this.restoreSystemDefaultSnapshot({ detectExternalLogin: true }) - this.lastSyncedAccountId = null - } else if (!runtimeAuthExistedBeforeSync) { - const logoutMarkerStatus = this.getRuntimeLogoutMarkerStatus() - if (logoutMarkerStatus.kind === 'applies') { - this.lastWrittenAuthJson = null - } else if ( - logoutMarkerStatus.kind === 'system-default-changed' && - logoutMarkerStatus.systemDefaultAuthJson !== null - ) { - this.restoreSystemDefaultSnapshot({ detectExternalLogin: false }) - } else if (logoutMarkerStatus.kind === 'system-default-changed') { - // Why: a real ~/.codex logout after a local runtime logout should keep runtime auth absent, not restore the stale snapshot. - this.captureSystemDefaultSnapshot({ force: true }) - this.persistRuntimeLogoutMarker(null) - this.lastWrittenAuthJson = null - } else if (this.lastWrittenAuthJson === null) { - // Why: unmanaged sessions use an Orca-owned CODEX_HOME; seed it once from system-default auth so terminals stay logged in without mutating ~/.codex. - this.restoreSystemDefaultSnapshot({ detectExternalLogin: false }) - } else { - this.persistRuntimeLogoutMarker() - } - } else { - this.clearRuntimeLogoutMarker() - this.syncRuntimeAuthWithSystemDefault() - } - } - - // Why: re-auth/add-account writes fresh host tokens, invalidating the shared mirror baseline. - clearLastWrittenAuthJson( - accountId = normalizeCodexRuntimeSelection(this.store.getSettings()).host - ): void { - if (accountId === normalizeCodexRuntimeSelection(this.store.getSettings()).host) { - this.lastWrittenAuthJson = null - } - } - - // Why: which ~/.codex bytes the mirror was seeded from, and whether the system - // default can be proven to own the mirror at all. - private resolveSystemDefaultMirrorClaim( - runtimeAuth: string, - provenanceStatus: CodexSharedRuntimeAuthProvenanceStatus - ): { ownershipProven: boolean; mirroredAuthJson: string | null } { - const provenance = provenanceStatus.kind === 'committed' ? provenanceStatus.provenance : null - const snapshotAuth = - this.readSystemDefaultSnapshot(this.getSystemDefaultSnapshotPath())?.authJson ?? null - const preProvenanceRuntimeRefreshProven = - provenanceStatus.kind === 'missing' && - snapshotAuth !== null && - this.runtimeAuthMatchesSystemDefaultIdentity(runtimeAuth, snapshotAuth) && - codexAuthIsMonotonicallyFresher(runtimeAuth, snapshotAuth) - return { - ownershipProven: provenance?.owner === 'system-default' || preProvenanceRuntimeRefreshProven, - mirroredAuthJson: - provenance?.owner === 'system-default' - ? provenance.authJson - : provenanceStatus.kind === 'missing' - ? (this.lastWrittenAuthJson ?? snapshotAuth) - : null - } - } - - private safeSyncForCurrentSelection(): void { - try { - this.syncForCurrentSelection() - } catch (error) { - console.warn('[codex-runtime-home] Failed to sync runtime auth state:', error) - } - } - - private safeRecoverInterruptedRuntimeAuthOperation(): void { - try { - recoverInterruptedGuardedFileOperation(this.getRuntimeAuthPath()) - } catch (error) { - console.warn('[codex-runtime-home] Failed to recover interrupted auth update:', error) - } - } - - private getActiveAccount( - accounts: CodexManagedAccount[], - activeAccountId: string | null - ): CodexManagedAccount | null { - if (!activeAccountId) { - return null - } - return accounts.find((account) => account.id === activeAccountId) ?? null - } - - private getWslManagedHomePath(account: CodexManagedAccount | null): string | null { - return this.getWslManagedHomeIdentity(account) ? (account?.managedHomePath ?? null) : null - } - - private getPreparedWslRateLimitHomePath(target: CodexAccountSelectionTarget): string | null { - return this.getWslCodexHomePathForSelection(target) - } - - private getWslCodexHomePathForSelection(target: CodexAccountSelectionTarget): string | null { - const settings = this.store.getSettings() - const account = this.getActiveAccount( - settings.codexManagedAccounts, - getSelectedCodexAccountIdForTarget(settings, target) - ) - if (account) { - const targetDistro = this.resolveWslDefaultTarget(target).wslDistro?.trim() - const accountHome = this.getWslLaunchCodexHomePath(account, targetDistro) - if (accountHome) { - return accountHome - } - } - return this.getWslSystemCodexHomePath(target) - } - - private getWslLaunchCodexHomePath( - account: CodexManagedAccount, - targetDistro: string | undefined - ): string | null { - const wslHome = this.getWslManagedHomeIdentity(account) - if (!wslHome) { - return null - } - const accountDistro = wslHome.distro - if (targetDistro && accountDistro.toLowerCase() !== targetDistro.toLowerCase()) { - return null - } - if (/^[A-Za-z]:[\\/]/.test(account.managedHomePath)) { - return toWindowsWslUncPath(wslHome.linuxHomePath, accountDistro) - } - return account.managedHomePath || toWindowsWslUncPath(wslHome.linuxHomePath, accountDistro) - } - - private getWslManagedHomeIdentity( - account: CodexManagedAccount | null - ): { distro: string; linuxHomePath: string } | null { - if (!account) { - return null - } - const distro = account.wslDistro?.trim() - const linuxHomePath = account.wslLinuxHomePath?.trim() - if (account.managedHomeRuntime === 'wsl' && distro && linuxHomePath?.startsWith('/')) { - return { distro, linuxHomePath } - } - const legacyHome = parseWslUncPath(account.managedHomePath) - return legacyHome ? { distro: legacyHome.distro, linuxHomePath: legacyHome.linuxPath } : null - } - - private startLegacyWslAuthDrain( - target: CodexAccountSelectionTarget, - options: { throwOnFailure?: boolean } = {} - ): Promise { - if (process.platform !== 'win32') { - return Promise.resolve() - } - const distro = target.wslDistro?.trim() || getDefaultWslDistro() - if (!distro) { - return Promise.resolve() - } - const guestHome = getWslHome(distro) - const guestHomeLinuxPath = guestHome ? toLinuxPath(guestHome).trim() : '' - if (!guestHomeLinuxPath.startsWith('/')) { - return Promise.resolve() - } - let legacyPanePresent = true - try { - legacyPanePresent = hasRecordedLegacyWslCodexPane(getCodexSelectionLaneKey(target)) - } catch (error) { - // Why: unknown pane liveness must preserve the source, but promotion can - // still keep the direct home from launching stale auth. - console.warn('[codex-wsl-auth-drain] Pane registry unavailable; preserving source:', error) - } - return startLegacyWslRuntimeAuthDrain( - { - distro, - guestHomeLinuxPath, - legacyPanePresent, - resolveDestination: (runtimeAuthContents) => - this.resolveLegacyWslAuthDestination(distro, runtimeAuthContents) - }, - options - ) - } - - /** Preserve refreshed auth from retained legacy WSL panes before restart. */ - async syncActiveWslSelectionsBeforeRestart(): Promise { - if (process.platform !== 'win32') { - return - } - const settings = this.store.getSettings() - const drains: Promise[] = [] - for (const [selectedDistroKey, accountId] of Object.entries( - normalizeCodexRuntimeSelection(settings).wsl - )) { - if (!accountId) { - continue - } - const account = this.getActiveAccount(settings.codexManagedAccounts, accountId) - if (!account || account.managedHomeRuntime !== 'wsl') { - continue - } - const distro = - selectedDistroKey === getWslSelectionKey(null) - ? account.wslDistro?.trim() || null - : selectedDistroKey.trim() || null - if (distro) { - drains.push(this.startLegacyWslAuthDrain({ runtime: 'wsl', wslDistro: distro })) - } - } - await Promise.all(drains) - } - - private async resolveLegacyWslAuthDestination( - distro: string, - runtimeAuthContents: string - ): Promise { - const accountHomes = this.store.getSettings().codexManagedAccounts.flatMap((account) => { - const wslHome = this.getWslManagedHomeIdentity(account) - return wslHome?.distro.toLowerCase() === distro.toLowerCase() - ? [{ account, linuxPath: wslHome.linuxHomePath }] - : [] - }) - const accounts = accountHomes.map(({ account }) => account) - const systemHome = this.getWslSystemCodexHomePath({ runtime: 'wsl', wslDistro: distro }) - const parsedSystemHome = systemHome ? parseWslUncPath(systemHome) : null - let reads: WslCodexAuthRead[] - try { - reads = await readWslCodexAuths(distro, [ - ...accountHomes.map(({ linuxPath }) => linuxPath), - ...(parsedSystemHome ? [parsedSystemHome.linuxPath] : []) - ]) - } catch { - reads = accountHomes.map(() => ({ kind: 'unreadable' })) - if (parsedSystemHome) { - reads.push({ kind: 'unreadable' }) - } - } - const authReads = new Map( - accountHomes.map(({ account }, index) => [account.id, reads[index] ?? { kind: 'unreadable' }]) - ) - const match = this.findManagedAccountForRuntimeAuth(runtimeAuthContents, undefined, { - accounts, - authReads - }) - if (match.kind === 'ambiguous') { - return null - } - if (match.kind === 'matched') { - const accountHome = accountHomes.find(({ account }) => account.id === match.account.id) - if (!accountHome) { - return null - } - return { - authContents: match.managedAuthContents, - linuxHomePath: accountHome.linuxPath - } - } - - if (!systemHome || !parsedSystemHome) { - return null - } - const systemAuth = reads[accountHomes.length] ?? { kind: 'unreadable' } - if (systemAuth.kind !== 'present') { - return null - } - return this.runtimeAuthMatchesSystemDefaultIdentity(runtimeAuthContents, systemAuth.contents) - ? { authContents: systemAuth.contents, linuxHomePath: parsedSystemHome.linuxPath } - : null - } - - private joinWslPath(basePath: string, ...segments: string[]): string { - return parseWslUncPath(basePath) - ? pathWin32.join(basePath, ...segments) - : join(basePath, ...segments) - } - - private resolveWslDefaultTarget( - target: CodexAccountSelectionTarget - ): CodexAccountSelectionTarget { - if (target.runtime !== 'wsl' || target.wslDistro?.trim()) { - return target - } - const defaultDistro = getDefaultWslDistro() - return defaultDistro ? { runtime: 'wsl', wslDistro: defaultDistro } : target - } - - private findManagedAccountForRuntimeAuth( - runtimeAuthContents: string, - expectedAccountId?: string, - options?: { - accounts: readonly CodexManagedAccount[] - authReads: ReadonlyMap - } - ): CodexReadBackMatch { - const matches: { - account: CodexManagedAccount - managedAuthPath: string - managedAuthContents: string - }[] = [] - let unreadableHomeCouldOwnRuntimeAuth = false - for (const account of options?.accounts ?? this.store.getSettings().codexManagedAccounts) { - if (expectedAccountId && account.id !== expectedAccountId) { - continue - } - const managedAuthPath = join(account.managedHomePath, 'auth.json') - let managedAuthContents: string - const suppliedRead = options?.authReads.get(account.id) - if (suppliedRead?.kind === 'missing') { - continue - } - if (suppliedRead?.kind === 'unreadable') { - // Why: an unreadable home can never be compared, but letting the read - // throw abandons the scan for every other account — dropping a refresh - // the runtime home holds for one of them. Only its record can rule it - // out as the owner; when it cannot, the scan is no longer unambiguous. - if ( - !expectedAccountId && - codexAuthCouldBelongToManagedAccount(runtimeAuthContents, account) - ) { - unreadableHomeCouldOwnRuntimeAuth = true - } - continue - } - if (suppliedRead?.kind === 'present') { - managedAuthContents = suppliedRead.contents - } else { - if (!existsSync(managedAuthPath)) { - continue - } - try { - managedAuthContents = readFileSync(managedAuthPath, 'utf-8') - } catch { - if ( - !expectedAccountId && - codexAuthCouldBelongToManagedAccount(runtimeAuthContents, account) - ) { - unreadableHomeCouldOwnRuntimeAuth = true - } - continue - } - } - if (codexAuthMatchesManagedAccount(runtimeAuthContents, account, managedAuthContents)) { - matches.push({ account, managedAuthPath, managedAuthContents }) - } - } - - if (unreadableHomeCouldOwnRuntimeAuth) { - return { kind: 'ambiguous' } - } - if (matches.length === 1) { - return { kind: 'matched', ...matches[0] } - } - return { kind: matches.length === 0 ? 'none' : 'ambiguous' } - } - - private runtimeAuthMatchesSystemDefaultIdentity( - runtimeAuthContents: string, - systemDefaultAuthContents: string - ): boolean { - return codexAuthMatchesSystemDefaultIdentity(runtimeAuthContents, systemDefaultAuthContents) - } - - private safeMigrateLegacySharedAuth(): void { - const settings = this.store.getSettings() - try { - migrateLegacySharedAuthToPerAccountHome({ - activeHostAccountId: normalizeCodexRuntimeSelection(settings).host, - hostAccounts: settings.codexManagedAccounts.filter( - (account) => !this.getWslManagedHomePath(account) - ), - managedAccountsRoot: this.getManagedAccountsRoot(), - metadataDir: this.getRuntimeMetadataDir(), - sharedRuntimeHome: this.getRuntimeHomePath(), - systemCodexHome: getSystemCodexHomePath() - }) - } catch (error) { - // Why: an inconclusive identity, ownership, or filesystem result must - // leave the marker absent so the next startup can retry safely. - console.warn('[codex-runtime-home] Failed to migrate legacy shared Codex auth:', error) - } - } - - private safeMigrateLegacyManagedState(): void { - try { - this.migrateLegacyManagedStateIfNeeded() - } catch (error) { - console.warn('[codex-runtime-home] Failed to migrate legacy managed Codex state:', error) - } - } - - private safeMigrateLegacyActiveHomePointer(): void { - try { - const activeHomePath = this.getLegacyHostActiveHomePath() - if (!this.legacyActiveHomePathExists(activeHomePath)) { - return - } - this.repointLegacyActiveHomePointer(activeHomePath, this.getRuntimeHomePath()) - } catch (error) { - console.warn('[codex-runtime-home] Failed to migrate legacy active Codex home:', error) - } - } - - private getRuntimeHomePath(): string { - return getOrcaManagedCodexHomePath() - } - - /** - * Resolves the managed home the config mirror actually targets for the - * current HOST selection, or null when no mirror runs for it. - * - * Read-only on purpose: unlike the launch and quota-fetch paths this prepares - * nothing and creates no directories, so surfacing sync health cannot alter - * the state it is reporting on. Returns null for the system default on the - * real-home lane, which runs Codex directly against ~/.codex — there is no - * mirror there, so there is nothing that can fall behind. - */ - getMirroredHostHomePathForStatus(): CodexMirroredHomeStatus { - const selfContainedAccount = this.getSelfContainedManagedHostAccount() - if (selfContainedAccount) { - const resolved = this.resolveSelfContainedManagedHome(selfContainedAccount) - if (resolved.kind === 'indeterminate') { - // Why: `null` here is a positive claim that no mirror exists, which the - // status channel reports as healthy. An unreadable home is not that. - return { kind: 'unavailable' } - } - return { kind: 'ready', homePath: resolved.kind === 'owned' ? resolved.homePath : null } - } - if (this.isHostSystemDefaultRealHome()) { - return { kind: 'ready', homePath: null } - } - return { - kind: 'ready', - homePath: join(getOrcaUserDataPath(), 'codex-runtime-home', 'home') - } - } - - private getRuntimeAuthPath(): string { - return join(this.getRuntimeHomePath(), 'auth.json') - } - - private getSystemDefaultSnapshotPath(): string { - return join(this.getRuntimeMetadataDir(), 'system-default-auth.json') - } - - private getRuntimeLogoutMarkerPath(): string { - return join(this.getRuntimeMetadataDir(), 'system-default-runtime-logout.json') - } - - private getSharedRuntimeAuthProvenancePath(): string { - return join(this.getRuntimeMetadataDir(), 'shared-runtime-auth-provenance.json') - } - - private getRuntimeMetadataDir(): string { - const metadataDir = join(app.getPath('userData'), 'codex-runtime-home') - mkdirSync(metadataDir, { recursive: true }) - return metadataDir - } - - private getLegacyHostActiveHomePath(): string { - return join(this.getRuntimeMetadataDir(), 'active', 'host', 'home') - } - - private getMigrationMarkerPath(): string { - return join(this.getRuntimeMetadataDir(), 'migration-v1.json') - } - - private getMigrationDiagnosticsPath(): string { - return join(this.getRuntimeMetadataDir(), 'migration-diagnostics.jsonl') - } - - private getManagedAccountsRoot(): string { - return join(app.getPath('userData'), 'codex-accounts') - } - - private repointLegacyActiveHomePointer(activeHomePath: string, runtimeHomePath: string): void { - if (this.activeHomeAlreadyPointsToRuntimeHome(activeHomePath, runtimeHomePath)) { - return - } - if (!this.legacyActiveHomeLinkIsReplaceable(activeHomePath)) { - return - } - - mkdirSync(runtimeHomePath, { recursive: true }) - mkdirSync(dirname(activeHomePath), { recursive: true }) - const nextLinkPath = `${activeHomePath}.next-${process.pid}-${Date.now()}` - this.removeLegacyActiveHomeLinkIfOwned(nextLinkPath) - try { - symlinkSync( - runtimeHomePath, - nextLinkPath, - process.platform === 'win32' && lstatSync(runtimeHomePath).isDirectory() - ? 'junction' - : undefined - ) - try { - renameSync(nextLinkPath, activeHomePath) - } catch (error) { - if (!this.legacyActiveHomeLinkIsReplaceable(activeHomePath)) { - throw error - } - this.removeLegacyActiveHomeLinkIfOwned(activeHomePath) - renameSync(nextLinkPath, activeHomePath) - } - } finally { - this.removeLegacyActiveHomeLinkIfOwned(nextLinkPath) - } - } - - private activeHomeAlreadyPointsToRuntimeHome( - activeHomePath: string, - runtimeHomePath: string - ): boolean { - try { - return this.linkTargetsMatch(readlinkSync(activeHomePath), activeHomePath, runtimeHomePath) - } catch { - return false - } - } - - private linkTargetsMatch( - linkTarget: string, - linkPath: string, - expectedTargetPath: string - ): boolean { - const resolvedLinkTarget = isAbsolute(linkTarget) - ? resolve(linkTarget) - : resolve(dirname(linkPath), linkTarget) - return resolvedLinkTarget === resolve(expectedTargetPath) - } - - private legacyActiveHomeLinkIsReplaceable(activeHomePath: string): boolean { - try { - const stat = lstatSync(activeHomePath) - return stat.isSymbolicLink() || this.isWindowsReadableLink(activeHomePath) - } catch { - return true - } - } - - private legacyActiveHomePathExists(activeHomePath: string): boolean { - try { - lstatSync(activeHomePath) - return true - } catch { - return false - } - } - - private removeLegacyActiveHomeLinkIfOwned(activeHomePath: string): void { - try { - const stat = lstatSync(activeHomePath) - if (stat.isSymbolicLink()) { - unlinkSync(activeHomePath) - } else if (this.isWindowsReadableLink(activeHomePath)) { - rmdirSync(activeHomePath) - } - } catch { - // Missing or inaccessible temporary links are handled by the caller. - } - } - - private isWindowsReadableLink(targetPath: string): boolean { - if (process.platform !== 'win32') { - return false - } - try { - readlinkSync(targetPath) - return true - } catch { - return false - } - } - - private migrateLegacyManagedStateIfNeeded(): void { - if (existsSync(this.getMigrationMarkerPath())) { - return - } - - const managedHomes = this.getLegacyManagedHomes() - for (const managedHomePath of managedHomes) { - const accountId = parse(relative(this.getManagedAccountsRoot(), managedHomePath)).dir.split( - /[\\/]/ - )[0] - if (!accountId) { - continue - } - this.migrateLegacyHistory(managedHomePath) - this.migrateLegacySessions(managedHomePath, accountId) - } - - // Why: migration is one-shot; re-importing every startup would replay stale managed-home state into the shared runtime. - writeFileAtomically( - this.getMigrationMarkerPath(), - `${JSON.stringify({ completedAt: Date.now(), migratedHomeCount: managedHomes.length })}\n` - ) - } - - private getLegacyManagedHomes(): string[] { - const managedAccountsRoot = this.getManagedAccountsRoot() - if (!existsSync(managedAccountsRoot)) { - return [] - } - - const accountEntries = readdirSync(managedAccountsRoot, { withFileTypes: true }) - const managedHomes: string[] = [] - for (const entry of accountEntries) { - if (!entry.isDirectory()) { - continue - } - const managedHomePath = join(managedAccountsRoot, entry.name, 'home') - if (existsSync(join(managedHomePath, '.orca-managed-home'))) { - managedHomes.push(managedHomePath) - } - } - return managedHomes.sort() - } - - private migrateLegacyHistory(managedHomePath: string): void { - const legacyHistoryPath = join(managedHomePath, 'history.jsonl') - if (!existsSync(legacyHistoryPath)) { - return - } - - const runtimeHistoryPath = join(this.getRuntimeHomePath(), 'history.jsonl') - const existingLines = existsSync(runtimeHistoryPath) - ? readFileSync(runtimeHistoryPath, 'utf-8').split('\n').filter(Boolean) - : [] - const mergedLines = [...existingLines] - const seenLines = new Set(existingLines) - for (const line of readFileSync(legacyHistoryPath, 'utf-8').split('\n')) { - if (!line || seenLines.has(line)) { - continue - } - seenLines.add(line) - mergedLines.push(line) - } - - if (mergedLines.length === 0) { - return - } - writeFileAtomically(runtimeHistoryPath, `${mergedLines.join('\n')}\n`) - } - - private migrateLegacySessions(managedHomePath: string, accountId: string): void { - const legacySessionsRoot = join(managedHomePath, 'sessions') - if (!existsSync(legacySessionsRoot)) { - return - } - - const runtimeSessionsRoot = join(this.getRuntimeHomePath(), 'sessions') - mkdirSync(runtimeSessionsRoot, { recursive: true }) - for (const legacyFilePath of this.listFilesRecursively(legacySessionsRoot)) { - const relativePath = relative(legacySessionsRoot, legacyFilePath) - const runtimeFilePath = join(runtimeSessionsRoot, relativePath) - mkdirSync(dirname(runtimeFilePath), { recursive: true }) - if (!existsSync(runtimeFilePath)) { - copyFileSync(legacyFilePath, runtimeFilePath) - continue - } - - const legacyContents = readFileSync(legacyFilePath) - const runtimeContents = readFileSync(runtimeFilePath) - if (runtimeContents.equals(legacyContents)) { - continue - } - - const preservedPath = this.getPreservedLegacySessionPath(runtimeFilePath, accountId) - copyFileSync(legacyFilePath, preservedPath) - this.appendMigrationDiagnostic({ - type: 'session-conflict', - accountId, - runtimeFilePath, - preservedPath - }) - } - } - - private listFilesRecursively(rootPath: string): string[] { - const stat = statSync(rootPath) - if (!stat.isDirectory()) { - return [rootPath] - } - - const files: string[] = [] - for (const entry of readdirSync(rootPath, { withFileTypes: true })) { - const childPath = join(rootPath, entry.name) - if (entry.isDirectory()) { - this.appendListedFiles(files, this.listFilesRecursively(childPath)) - continue - } - if (entry.isFile()) { - files.push(childPath) - } - } - return files.sort() - } - - private appendListedFiles(target: string[], source: readonly string[]): void { - // Why: tolerate directories larger than V8's argument limit for spread calls. - for (const filePath of source) { - target.push(filePath) - } - } - - private getPreservedLegacySessionPath(runtimeFilePath: string, accountId: string): string { - const extension = extname(runtimeFilePath) - const basename = runtimeFilePath.slice(0, runtimeFilePath.length - extension.length) - return `${basename}.orca-legacy-${accountId}${extension}` - } - - private appendMigrationDiagnostic(record: Record): void { - const diagnosticsPath = this.getMigrationDiagnosticsPath() - try { - appendFileSync(diagnosticsPath, `${JSON.stringify(record)}\n`, { encoding: 'utf-8' }) - } catch (error) { - // Why: diagnostics must not fail the one-shot migration after the session file is already preserved. - console.warn('[codex-runtime-home] Failed to append migration diagnostic:', error) - } - } - - private captureSystemDefaultSnapshot(options: { force: boolean }): void { - const snapshotPath = this.getSystemDefaultSnapshotPath() - if (!options.force && existsSync(snapshotPath)) { - return - } - - const runtimeAuthPath = join(getSystemCodexHomePath(), 'auth.json') - const snapshot: CodexSystemDefaultSnapshot = { - authJson: existsSync(runtimeAuthPath) ? readFileSync(runtimeAuthPath, 'utf-8') : null - } - writeFileAtomically(snapshotPath, `${JSON.stringify(snapshot, null, 2)}\n`, { mode: 0o600 }) - } - - private syncRuntimeAuthWithSystemDefault(): void { - const runtimeAuthPath = this.getRuntimeAuthPath() - const systemDefaultAuthPath = join(getSystemCodexHomePath(), 'auth.json') - if (!existsSync(runtimeAuthPath)) { - return - } - - try { - const runtimeAuth = readFileSync(runtimeAuthPath, 'utf-8') - const provenanceStatus = this.resolveSharedRuntimeAuthProvenanceStatus() - const provenance = provenanceStatus.kind === 'committed' ? provenanceStatus.provenance : null - if (provenance?.owner === 'managed') { - this.captureSystemDefaultSnapshot({ force: true }) - if (!existsSync(systemDefaultAuthPath)) { - this.clearRuntimeAuthAfterSystemDefaultLogout(runtimeAuthPath) - return - } - this.writeRuntimeAuth(readFileSync(systemDefaultAuthPath, 'utf-8'), { - owner: 'system-default' - }) - return - } - const { - ownershipProven: systemDefaultOwnershipProven, - mirroredAuthJson: mirroredSystemDefaultAuth - } = this.resolveSystemDefaultMirrorClaim(runtimeAuth, provenanceStatus) - if (!existsSync(systemDefaultAuthPath)) { - if (mirroredSystemDefaultAuth !== null && runtimeAuth === mirroredSystemDefaultAuth) { - this.clearRuntimeAuthAfterSystemDefaultLogout(runtimeAuthPath) - return - } - if ( - systemDefaultOwnershipProven && - mirroredSystemDefaultAuth !== null && - this.runtimeAuthMatchesSystemDefaultIdentity(runtimeAuth, mirroredSystemDefaultAuth) - ) { - this.clearRuntimeAuthAfterSystemDefaultLogout(runtimeAuthPath) - } - return - } - const systemDefaultAuth = readFileSync(systemDefaultAuthPath, 'utf-8') - if (runtimeAuth === systemDefaultAuth) { - this.writeRuntimeAuth(systemDefaultAuth, { owner: 'system-default' }) - return - } - if ( - systemDefaultOwnershipProven && - mirroredSystemDefaultAuth !== null && - systemDefaultAuth === mirroredSystemDefaultAuth && - this.runtimeAuthMatchesSystemDefaultIdentity(runtimeAuth, mirroredSystemDefaultAuth) - ) { - // Why: Codex refreshes tokens in the runtime CODEX_HOME; read that back to ~/.codex so the next sync won't clobber fresh creds with stale ones. - this.writeSystemDefaultAuth(runtimeAuth) - this.captureSystemDefaultSnapshot({ force: true }) - this.writeRuntimeAuth(runtimeAuth, { owner: 'system-default' }) - return - } - // Why: mirror external logins/logouts into Orca's runtime home so unmanaged Codex sessions keep matching the current system-default state. - this.captureSystemDefaultSnapshot({ force: true }) - this.writeRuntimeAuth(systemDefaultAuth, { owner: 'system-default' }) - } catch (error) { - console.warn('[codex-runtime-home] Failed to sync system-default auth:', error) - } - } - - private syncLegacySharedSystemDefaultAuthForRetainedPanes(): void { - if (this.sharedAuthRefreshBlockedByManagedTransition || this.lastSyncedAccountId !== null) { - this.sharedAuthRefreshBlockedByManagedTransition = false - return - } - const runtimeAuthPath = this.getRuntimeAuthPath() - try { - let provenanceStatus = this.resolveSharedRuntimeAuthProvenanceStatus() - if ( - provenanceStatus.kind === 'committed' && - provenanceStatus.provenance.owner === 'managed' - ) { - const restoredProvenance = this.restoreUntouchedSystemDefaultProvenance( - provenanceStatus.provenance - ) - if (restoredProvenance) { - provenanceStatus = { kind: 'committed', provenance: restoredProvenance } - } - } - if ( - provenanceStatus.kind === 'fenced' || - (provenanceStatus.kind === 'committed' && provenanceStatus.provenance.owner === 'managed') - ) { - return - } - const systemAuth = this.readSystemDefaultAuth() - if (!existsSync(runtimeAuthPath)) { - const logoutMarkerStatus = this.getRuntimeLogoutMarkerStatus() - const snapshot = this.readSystemDefaultSnapshot(this.getSystemDefaultSnapshotPath()) - const knownSystemAuthBaseline = - provenanceStatus.kind === 'committed' && - provenanceStatus.provenance.owner === 'system-default' - ? provenanceStatus.provenance.authJson - : provenanceStatus.kind === 'missing' - ? (this.lastWrittenAuthJson ?? snapshot?.authJson) - : undefined - if (systemAuth === null) { - if ( - provenanceStatus.kind === 'committed' && - provenanceStatus.provenance.owner === 'system-default' && - provenanceStatus.provenance.authJson === null && - logoutMarkerStatus.kind === 'applies' && - snapshot?.authJson === null - ) { - this.lastWrittenAuthJson = null - return - } - // Why: commit a crashed logout before a managed transition can discard its recovery baseline. - this.captureSystemDefaultSnapshot({ force: true }) - this.persistRuntimeLogoutMarker(null) - this.lastWrittenAuthJson = null - this.persistSharedRuntimeAuthProvenance({ owner: 'system-default', authJson: null }) - return - } - if ( - logoutMarkerStatus.kind === 'system-default-changed' || - (knownSystemAuthBaseline !== undefined && knownSystemAuthBaseline !== systemAuth) - ) { - const replaced = this.writeRuntimeAuth( - systemAuth, - { - owner: 'system-default' - }, - { expectedContents: null } - ) - if (replaced) { - this.captureSystemDefaultSnapshot({ force: true }) - } - } - return - } - const runtimeAuthBeforeSync = readFileSync(runtimeAuthPath, 'utf-8') - const snapshot = this.readSystemDefaultSnapshot(this.getSystemDefaultSnapshotPath()) - const provenance = provenanceStatus.kind === 'committed' ? provenanceStatus.provenance : null - const knownSharedAuth = - provenance?.owner === 'system-default' - ? provenance.authJson - : provenanceStatus.kind === 'missing' - ? (this.lastWrittenAuthJson ?? snapshot?.authJson ?? null) - : null - // Why: only bytes Orca can prove it wrote belong to the compatibility - // mirror; retained Codex or a managed transition owns every other value. - if (knownSharedAuth === null) { - return - } - const sharedAuthOwnedBySystemDefault = - runtimeAuthBeforeSync === knownSharedAuth || - (provenance?.owner === 'system-default' && - systemAuth === null && - this.runtimeAuthMatchesSystemDefaultIdentity(runtimeAuthBeforeSync, knownSharedAuth)) - if (!sharedAuthOwnedBySystemDefault) { - return - } - if (systemAuth === null) { - removeFileAtomicallyIfUnchanged(runtimeAuthPath, runtimeAuthBeforeSync) - if (existsSync(runtimeAuthPath)) { - this.persistSharedRuntimeAuthProvenance({ owner: 'fenced' }) - return - } - this.captureSystemDefaultSnapshot({ force: true }) - this.persistRuntimeLogoutMarker(null) - this.lastWrittenAuthJson = null - this.persistSharedRuntimeAuthProvenance({ - owner: 'system-default', - authJson: null - }) - return - } - if (runtimeAuthBeforeSync !== knownSharedAuth) { - return - } - const replaced = this.writeRuntimeAuth( - systemAuth, - { owner: 'system-default' }, - { expectedContents: runtimeAuthBeforeSync } - ) - if (replaced) { - this.captureSystemDefaultSnapshot({ force: true }) - } - } catch (error) { - console.warn('[codex-runtime-home] Failed to refresh retained-pane auth:', error) - } - } - - private restoreSystemDefaultSnapshot(options: { detectExternalLogin: boolean }): void { - const snapshotPath = this.getSystemDefaultSnapshotPath() - const runtimeAuthPath = this.getRuntimeAuthPath() - const systemDefaultAuthPath = join(getSystemCodexHomePath(), 'auth.json') - if (existsSync(systemDefaultAuthPath)) { - const systemDefaultAuth = readFileSync(systemDefaultAuthPath, 'utf-8') - this.captureSystemDefaultSnapshot({ force: true }) - this.writeRuntimeAuth(systemDefaultAuth, { owner: 'system-default' }) - return - } - - if (options.detectExternalLogin && !existsSync(runtimeAuthPath)) { - // Why: with Orca owning CODEX_HOME, a deleted runtime auth.json is a local logout, not a cue to restore the user's real ~/.codex snapshot. - this.persistRuntimeLogoutMarker() - this.lastWrittenAuthJson = null - this.persistSharedRuntimeAuthProvenance({ owner: 'system-default', authJson: null }) - return - } - - if (options.detectExternalLogin) { - // Why: if ~/.codex/auth.json vanished while a managed account was selected, switching back must preserve that external system-default logout. - rmSync(runtimeAuthPath, { force: true }) - this.captureSystemDefaultSnapshot({ force: true }) - this.persistRuntimeLogoutMarker() - this.lastWrittenAuthJson = null - this.persistSharedRuntimeAuthProvenance({ owner: 'system-default', authJson: null }) - return - } - - if (!existsSync(snapshotPath)) { - this.captureSystemDefaultSnapshot({ force: true }) - } - - const snapshot = this.readSystemDefaultSnapshot(snapshotPath) - if (!snapshot) { - console.warn('[codex-runtime-home] Ignoring invalid system-default auth snapshot') - rmSync(snapshotPath, { force: true }) - this.captureSystemDefaultSnapshot({ force: true }) - const refreshedSnapshot = this.readSystemDefaultSnapshot(snapshotPath) - if (!refreshedSnapshot) { - rmSync(runtimeAuthPath, { force: true }) - this.lastWrittenAuthJson = null - this.persistSharedRuntimeAuthProvenance({ owner: 'system-default', authJson: null }) - return - } - if (refreshedSnapshot.authJson === null) { - rmSync(runtimeAuthPath, { force: true }) - this.lastWrittenAuthJson = null - this.persistSharedRuntimeAuthProvenance({ owner: 'system-default', authJson: null }) - return - } - this.writeRuntimeAuth(refreshedSnapshot.authJson, { owner: 'system-default' }) - return - } - if (snapshot.authJson === null) { - rmSync(runtimeAuthPath, { force: true }) - this.lastWrittenAuthJson = null - this.persistSharedRuntimeAuthProvenance({ owner: 'system-default', authJson: null }) - return - } - this.writeRuntimeAuth(snapshot.authJson, { owner: 'system-default' }) - } - - private writeSystemDefaultAuth(contents: string): void { - const systemDefaultAuthPath = join(getSystemCodexHomePath(), 'auth.json') - mkdirSync(dirname(systemDefaultAuthPath), { recursive: true }) - writeFileAtomically(systemDefaultAuthPath, contents, { mode: 0o600 }) - this.ensureOwnerOnlyMode(systemDefaultAuthPath) - } - - private clearRuntimeAuthAfterSystemDefaultLogout(runtimeAuthPath: string): void { - // Why: a vanished ~/.codex auth means external logout for unmanaged sessions, even if runtime auth already refreshed in Orca's CODEX_HOME. - rmSync(runtimeAuthPath, { force: true }) - this.captureSystemDefaultSnapshot({ force: true }) - this.persistRuntimeLogoutMarker() - this.lastWrittenAuthJson = null - this.persistSharedRuntimeAuthProvenance({ - owner: 'system-default', - authJson: null - }) - } - - private readSystemDefaultAuth(): string | null { - const systemDefaultAuthPath = join(getSystemCodexHomePath(), 'auth.json') - return existsSync(systemDefaultAuthPath) ? readFileSync(systemDefaultAuthPath, 'utf-8') : null - } - - private writeRuntimeAuth( - contents: string, - owner: { owner: 'system-default' } | { owner: 'managed'; accountId: string }, - options?: { expectedContents: string | null } - ): boolean { - // Why: auth.json holds credentials; restrict to owner-only so other users on a shared machine cannot read it. - const runtimeAuthPath = this.getRuntimeAuthPath() - if (options && !this.fileContentsMatchExpected(runtimeAuthPath, options.expectedContents)) { - return false - } - const provenance: CodexSharedRuntimeAuthProvenance = - owner.owner === 'system-default' ? { owner: 'system-default', authJson: contents } : owner - const runtimeAuthComparison = this.compareFileContents(runtimeAuthPath, contents) - if (runtimeAuthComparison === null) { - // Why: an unreadable runtime auth.json may hold a token Codex rotated a - // moment ago. Treating "could not read" as "differs" sent execution to the - // unconditional write below, consuming that rotation and logging the user - // out for good. Refuse; the next sync retries. - return false - } - const runtimeAuthAlreadyMatches = runtimeAuthComparison - if ( - runtimeAuthAlreadyMatches && - this.sharedRuntimeAuthProvenanceMatches( - this.resolveSharedRuntimeAuthProvenanceStatus(), - provenance - ) - ) { - this.ensureOwnerOnlyMode(runtimeAuthPath) - this.lastWrittenAuthJson = contents - this.clearRuntimeLogoutMarker() - return true - } - this.persistSharedRuntimeAuthProvenance({ - owner: 'pending', - next: provenance, - runtimeAuthJson: contents - }) - if (runtimeAuthAlreadyMatches) { - this.ensureOwnerOnlyMode(runtimeAuthPath) - this.lastWrittenAuthJson = contents - this.persistSharedRuntimeAuthProvenance(provenance) - this.clearRuntimeLogoutMarker() - return true - } - const replaced = options - ? writeFileAtomicallyIfUnchanged(runtimeAuthPath, options.expectedContents, contents, { - mode: 0o600 - }) - : (writeFileAtomically(runtimeAuthPath, contents, { mode: 0o600 }), true) - if (!replaced) { - return false - } - this.lastWrittenAuthJson = contents - this.persistSharedRuntimeAuthProvenance(provenance) - this.clearRuntimeLogoutMarker() - return true - } - - /** - * `true`/`false` only when the bytes were actually read; `null` when the file - * could not be read at all. The old `catch { return false }` reported "these - * differ" for a file nobody could open, and every caller reads that as - * permission to write. - */ - private compareFileContents(targetPath: string, contents: string): boolean | null { - try { - return readFileSync(targetPath, 'utf-8') === contents - } catch (error) { - return isDefinitiveAbsence(error) ? false : null - } - } - - private fileContentsEqual(targetPath: string, contents: string): boolean { - return this.compareFileContents(targetPath, contents) === true - } - - private fileContentsMatchExpected(targetPath: string, expectedContents: string | null): boolean { - if (expectedContents === null) { - // Why: `!existsSync` does report `true` for a locked file, but this branch - // is not where that matters — the write it guards is - // `writeFileAtomicallyIfUnchanged`, whose rename-and-compare re-checks the - // real file and refuses on its own. Classifying here would be a guard no - // test can drive. - return !existsSync(targetPath) - } - return this.fileContentsEqual(targetPath, expectedContents) - } - - private ensureOwnerOnlyMode(targetPath: string): void { - if (process.platform === 'win32') { - return - } - try { - chmodSync(targetPath, 0o600) - } catch { - /* Best effort: the next atomic write will set the restrictive mode. */ - } - } - - private getRuntimeLogoutMarkerStatus(): CodexRuntimeLogoutMarkerStatus { - const marker = this.readRuntimeLogoutMarker() - if (!marker) { - return { kind: 'missing' } - } - const systemDefaultAuthJson = this.readSystemDefaultAuth() - if (systemDefaultAuthJson === marker.systemDefaultAuthJson) { - return { kind: 'applies' } - } - this.clearRuntimeLogoutMarker() - return { kind: 'system-default-changed', systemDefaultAuthJson } - } - - private persistRuntimeLogoutMarker(systemDefaultAuthJson = this.readSystemDefaultAuth()): void { - const marker: CodexRuntimeLogoutMarker = { - systemDefaultAuthJson, - loggedOutAt: Date.now() - } - writeFileAtomically(this.getRuntimeLogoutMarkerPath(), `${JSON.stringify(marker, null, 2)}\n`, { - mode: 0o600 - }) - } - - private readRuntimeLogoutMarker(): CodexRuntimeLogoutMarker | null { - let parsed: unknown - try { - parsed = JSON.parse(readFileSync(this.getRuntimeLogoutMarkerPath(), 'utf-8')) as unknown - } catch { - return null - } - if ( - !parsed || - typeof parsed !== 'object' || - Array.isArray(parsed) || - !('systemDefaultAuthJson' in parsed) || - !('loggedOutAt' in parsed) - ) { - return null - } - const marker = parsed as { systemDefaultAuthJson: unknown; loggedOutAt: unknown } - if ( - (marker.systemDefaultAuthJson !== null && typeof marker.systemDefaultAuthJson !== 'string') || - typeof marker.loggedOutAt !== 'number' - ) { - return null - } - return marker as CodexRuntimeLogoutMarker - } - - private clearRuntimeLogoutMarker(): void { - rmSync(this.getRuntimeLogoutMarkerPath(), { force: true }) - } - - private persistSharedRuntimeAuthProvenance( - provenance: CodexSharedRuntimeAuthProvenanceFile - ): void { - writeFileAtomically( - this.getSharedRuntimeAuthProvenancePath(), - `${JSON.stringify(provenance, null, 2)}\n`, - { mode: 0o600 } - ) - } - - private markSharedRuntimeAuthManaged(accountId: string): void { - const status = this.resolveSharedRuntimeAuthProvenanceStatus() - if ( - status.kind === 'committed' && - status.provenance.owner === 'managed' && - status.provenance.accountId === accountId - ) { - return - } - const runtimeAuthJson = this.readRuntimeAuthForProvenance() - const systemDefaultBaseline = this.getUntouchedSystemDefaultBaseline(status, runtimeAuthJson) - const provenance: CodexSharedRuntimeAuthProvenance = { - owner: 'managed', - accountId, - ...(systemDefaultBaseline ? { systemDefaultBaseline } : {}) - } - this.persistSharedRuntimeAuthProvenance({ - owner: 'pending', - next: provenance, - runtimeAuthJson - }) - if (this.readRuntimeAuthForProvenance() === runtimeAuthJson) { - this.persistSharedRuntimeAuthProvenance(provenance) - } - } - - private getUntouchedSystemDefaultBaseline( - status: CodexSharedRuntimeAuthProvenanceStatus, - runtimeAuthJson: string | null - ): { authJson: string | null } | null { - if (status.kind !== 'committed') { - return null - } - const baseline = - status.provenance.owner === 'system-default' - ? { authJson: status.provenance.authJson } - : status.provenance.systemDefaultBaseline - return baseline && runtimeAuthJson === baseline.authJson ? baseline : null - } - - private restoreUntouchedSystemDefaultProvenance( - provenance: Extract - ): Extract | null { - const baseline = provenance.systemDefaultBaseline - if (!baseline || this.readRuntimeAuthForProvenance() !== baseline.authJson) { - return null - } - const restored = { owner: 'system-default' as const, authJson: baseline.authJson } - this.persistSharedRuntimeAuthProvenance({ - owner: 'pending', - next: restored, - runtimeAuthJson: baseline.authJson - }) - if (this.readRuntimeAuthForProvenance() !== baseline.authJson) { - return null - } - this.persistSharedRuntimeAuthProvenance(restored) - return restored - } - - private sharedRuntimeAuthProvenanceMatches( - status: CodexSharedRuntimeAuthProvenanceStatus, - expected: CodexSharedRuntimeAuthProvenance - ): boolean { - if (status.kind !== 'committed' || status.provenance.owner !== expected.owner) { - return false - } - return expected.owner === 'system-default' - ? status.provenance.owner === 'system-default' && - status.provenance.authJson === expected.authJson - : status.provenance.owner === 'managed' && status.provenance.accountId === expected.accountId - } - - private resolveSharedRuntimeAuthProvenanceStatus(): CodexSharedRuntimeAuthProvenanceStatus { - const provenancePath = this.getSharedRuntimeAuthProvenancePath() - if (!existsSync(provenancePath)) { - return { kind: 'missing' } - } - let parsed: unknown - try { - parsed = JSON.parse(readFileSync(provenancePath, 'utf-8')) as unknown - } catch { - return { kind: 'fenced' } - } - const committed = this.parseSharedRuntimeAuthProvenance(parsed) - if (committed) { - return { kind: 'committed', provenance: committed } - } - const pending = this.parsePendingSharedRuntimeAuthProvenance(parsed) - if (!pending || this.readRuntimeAuthForProvenance() !== pending.runtimeAuthJson) { - return { kind: 'fenced' } - } - try { - this.persistSharedRuntimeAuthProvenance(pending.next) - return { kind: 'committed', provenance: pending.next } - } catch { - return { kind: 'fenced' } - } - } - - private parseSharedRuntimeAuthProvenance( - value: unknown - ): CodexSharedRuntimeAuthProvenance | null { - if (!value || typeof value !== 'object' || Array.isArray(value)) { - return null - } - const provenance = value as Record - if ( - provenance.owner === 'system-default' && - (typeof provenance.authJson === 'string' || provenance.authJson === null) - ) { - return { owner: 'system-default', authJson: provenance.authJson } - } - if ( - provenance.owner !== 'managed' || - typeof provenance.accountId !== 'string' || - provenance.accountId.length === 0 - ) { - return null - } - const baseline = this.parseSystemDefaultBaseline(provenance.systemDefaultBaseline) - if ('systemDefaultBaseline' in provenance && !baseline) { - return null - } - return { - owner: 'managed', - accountId: provenance.accountId, - ...(baseline ? { systemDefaultBaseline: baseline } : {}) - } - } - - private parseSystemDefaultBaseline(value: unknown): { authJson: string | null } | null { - if (!value || typeof value !== 'object' || Array.isArray(value)) { - return null - } - const baseline = value as Record - return typeof baseline.authJson === 'string' || baseline.authJson === null - ? { authJson: baseline.authJson } - : null - } - - private parsePendingSharedRuntimeAuthProvenance( - value: unknown - ): CodexSharedRuntimeAuthPendingProvenance | null { - if (!value || typeof value !== 'object' || Array.isArray(value)) { - return null - } - const pending = value as Record - const next = this.parseSharedRuntimeAuthProvenance(pending.next) - return pending.owner === 'pending' && - next && - (typeof pending.runtimeAuthJson === 'string' || pending.runtimeAuthJson === null) - ? { owner: 'pending', next, runtimeAuthJson: pending.runtimeAuthJson } - : null - } - - private readRuntimeAuthForProvenance(): string | null { - try { - return readFileSync(this.getRuntimeAuthPath(), 'utf-8') - } catch { - return null - } - } - - private readSystemDefaultSnapshot(snapshotPath: string): CodexSystemDefaultSnapshot | null { - let rawContents: string - try { - rawContents = readFileSync(snapshotPath, 'utf-8') - } catch { - return null - } - try { - const parsed = JSON.parse(rawContents) as unknown - if ( - parsed && - typeof parsed === 'object' && - !Array.isArray(parsed) && - 'authJson' in parsed && - (typeof (parsed as { authJson: unknown }).authJson === 'string' || - (parsed as { authJson: unknown }).authJson === null) - ) { - return parsed as CodexSystemDefaultSnapshot - } - // Why: pre-PR snapshots stored raw auth.json; treat objects lacking an authJson wrapper as legacy so upgraders don't lose their auth. - if ( - parsed && - typeof parsed === 'object' && - !Array.isArray(parsed) && - !('authJson' in parsed) - ) { - return { authJson: rawContents } - } - } catch { - return null - } - return null - } - - clearSystemDefaultSnapshot(): void { - rmSync(this.getSystemDefaultSnapshotPath(), { force: true }) - } } diff --git a/src/main/codex/codex-rollout-session-meta.test.ts b/src/main/codex/codex-rollout-session-meta.test.ts new file mode 100644 index 00000000000..5af978a2139 --- /dev/null +++ b/src/main/codex/codex-rollout-session-meta.test.ts @@ -0,0 +1,93 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type * as WslFsAccessModule from '../native-chat/wsl-transcript-fs-access' +import { readCodexRolloutSessionMetaId } from './codex-rollout-session-meta' + +const mocks = vi.hoisted(() => ({ readTranscriptSlice: vi.fn() })) + +vi.mock('../native-chat/wsl-transcript-fs-access', async (importOriginal) => { + const original = await importOriginal() + mocks.readTranscriptSlice.mockImplementation(original.readTranscriptSlice) + return { ...original, readTranscriptSlice: mocks.readTranscriptSlice } +}) + +let tempRoots: string[] = [] + +afterEach(async () => { + mocks.readTranscriptSlice.mockClear() + await Promise.all(tempRoots.map((root) => rm(root, { recursive: true, force: true }))) + tempRoots = [] +}) + +describe('readCodexRolloutSessionMetaId', () => { + it('reads the session id from the first rollout record', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-codex-session-meta-')) + tempRoots.push(root) + const rollout = join(root, 'rollout.jsonl') + await writeFile( + rollout, + `${JSON.stringify({ type: 'session_meta', payload: { id: 'session-id' } })}\nignored` + ) + + await expect(readCodexRolloutSessionMetaId(rollout)).resolves.toBe('session-id') + }) + + it('returns null for a missing or malformed rollout', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-codex-session-meta-')) + tempRoots.push(root) + const malformed = join(root, 'malformed.jsonl') + await writeFile(malformed, '{"type":"session_meta"') + + await expect(readCodexRolloutSessionMetaId(join(root, 'missing.jsonl'))).resolves.toBeNull() + await expect(readCodexRolloutSessionMetaId(malformed)).resolves.toBeNull() + }) + + // Why: AI Vault hands this every scan candidate, so a `\\wsl.localhost\...` + // rollout must fail on the transcript gate's deadline rather than block the + // scan behind a stalled distro (#15453). + it('reads through the gated transcript filesystem at interactive priority', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-codex-session-meta-')) + tempRoots.push(root) + const rollout = join(root, 'rollout.jsonl') + await writeFile(rollout, JSON.stringify({ type: 'session_meta', payload: { id: 'gated' } })) + const controller = new AbortController() + + await expect(readCodexRolloutSessionMetaId(rollout, controller.signal)).resolves.toBe('gated') + + // Default `exact`: the live-resume proof is interactive and must not queue + // behind an AI Vault sweep of the same WSL distro. + expect(mocks.readTranscriptSlice).toHaveBeenCalledWith( + rollout, + 0, + expect.any(Number), + 'exact', + controller.signal + ) + await expect(readCodexRolloutSessionMetaId(rollout, undefined, 'scan')).resolves.toBe('gated') + expect(mocks.readTranscriptSlice).toHaveBeenLastCalledWith( + rollout, + 0, + expect.any(Number), + 'scan', + undefined + ) + }) + + it('surfaces an aborted read instead of reporting an unprovable rollout', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-codex-session-meta-')) + tempRoots.push(root) + const rollout = join(root, 'rollout.jsonl') + await writeFile(rollout, JSON.stringify({ type: 'session_meta', payload: { id: 'aborted' } })) + const controller = new AbortController() + mocks.readTranscriptSlice.mockImplementationOnce(async () => { + controller.abort() + throw new Error('aborted') + }) + + await expect(readCodexRolloutSessionMetaId(rollout, controller.signal)).rejects.toThrow( + 'aborted' + ) + }) +}) diff --git a/src/main/codex/codex-rollout-session-meta.ts b/src/main/codex/codex-rollout-session-meta.ts new file mode 100644 index 00000000000..3d64e71760a --- /dev/null +++ b/src/main/codex/codex-rollout-session-meta.ts @@ -0,0 +1,58 @@ +import { readTranscriptSlice } from '../native-chat/wsl-transcript-fs-access' +import type { WslTranscriptFsTaskPriority } from '../native-chat/wsl-transcript-fs-gate' + +const ROLLOUT_READ_LIMIT = 64 * 1024 + +/** + * Read the Codex session id without streaming the full rollout transcript. + * Defaults to `exact` because the live-resume proof is interactive; the AI + * Vault scan passes `scan` so a bulk sweep cannot starve it. + */ +export async function readCodexRolloutSessionMetaId( + filePath: string, + signal?: AbortSignal, + priority: WslTranscriptFsTaskPriority = 'exact' +): Promise { + let head: Buffer + try { + // Gated, not raw fs: AI Vault hands this every scan candidate, including + // `\\wsl.localhost\...` rollouts, so a stalled distro must fail on the + // transcript gate's deadline instead of blocking the scan (#15453). A + // listed rollout may also vanish before it is read — Codex prunes and + // rewrites these files — and one missing file must not abort the scan. + head = await readTranscriptSlice(filePath, 0, ROLLOUT_READ_LIMIT, priority, signal) + } catch (error) { + // A cancelled scan must surface as cancellation, not as one more rollout + // that could not prove its id. + if (signal?.aborted) { + throw error + } + return null + } + const firstLine = head.toString('utf8').split(/\r?\n/, 1)[0]?.trim() + if (!firstLine) { + return null + } + try { + const record = JSON.parse(firstLine) as { + type?: unknown + id?: unknown + session_id?: unknown + thread_id?: unknown + payload?: { id?: unknown; session_id?: unknown; thread_id?: unknown } + } + if (record.type !== 'session_meta') { + return null + } + const id = + record.payload?.id ?? + record.payload?.session_id ?? + record.payload?.thread_id ?? + record.id ?? + record.session_id ?? + record.thread_id + return typeof id === 'string' && id.length > 0 ? id : null + } catch { + return null + } +} diff --git a/src/main/codex/codex-tui-rollout-proof.ts b/src/main/codex/codex-tui-rollout-proof.ts index d9de2227231..0e9293f1616 100644 --- a/src/main/codex/codex-tui-rollout-proof.ts +++ b/src/main/codex/codex-tui-rollout-proof.ts @@ -1,7 +1,7 @@ -import { open } from 'node:fs/promises' import { join } from 'node:path' import { stripAnsiEscapeSequences } from '../../shared/ansi-escape-sequences' import { relativePathInsideRoot } from '../../shared/cross-platform-path' +import { readCodexRolloutSessionMetaId } from './codex-rollout-session-meta' import { listCodexSessionJsonlFilesIncrementally } from './codex-session-file-listing' const SESSION_ID_PATTERN = '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}' @@ -9,7 +9,6 @@ const STATUS_SESSION_RE = new RegExp( `\\b(?:Session|Thread)(?:\\s+ID)?\\s*:\\s*(${SESSION_ID_PATTERN})\\b`, 'gi' ) -const ROLLOUT_READ_LIMIT = 64 * 1024 const STATUS_COMMAND_PASTE = '\u001b[200~/status\u001b[201~' const KITTY_ENTER = '\u001b[13u' const TAB = '\t' @@ -204,44 +203,3 @@ export async function resolveLiveCodexTuiRollout(input: { } throw new Error('The agent terminal did not publish a resumable Codex conversation.') } - -async function readCodexRolloutSessionMetaId(filePath: string): Promise { - // A listed rollout may vanish before it is read — Codex prunes and rewrites - // these files. One missing file must not abort the whole scan. - let file: Awaited> - try { - file = await open(filePath, 'r') - } catch { - return null - } - try { - const buffer = Buffer.alloc(ROLLOUT_READ_LIMIT) - const { bytesRead } = await file.read(buffer, 0, buffer.length, 0) - const firstLine = buffer.subarray(0, bytesRead).toString('utf8').split(/\r?\n/, 1)[0]?.trim() - if (!firstLine) { - return null - } - const record = JSON.parse(firstLine) as { - type?: unknown - id?: unknown - session_id?: unknown - thread_id?: unknown - payload?: { id?: unknown; session_id?: unknown; thread_id?: unknown } - } - if (record.type !== 'session_meta') { - return null - } - const id = - record.payload?.id ?? - record.payload?.session_id ?? - record.payload?.thread_id ?? - record.id ?? - record.session_id ?? - record.thread_id - return typeof id === 'string' && id.length > 0 ? id : null - } catch { - return null - } finally { - await file.close() - } -} diff --git a/src/main/crash-reporting/gpu-crash-diagnostics.test.ts b/src/main/crash-reporting/gpu-crash-diagnostics.test.ts index 0cb6c02cf3a..1982facd5f3 100644 --- a/src/main/crash-reporting/gpu-crash-diagnostics.test.ts +++ b/src/main/crash-reporting/gpu-crash-diagnostics.test.ts @@ -226,16 +226,26 @@ describe('GpuCrashDiagnosticsRecorder', () => { describe('GPU crash diagnostics production wiring', () => { it('starts diagnostics without delaying safe-graphics fallback', () => { - const source = readFileSync(join(__dirname, '..', 'index.ts'), 'utf8') - const listenerStart = source.indexOf("app.on('child-process-gone'") + const source = readFileSync( + join(__dirname, '..', 'startup', 'main-process-preflight.ts'), + 'utf8' + ) + const listenerSource = readFileSync( + join(__dirname, '..', 'startup', 'main-process-ready-runtime.ts'), + 'utf8' + ) + const listenerStart = listenerSource.indexOf(" app.on('child-process-gone'") expect(listenerStart).toBeGreaterThan(0) - const listener = source.slice(listenerStart, source.indexOf('\n })', listenerStart)) + const listener = listenerSource.slice( + listenerStart, + listenerSource.indexOf('\n })', listenerStart) + ) expect(source).toMatch( /recordBreadcrumb: \(data\) =>\s*recordDurableCrashBreadcrumb\('gpu_crash_hardware', data\)/ ) expect(listener).toMatch( - /const crashedAt = performance\.now\(\)[\s\S]*?void gpuCrashDiagnostics\?\.record\(\)[\s\S]*?void handleGpuChildCrash\(details\.reason, details\.exitCode \?\? null, crashedAt\)/ + /const crashedAt = performance\.now\(\)[\s\S]*?void state\.gpuCrashDiagnostics\?\.record\(\)[\s\S]*?void handleGpuChildCrash\(details\.reason, details\.exitCode \?\? null, crashedAt\)/ ) - expect(listener).not.toMatch(/gpuCrashDiagnostics\?\.record\(\)[\s\S]*?\.then\(/) + expect(listener).not.toMatch(/state\.gpuCrashDiagnostics\?\.record\(\)[\s\S]*?\.then\(/) }) }) diff --git a/src/main/crash-reporting/gpu-crash-fallback-field-sessions.test.ts b/src/main/crash-reporting/gpu-crash-fallback-field-sessions.test.ts index aec22e2e446..43441250708 100644 --- a/src/main/crash-reporting/gpu-crash-fallback-field-sessions.test.ts +++ b/src/main/crash-reporting/gpu-crash-fallback-field-sessions.test.ts @@ -40,10 +40,13 @@ const CRASHED_CLUSTER_SESSIONS: FieldSession[] = [ { report: '96d8c63b', gpuCrashesMsSinceLaunch: [2_108] } ] -/** index.ts's `child-process-gone` listener body — the wiring these claims rest on. */ +/** Ready-phase `child-process-gone` listener body — the wiring these claims rest on. */ function readChildProcessGoneListener(): string { - const source = readFileSync(join(__dirname, '..', 'index.ts'), 'utf8') - const start = source.indexOf("app.on('child-process-gone'") + const source = readFileSync( + join(__dirname, '..', 'startup', 'main-process-ready-runtime.ts'), + 'utf8' + ) + const start = source.indexOf(" app.on('child-process-gone'") expect(start).toBeGreaterThan(0) return source.slice(start, source.indexOf('\n })', start)) } @@ -89,7 +92,7 @@ describe('1.4.190 win32 GPU-child crash cluster', () => { expect(guardStart).toBeGreaterThan(0) expect(listener.slice(0, guardStart).match(/\bif\s*\(/g) ?? []).toHaveLength(1) expect(listener).toMatch( - /isGpuFallbackCrashCandidate\([\s\S]*?gpuCrashDiagnostics\?\.record\(\)[\s\S]*?handleGpuChildCrash\(/ + /isGpuFallbackCrashCandidate\([\s\S]*?state\.gpuCrashDiagnostics\?\.record\(\)[\s\S]*?handleGpuChildCrash\(/ ) // The `if (` count alone still allows `recorded && isGpuFallbackCrashCandidate(...)`, which // re-couples recovery to the suppression decision, so pin the guard to that check alone. diff --git a/src/main/crash-reporting/gpu-fallback-recovered-launch.test.ts b/src/main/crash-reporting/gpu-fallback-recovered-launch.test.ts index 3c93f7992ff..23707754ff8 100644 --- a/src/main/crash-reporting/gpu-fallback-recovered-launch.test.ts +++ b/src/main/crash-reporting/gpu-fallback-recovered-launch.test.ts @@ -108,11 +108,18 @@ describe('handleGpuFallbackRecoveredLaunch', () => { describe('recovered safe-graphics production wiring', () => { it('prompts only after the recovered window is shown and persists both consent states', () => { - const source = readFileSync(join(__dirname, '..', 'index.ts'), 'utf8') - expect(source).toMatch( + const windowSource = readFileSync( + join(__dirname, '..', 'startup', 'main-window-controller.ts'), + 'utf8' + ) + const lifecycleSource = readFileSync( + join(__dirname, '..', 'startup', 'gpu-lifecycle.ts'), + 'utf8' + ) + expect(windowSource).toMatch( /window\.once\('show',[\s\S]*?presentGpuFallbackRecoveredLaunchPrompt\(window\)/ ) - expect(source).toMatch( + expect(lifecycleSource).toMatch( /persistMarker:[\s\S]*?userConfirmed: false[\s\S]*?confirmMarker:[\s\S]*?userConfirmed: true/ ) }) diff --git a/src/main/daemon/daemon-adoption-telemetry-event.test.ts b/src/main/daemon/daemon-adoption-telemetry-event.test.ts new file mode 100644 index 00000000000..5a5cd7406c4 --- /dev/null +++ b/src/main/daemon/daemon-adoption-telemetry-event.test.ts @@ -0,0 +1,168 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { ParsedDaemonPid } from './daemon-pid-file-parse' +import { validate } from '../telemetry/validator' + +const { trackMock, accessSyncMock, existsSyncMock, readFileSyncMock, getVersionMock } = vi.hoisted( + () => ({ + trackMock: vi.fn(), + accessSyncMock: vi.fn(), + existsSyncMock: vi.fn(() => true), + readFileSyncMock: vi.fn(), + getVersionMock: vi.fn(() => '1.4.191') + }) +) +vi.mock('../telemetry/client', () => ({ track: trackMock })) +vi.mock('node:fs', async (importOriginal) => ({ + ...(await importOriginal>()), + accessSync: accessSyncMock, + existsSync: existsSyncMock, + readFileSync: readFileSyncMock +})) +vi.mock('node:os', async (importOriginal) => ({ + ...(await importOriginal>()), + homedir: () => '/Users/alice' +})) +vi.mock('../../shared/app-environment', () => ({ + getAppEnvironment: () => ({ getVersion: getVersionMock }) +})) + +import { + classifyDaemonAdoptionOrigin, + trackDaemonAdopted, + trackDaemonPtyCwdDeniedIfDiverged +} from './daemon-adoption-telemetry-event' + +const stalePidRecord: ParsedDaemonPid = { + pid: 1530, + startedAtMs: 1, + entryPath: '/x/daemon-entry.js', + appVersion: '1.4.187', + launchNonce: 'n', + linuxStartTicks: null, + bootId: null, + spawnerExecPath: + '/Users/alice/Library/Caches/com.stablyai.orca.ShipIt/u/Orca.app/Contents/MacOS/Orca' +} +const origin = { app_version_match: 'different', spawner_path_class: 'updater-cache' } as const +const PID_PATH = '/fake/daemon.pid' + +beforeEach(() => { + trackMock.mockReset() + accessSyncMock.mockReset() + existsSyncMock.mockReset().mockReturnValue(true) + readFileSyncMock.mockReset().mockReturnValue(JSON.stringify(stalePidRecord)) + vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin') +}) + +afterEach(() => { + vi.restoreAllMocks() +}) + +describe('classifyDaemonAdoptionOrigin', () => { + it('compares the recorded app version and classifies the spawner path', () => { + expect(classifyDaemonAdoptionOrigin(stalePidRecord)).toEqual(origin) + expect(classifyDaemonAdoptionOrigin({ ...stalePidRecord, appVersion: '1.4.191' })).toEqual({ + app_version_match: 'same', + spawner_path_class: 'updater-cache' + }) + expect(classifyDaemonAdoptionOrigin(null)).toEqual({ + app_version_match: 'unknown', + spawner_path_class: 'unknown' + }) + }) +}) + +describe('trackDaemonAdopted', () => { + it('emits a validator-accepted payload', () => { + trackDaemonAdopted(stalePidRecord, 'intact', 7) + expect(trackMock).toHaveBeenCalledTimes(1) + const [name, props] = trackMock.mock.calls[0] + expect(name).toBe('daemon_adopted') + expect(props).toEqual({ + ...origin, + tcc_attribution: 'intact', + live_session_count_bucket: '6+' + }) + expect(validate('daemon_adopted', props).ok).toBe(true) + }) + + it('swallows a throwing telemetry client', () => { + trackMock.mockImplementationOnce(() => { + throw new Error('posthog exploded') + }) + expect(() => trackDaemonAdopted(null, 'unknown', null)).not.toThrow() + }) +}) + +describe('trackDaemonPtyCwdDeniedIfDiverged', () => { + it('emits only when the daemon was denied and the app can read the same cwd', () => { + trackDaemonPtyCwdDeniedIfDiverged('/Users/alice/Documents/repo', false, PID_PATH) + expect(accessSyncMock).toHaveBeenCalledWith('/Users/alice/Documents/repo', expect.any(Number)) + expect(trackMock).toHaveBeenCalledTimes(1) + const [name, props] = trackMock.mock.calls[0] + expect(name).toBe('daemon_pty_cwd_denied') + expect(props).toEqual({ cwd_class: 'documents', ...origin }) + expect(validate('daemon_pty_cwd_denied', props).ok).toBe(true) + }) + + // False positives would drown the signal this event exists to measure, so every + // non-divergent shape must stay silent. + it('stays silent when the daemon could read the cwd or did not report', () => { + trackDaemonPtyCwdDeniedIfDiverged('/Users/alice/Documents/repo', true, PID_PATH) + trackDaemonPtyCwdDeniedIfDiverged('/Users/alice/Documents/repo', undefined, PID_PATH) + trackDaemonPtyCwdDeniedIfDiverged(undefined, false, PID_PATH) + expect(accessSyncMock).not.toHaveBeenCalled() + expect(trackMock).not.toHaveBeenCalled() + }) + + it('stays silent when the app cannot read the cwd either (no divergence)', () => { + accessSyncMock.mockImplementation(() => { + throw Object.assign(new Error('EACCES'), { code: 'EACCES' }) + }) + trackDaemonPtyCwdDeniedIfDiverged('/Users/alice/Documents/repo', false, PID_PATH) + expect(trackMock).not.toHaveBeenCalled() + }) + + it('attributes the denial to the daemon recorded right now, not a startup snapshot', () => { + readFileSyncMock.mockReturnValue( + JSON.stringify({ + ...stalePidRecord, + appVersion: '1.4.191', + spawnerExecPath: '/Applications/Orca.app/Contents/MacOS/Orca' + }) + ) + trackDaemonPtyCwdDeniedIfDiverged('/Users/alice/Documents/repo', false, PID_PATH) + expect(readFileSyncMock).toHaveBeenCalledWith(PID_PATH, 'utf8') + expect(trackMock.mock.calls[0][1]).toEqual({ + cwd_class: 'documents', + app_version_match: 'same', + spawner_path_class: 'applications' + }) + }) + + it('swallows a throwing app environment or pid-record read instead of failing the spawn', () => { + getVersionMock.mockImplementationOnce(() => { + throw new Error('AppEnvironment not initialized') + }) + expect(() => + trackDaemonPtyCwdDeniedIfDiverged('/Users/alice/Documents/repo', false, PID_PATH) + ).not.toThrow() + expect(trackMock).not.toHaveBeenCalled() + }) + + it('stays silent off macOS', () => { + vi.spyOn(process, 'platform', 'get').mockReturnValue('linux') + trackDaemonPtyCwdDeniedIfDiverged('/home/alice/Documents/repo', false, PID_PATH) + expect(accessSyncMock).not.toHaveBeenCalled() + expect(trackMock).not.toHaveBeenCalled() + }) + + it('swallows a throwing telemetry client', () => { + trackMock.mockImplementationOnce(() => { + throw new Error('posthog exploded') + }) + expect(() => + trackDaemonPtyCwdDeniedIfDiverged('/Users/alice/Documents/repo', false, PID_PATH) + ).not.toThrow() + }) +}) diff --git a/src/main/daemon/daemon-adoption-telemetry-event.ts b/src/main/daemon/daemon-adoption-telemetry-event.ts new file mode 100644 index 00000000000..47f554bf9bb --- /dev/null +++ b/src/main/daemon/daemon-adoption-telemetry-event.ts @@ -0,0 +1,81 @@ +// App-side emitters for `daemon_adopted` and `daemon_pty_cwd_denied` (#17696). Both sit on the +// daemon launch / PTY spawn path, so every failure dies here — telemetry can never cost a terminal. + +import { accessSync, constants as fsConstants, existsSync } from 'node:fs' +import { homedir } from 'node:os' +import { getAppEnvironment } from '../../shared/app-environment' +import { + classifyDaemonPtyCwd, + classifyDaemonSpawnerPath, + type DaemonAdoptedAppVersionMatch, + type DaemonSpawnerPathClass +} from '../../shared/daemon-adoption-telemetry' +import { bucketDaemonLiveSessionCount } from '../../shared/daemon-lifecycle-telemetry' +import type { EventProps } from '../../shared/telemetry-events' +import { track } from '../telemetry/client' +import { readDaemonPidRecord } from './daemon-endpoint-incarnation' +import type { ParsedDaemonPid } from './daemon-pid-file-parse' +import type { MacDaemonTccAttributionHealth } from './daemon-tcc-attribution' + +export type DaemonAdoptionOrigin = Pick< + EventProps<'daemon_pty_cwd_denied'>, + 'app_version_match' | 'spawner_path_class' +> + +/** Classifies the adopted daemon's pid record against the running app; enum-only by construction. */ +export function classifyDaemonAdoptionOrigin( + pidRecord: ParsedDaemonPid | null +): DaemonAdoptionOrigin { + const appVersionMatch: DaemonAdoptedAppVersionMatch = !pidRecord?.appVersion + ? 'unknown' + : pidRecord.appVersion === getAppEnvironment().getVersion() + ? 'same' + : 'different' + const spawnerPathClass: DaemonSpawnerPathClass = classifyDaemonSpawnerPath( + pidRecord?.spawnerExecPath ?? null, + existsSync + ) + return { app_version_match: appVersionMatch, spawner_path_class: spawnerPathClass } +} + +// Adopted a daemon that a previous app launch forked (macOS only; that is where attribution matters). +export function trackDaemonAdopted( + pidRecord: ParsedDaemonPid | null, + tccAttribution: MacDaemonTccAttributionHealth, + liveSessionCount: number | null +): void { + try { + track('daemon_adopted', { + ...classifyDaemonAdoptionOrigin(pidRecord), + tcc_attribution: tccAttribution, + live_session_count_bucket: bucketDaemonLiveSessionCount(liveSessionCount) + }) + } catch { + // Telemetry is best-effort; a dropped event must not fail daemon adoption. + } +} + +/** + * Emits only on proven divergence: the daemon reported the cwd unreadable AND this process can + * read it. A cwd neither can read (chmod, ENOENT, unmounted volume) is not the #17696 shape. + */ +export function trackDaemonPtyCwdDeniedIfDiverged( + cwd: string | undefined, + cwdReadableByDaemon: boolean | undefined, + pidPath: string | null +): void { + try { + if (process.platform !== 'darwin' || !cwd || cwdReadableByDaemon !== false) { + return + } + accessSync(cwd, fsConstants.R_OK | fsConstants.X_OK) + // Why read now, not the adapter's startup snapshot: a respawn swaps the daemon under a + // long-lived adapter, and the denial must be attributed to the daemon that just spawned. + track('daemon_pty_cwd_denied', { + cwd_class: classifyDaemonPtyCwd(cwd, homedir()), + ...classifyDaemonAdoptionOrigin(readDaemonPidRecord(pidPath)) + }) + } catch { + // Either the app cannot read it (no divergence) or telemetry failed; neither may reach the caller. + } +} diff --git a/src/main/daemon/daemon-client-rpc-request.test.ts b/src/main/daemon/daemon-client-rpc-request.test.ts index b495ee07425..75d9d9edfd6 100644 --- a/src/main/daemon/daemon-client-rpc-request.test.ts +++ b/src/main/daemon/daemon-client-rpc-request.test.ts @@ -21,9 +21,66 @@ function addSiblingRequest(pendingRequests: DaemonPendingRequests, reject: () => describe('requestDaemonRpc', () => { afterEach(() => { + vi.restoreAllMocks() vi.useRealTimers() }) + it('keeps the daemon attach guard behind the client timeout window', async () => { + const pendingRequests = new DaemonPendingRequests() + const abort = new AbortController() + const write = vi.fn() + const request = requestDaemonRpc({ + socket: { write } as unknown as Socket, + pendingRequests, + id: 'req-1', + type: 'createOrAttach', + payload: { sessionId: 'guarded-spawn' }, + timeoutMs: 30_000, + signal: abort.signal, + unmatchedCancelGraceMs: 5_000, + onCreateCancellationFailure: vi.fn(), + settleCreateCancellation: vi.fn(async () => ({ canceled: true })) + }) + + expect(JSON.parse(String(write.mock.calls[0]?.[0]))).toMatchObject({ + payload: { sessionId: 'guarded-spawn', cancelAfterMs: 35_000 } + }) + abort.abort() + await expect(request).rejects.toThrow('client_disconnected') + }) + + it('classifies a cancellation delivered after an overdue timeout as a timeout', async () => { + vi.useFakeTimers() + const monotonicNow = vi.spyOn(performance, 'now').mockReturnValue(0) + const pendingRequests = new DaemonPendingRequests() + const settleCreateCancellation = vi.fn(() => new Promise<{ canceled: boolean }>(() => {})) + const request = requestDaemonRpc({ + socket: { write: vi.fn() } as unknown as Socket, + pendingRequests, + id: 'req-1', + type: 'createOrAttach', + payload: { sessionId: 'deadline-spawn' }, + timeoutMs: 10, + unmatchedCancelGraceMs: 5, + onCreateCancellationFailure: vi.fn(), + settleCreateCancellation + }) + const rejected = expect(request).rejects.toMatchObject({ + name: 'DaemonRequestTimeoutError', + message: 'Request createOrAttach timed out after 10ms' + }) + + monotonicNow.mockReturnValue(16) + pendingRequests.settle({ + id: 'req-1', + ok: false, + error: 'Attach canceled for session deadline-spawn' + }) + + await rejected + expect(settleCreateCancellation).not.toHaveBeenCalled() + }) + it('keeps a completed spawn result when cancellation arrives too late', async () => { const pendingRequests = new DaemonPendingRequests() const abort = new AbortController() diff --git a/src/main/daemon/daemon-client-rpc-request.ts b/src/main/daemon/daemon-client-rpc-request.ts index 33936a9286a..fb72538eaa0 100644 --- a/src/main/daemon/daemon-client-rpc-request.ts +++ b/src/main/daemon/daemon-client-rpc-request.ts @@ -54,20 +54,27 @@ function wedgedDaemonError(requestError: Error, cancelError: unknown): Error | n } export function requestDaemonRpc(opts: DaemonRpcRequestOptions): Promise { + // A stalled event loop can deliver a daemon cancellation before its overdue timer runs. const { payload, type } = opts + const createTimeoutError = (): DaemonRequestTimeoutError => + new DaemonRequestTimeoutError(`Request ${type} timed out after ${opts.timeoutMs}ms`) const createSessionId = type === 'createOrAttach' && payload !== null && typeof payload === 'object' ? Reflect.get(payload, 'sessionId') : null const requestPayload = type === 'createOrAttach' && payload !== null && typeof payload === 'object' - ? { ...payload, cancelAfterMs: Math.max(1, opts.timeoutMs - 100) } + ? { + ...payload, + cancelAfterMs: Math.max(1, opts.timeoutMs + opts.unmatchedCancelGraceMs) + } : payload const encoded = encodeNdjson({ id: opts.id, type, ...(requestPayload !== undefined ? { payload: requestPayload } : {}) }) + const clientDeadlineMs = performance.now() + opts.timeoutMs return new Promise((resolve, reject) => { let sent = false @@ -146,9 +153,7 @@ export function requestDaemonRpc(opts: DaemonRpcRequestOptions): Promise { }) } const timer = setTimeout(() => { - const error = new DaemonRequestTimeoutError( - `Request ${type} timed out after ${opts.timeoutMs}ms` - ) + const error = createTimeoutError() if (typeof createSessionId === 'string') { cancelCreate(error) } else { @@ -172,8 +177,11 @@ export function requestDaemonRpc(opts: DaemonRpcRequestOptions): Promise { removeAbortListener() clearTimers() reject( - cancellationError !== null && isTerminalAttachCanceledMessage(error.message) - ? cancellationError + isTerminalAttachCanceledMessage(error.message) + ? (cancellationError ?? + (type === 'createOrAttach' && performance.now() >= clientDeadlineMs + ? createTimeoutError() + : error)) : error ) }, diff --git a/src/main/daemon/daemon-create-or-attach-result.ts b/src/main/daemon/daemon-create-or-attach-result.ts index d6668342487..92a7e451a10 100644 --- a/src/main/daemon/daemon-create-or-attach-result.ts +++ b/src/main/daemon/daemon-create-or-attach-result.ts @@ -14,6 +14,12 @@ export type DaemonCreateOrAttachResult = { wslDistro?: string | null agentSessionEnsure?: AgentSessionClaimedSpawnResult incarnationId?: PtyIncarnationId + /** + * Whether the daemon process itself could read the requested cwd at spawn. Only the daemon's own + * verdict counts: macOS TCC scopes folder access per process tree, so the app's view of the same + * path proves nothing about the daemon's (#17696). Omitted by daemons predating this field. + */ + cwdReadableByDaemon?: boolean } export function getDaemonSessionResultMetadata(session: { diff --git a/src/main/daemon/daemon-host-relocation.ts b/src/main/daemon/daemon-host-relocation.ts index a7e8f2b6db2..6d94bea06e4 100644 --- a/src/main/daemon/daemon-host-relocation.ts +++ b/src/main/daemon/daemon-host-relocation.ts @@ -34,7 +34,7 @@ export type RelocatedDaemonHost = { const HOST_SUBDIR = 'daemon-host' const MARKER_NAME = '.materialized.json' -// LOCAL appData (not roaming) so OneDrive/roaming never syncs this ~260MB runtime. Shared with NSIS uninstall (config/nsis/daemon-host-uninstall.nsh) — keep in sync. +// LOCAL appData (not roaming) so OneDrive/roaming never syncs this ~260MB runtime. Shared with NSIS uninstall (config/nsis/orca-installer-hooks.nsh) — keep in sync. const LOCAL_HOST_ROOT_NAME = 'Orca' // Copy of Orca.exe renamed to a distinct image name so the NSIS updater's `taskkill /IM Orca.exe` can't match it. diff --git a/src/main/daemon/daemon-init-dependency-mocks.ts b/src/main/daemon/daemon-init-dependency-mocks.ts index d920a13866e..d7217d4df63 100644 --- a/src/main/daemon/daemon-init-dependency-mocks.ts +++ b/src/main/daemon/daemon-init-dependency-mocks.ts @@ -50,7 +50,8 @@ export function createDaemonInitModuleFactories(state: DaemonInitMockState) { unbindLocalProviderListenersMock, rebindLocalProviderListenersMock, trackDaemonReplacedMock, - trackDaemonRetiredMock + trackDaemonRetiredMock, + trackDaemonAdoptedMock } = state // Why: both fakes are annotated with constructor types so the exported factories widen to @@ -82,6 +83,9 @@ export function createDaemonInitModuleFactories(state: DaemonInitMockState) { if (result.mode) { this.handle.mode = result.mode } + if (result.adopted) { + this.handle.adopted = true + } return { socketPath: result.socketPath, tokenPath: result.tokenPath @@ -199,6 +203,9 @@ export function createDaemonInitModuleFactories(state: DaemonInitMockState) { trackDaemonReplaced: trackDaemonReplacedMock, trackDaemonRetired: trackDaemonRetiredMock }), + daemonAdoptionTelemetryEvent: () => ({ + trackDaemonAdopted: trackDaemonAdoptedMock + }), daemonSpawner: () => ({ DaemonSpawner: MockDaemonSpawner, getDaemonSocketPath: (_dir: string, version?: number) => diff --git a/src/main/daemon/daemon-init-fresh-import.ts b/src/main/daemon/daemon-init-fresh-import.ts index e1cc0416337..39f3625c063 100644 --- a/src/main/daemon/daemon-init-fresh-import.ts +++ b/src/main/daemon/daemon-init-fresh-import.ts @@ -41,7 +41,8 @@ export async function importFreshDaemonInit(state: DaemonInitMockState) { unbindLocalProviderListenersMock, rebindLocalProviderListenersMock, trackDaemonReplacedMock, - trackDaemonRetiredMock + trackDaemonRetiredMock, + trackDaemonAdoptedMock } = state vi.resetModules() @@ -64,6 +65,7 @@ export async function importFreshDaemonInit(state: DaemonInitMockState) { rebindLocalProviderListenersMock.mockClear() trackDaemonReplacedMock.mockClear() trackDaemonRetiredMock.mockClear() + trackDaemonAdoptedMock.mockClear() checkDaemonHealthMock.mockClear() checkDaemonHealthMock.mockResolvedValue('healthy') healthCheckDaemonMock.mockClear() diff --git a/src/main/daemon/daemon-init-mock-types.ts b/src/main/daemon/daemon-init-mock-types.ts index 8c8b805740f..341fcd26df7 100644 --- a/src/main/daemon/daemon-init-mock-types.ts +++ b/src/main/daemon/daemon-init-mock-types.ts @@ -47,6 +47,7 @@ export type MockAdapterConstructor = new (opts: MockAdapter['options']) => MockA /** Handle the fake spawner hands back from ensureRunning/getHandle. */ export type MockSpawnerHandle = { mode?: 'degraded-new-pty-fallback' + adopted?: true releaseAdoptionLease?: () => void shutdown: () => Promise } @@ -95,6 +96,7 @@ export type EnsureRunningOverride = () => Promise<{ socketPath: string tokenPath: string mode?: 'degraded-new-pty-fallback' + adopted?: true }> /** Every stub daemon-init's suites share, plus the control knobs they mutate per test. */ @@ -143,6 +145,7 @@ export type DaemonInitMockState = { rebindLocalProviderListenersMock: Mock<(...args: unknown[]) => void> trackDaemonReplacedMock: Mock<(...args: unknown[]) => void> trackDaemonRetiredMock: Mock<(...args: unknown[]) => void> + trackDaemonAdoptedMock: Mock<(...args: unknown[]) => void> } /** net.connect stubs the suites install in beforeEach. */ diff --git a/src/main/daemon/daemon-init-provider-installation.test.ts b/src/main/daemon/daemon-init-provider-installation.test.ts index ceb7e9dfa69..423ee9ee34f 100644 --- a/src/main/daemon/daemon-init-provider-installation.test.ts +++ b/src/main/daemon/daemon-init-provider-installation.test.ts @@ -2,6 +2,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const { isPackagedMock, + getMacDaemonTccAttributionHealthMock, + trackDaemonAdoptedMock, probeSocketExistsMock, readFileSyncMock, unlinkSyncMock, @@ -42,6 +44,7 @@ vi.mock('./daemon-process-start-time', () => moduleFactories.daemonProcessStartT vi.mock('./daemon-pid-file-parse', () => moduleFactories.daemonPidFileParse()) vi.mock('./client', () => moduleFactories.client()) vi.mock('./daemon-lifecycle-event', () => moduleFactories.daemonLifecycleEvent()) +vi.mock('./daemon-adoption-telemetry-event', () => moduleFactories.daemonAdoptionTelemetryEvent()) vi.mock('./daemon-spawner', () => moduleFactories.daemonSpawner()) vi.mock('./daemon-pty-adapter', () => moduleFactories.daemonPtyAdapter()) vi.mock('../ipc/pty', () => moduleFactories.ipcPty()) @@ -228,6 +231,48 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { expect(adapterInstances[1].disconnectOnly).toHaveBeenCalledOnce() }) + // #17696: adopting a daemon from an earlier app launch is invisible to daemon_lifecycle, so + // it gets its own event — macOS only, and only for adopted (not freshly forked) daemons. + it('reports a macOS daemon adoption with its TCC attribution and live session bucket', async () => { + vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin') + const mod = await importFresh() + ensureRunningOverrides.push(async () => ({ + socketPath: '/fake/adopted-socket', + tokenPath: '/fake/adopted-token', + adopted: true + })) + getMacDaemonTccAttributionHealthMock.mockResolvedValueOnce('severed') + defaultListSessionsSessions.push({ sessionId: 'wt-1@@a' }, { sessionId: 'wt-1@@b' }) + + await mod.initDaemonPtyProvider() + await vi.waitFor(() => expect(trackDaemonAdoptedMock).toHaveBeenCalledOnce()) + + // null pid record: the harness has no pid file, which the emitter classifies as 'unknown'. + expect(trackDaemonAdoptedMock).toHaveBeenCalledWith(null, 'severed', 2) + vi.restoreAllMocks() + }) + + it('does not report adoption for a freshly forked daemon or off macOS', async () => { + vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin') + const mod = await importFresh() + await mod.initDaemonPtyProvider() + await new Promise((resolve) => setImmediate(resolve)) + expect(trackDaemonAdoptedMock).not.toHaveBeenCalled() + vi.restoreAllMocks() + + vi.spyOn(process, 'platform', 'get').mockReturnValue('linux') + const linuxMod = await importFresh() + ensureRunningOverrides.push(async () => ({ + socketPath: '/fake/adopted-socket', + tokenPath: '/fake/adopted-token', + adopted: true + })) + await linuxMod.initDaemonPtyProvider() + await new Promise((resolve) => setImmediate(resolve)) + expect(trackDaemonAdoptedMock).not.toHaveBeenCalled() + vi.restoreAllMocks() + }) + it('routes fresh PTYs to the local fallback when a preserved daemon cannot spawn new PTYs', async () => { const mod = await importFresh() ensureRunningOverrides.push(async () => ({ diff --git a/src/main/daemon/daemon-init-test-harness.ts b/src/main/daemon/daemon-init-test-harness.ts index 6c38720a40b..6060ed9b759 100644 --- a/src/main/daemon/daemon-init-test-harness.ts +++ b/src/main/daemon/daemon-init-test-harness.ts @@ -156,6 +156,7 @@ function createDaemonInitMockState(): DaemonInitMockState { const rebindLocalProviderListenersMock = vi.fn() const trackDaemonReplacedMock = vi.fn() const trackDaemonRetiredMock = vi.fn() + const trackDaemonAdoptedMock = vi.fn() return { getPathMock, @@ -197,7 +198,8 @@ function createDaemonInitMockState(): DaemonInitMockState { unbindLocalProviderListenersMock, rebindLocalProviderListenersMock, trackDaemonReplacedMock, - trackDaemonRetiredMock + trackDaemonRetiredMock, + trackDaemonAdoptedMock } } diff --git a/src/main/daemon/daemon-out-of-process-launcher.ts b/src/main/daemon/daemon-out-of-process-launcher.ts index b59535a249a..21ff31918ac 100644 --- a/src/main/daemon/daemon-out-of-process-launcher.ts +++ b/src/main/daemon/daemon-out-of-process-launcher.ts @@ -41,6 +41,7 @@ function createPreservedDaemonHandle( mode?: 'degraded-new-pty-fallback' ): DaemonProcessHandle { const handle: DaemonProcessHandle = { + adopted: true, shutdown: async () => { await cleanupDaemonForProtocol(runtimeDir, protocolVersion) } diff --git a/src/main/daemon/daemon-provider-init.ts b/src/main/daemon/daemon-provider-init.ts index 1881e276e97..fa794257bda 100644 --- a/src/main/daemon/daemon-provider-init.ts +++ b/src/main/daemon/daemon-provider-init.ts @@ -24,7 +24,10 @@ import { import type { DaemonProvider } from './daemon-provider-routing' import { installDaemonProvider } from './daemon-provider-state' import { DegradedDaemonPtyProvider } from './degraded-daemon-pty-provider' +import { trackDaemonAdopted } from './daemon-adoption-telemetry-event' +import { readDaemonPidRecord } from './daemon-endpoint-incarnation' import { trackDaemonRetired } from './daemon-lifecycle-event' +import { getMacDaemonTccAttributionHealth } from './daemon-tcc-attribution' import { DaemonPtyAdapter } from './daemon-pty-adapter' import type { DaemonRespawnReason } from './daemon-pty-runtime-state' import { DaemonPtyRouter } from './daemon-pty-router' @@ -156,9 +159,37 @@ export async function initDaemonPtyProvider( logDaemonMilestone('daemon-init-done', { legacyAdapters: legacyAdapters.length }) + if (process.platform === 'darwin' && newSpawner.getHandle()?.adopted) { + void reportDaemonAdoption(runtimeDir, info.socketPath, info.tokenPath, newAdapter) + } await reconcileSeededClaudeLivePtys(routedAdapter) } +// Why off the init path: this is measurement of an adopted daemon (#17696), and neither its probes nor their failure may delay or fail startup. +async function reportDaemonAdoption( + runtimeDir: string, + socketPath: string, + tokenPath: string, + adapter: DaemonPtyAdapter +): Promise { + try { + const [tccAttribution, liveSessionCount] = await Promise.all([ + getMacDaemonTccAttributionHealth(runtimeDir, socketPath, tokenPath), + adapter.listSessions().then( + (sessions) => sessions.length, + () => null + ) + ]) + trackDaemonAdopted( + readDaemonPidRecord(getDaemonPidPath(runtimeDir)), + tccAttribution, + liveSessionCount + ) + } catch { + // Best-effort measurement only. + } +} + // Why: release gate ids only for daemon-confirmed-dead sessions; keep seeds on listing failure since releasing early can rotate a live CLI's refresh token. async function reconcileSeededClaudeLivePtys(provider: DaemonProvider): Promise { if (!hasSeededUnconfirmedClaudePtys()) { diff --git a/src/main/daemon/daemon-pty-session-spawn.ts b/src/main/daemon/daemon-pty-session-spawn.ts index 62c073f403e..235b286b0bc 100644 --- a/src/main/daemon/daemon-pty-session-spawn.ts +++ b/src/main/daemon/daemon-pty-session-spawn.ts @@ -4,6 +4,7 @@ import type { HistoryRecoveryContext, PendingDaemonSpawnOperation } from './daemon-pty-runtime-state' +import { trackDaemonPtyCwdDeniedIfDiverged } from './daemon-adoption-telemetry-event' import { STABLE_PANE_ATTACH_ONLY_DAEMON_PROTOCOL_VERSION } from './daemon-protocol-version' import { TerminalKilledError } from './daemon-pty-lifecycle-errors' import { DaemonPtySpawnResult } from './daemon-pty-spawn-result' @@ -246,6 +247,9 @@ export abstract class DaemonPtySessionSpawn extends DaemonPtySpawnResult { } activeSpawnContext = context const result = await this.createOrAttachSpawn(context, context.historySeedSegments) + if (result.isNew && !attachOnly) { + trackDaemonPtyCwdDeniedIfDiverged(effectiveCwd, result.cwdReadableByDaemon, this.pidPath) + } return this.finishSpawn(context, result) } diff --git a/src/main/daemon/daemon-spawner.ts b/src/main/daemon/daemon-spawner.ts index a0376ef0fc0..8c50b764b05 100644 --- a/src/main/daemon/daemon-spawner.ts +++ b/src/main/daemon/daemon-spawner.ts @@ -31,6 +31,8 @@ export type DaemonPidFile = { export type DaemonProcessHandle = { mode?: 'degraded-new-pty-fallback' + /** Set when the launcher kept a daemon some earlier app launch forked, rather than forking one. */ + adopted?: true releaseAdoptionLease?(): void shutdown(): Promise } diff --git a/src/main/daemon/daemon-terminal-admission.ts b/src/main/daemon/daemon-terminal-admission.ts index b47b497fe43..83dadf5f5d2 100644 --- a/src/main/daemon/daemon-terminal-admission.ts +++ b/src/main/daemon/daemon-terminal-admission.ts @@ -161,7 +161,10 @@ export class DaemonTerminalAdmission { ...(result.launchAgent ? { launchAgent: result.launchAgent } : {}), wslDistro: result.wslDistro, ...(result.historySeeded !== undefined ? { historySeeded: result.historySeeded } : {}), - ...(result.agentSessionEnsure ? { agentSessionEnsure: result.agentSessionEnsure } : {}) + ...(result.agentSessionEnsure ? { agentSessionEnsure: result.agentSessionEnsure } : {}), + ...(result.cwdReadableByDaemon !== undefined + ? { cwdReadableByDaemon: result.cwdReadableByDaemon } + : {}) } } diff --git a/src/main/daemon/terminal-host-create-contract.ts b/src/main/daemon/terminal-host-create-contract.ts index aaaffaeb8e8..42f5bf457f4 100644 --- a/src/main/daemon/terminal-host-create-contract.ts +++ b/src/main/daemon/terminal-host-create-contract.ts @@ -54,4 +54,6 @@ export type CreateOrAttachResult = { attachToken: symbol incarnationId: PtyIncarnationId agentSessionEnsure?: AgentSessionClaimedSpawnResult + /** Daemon-process verdict on the spawn cwd; only set on a fresh spawn that was given a cwd. */ + cwdReadableByDaemon?: boolean } diff --git a/src/main/daemon/terminal-host-cwd-readability.test.ts b/src/main/daemon/terminal-host-cwd-readability.test.ts new file mode 100644 index 00000000000..aa9e08379d7 --- /dev/null +++ b/src/main/daemon/terminal-host-cwd-readability.test.ts @@ -0,0 +1,75 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { SubprocessHandle } from './session-subprocess-handle' +import { TerminalHost, type TerminalHostOptions } from './terminal-host' + +vi.mock('../pty-descendant-termination', () => ({ killWithDescendantSweep: vi.fn() })) + +function createMockSubprocess(): SubprocessHandle { + let onExitCb: ((code: number) => void) | null = null + return { + pid: 99999, + getForegroundProcess: vi.fn(() => null), + write: vi.fn(), + resize: vi.fn(), + kill: vi.fn(() => { + setTimeout(() => onExitCb?.(0), 5) + }), + terminateOwnedTree: () => 'unavailable' as const, + forceKill: vi.fn(() => onExitCb?.(137)), + signal: vi.fn(), + onData() {}, + onExit(cb) { + onExitCb = cb + }, + dispose: vi.fn() + } +} + +// #17696: only the daemon process can say whether TCC lets it read the cwd, so its verdict +// rides on the create result. A non-permission failure must never read as denial. +describe('TerminalHost cwd readability verdict', () => { + let host: TerminalHost + let platformDescriptor: PropertyDescriptor | undefined + + beforeEach(() => { + platformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { configurable: true, value: 'linux' }) + const spawnSubprocess: TerminalHostOptions['spawnSubprocess'] = () => createMockSubprocess() + host = new TerminalHost({ spawnSubprocess }) + }) + + afterEach(async () => { + await host.dispose() + if (platformDescriptor) { + Object.defineProperty(process, 'platform', platformDescriptor) + } + }) + + const create = (sessionId: string, cwd?: string) => + host.createOrAttach({ + sessionId, + cols: 80, + rows: 24, + ...(cwd ? { cwd } : {}), + streamClient: { onData: vi.fn(), onExit: vi.fn() } + }) + + it('reports a readable cwd as readable', async () => { + expect((await create('readable', process.cwd())).cwdReadableByDaemon).toBe(true) + }) + + it('reports a missing cwd as readable — absence is not a permission denial', async () => { + expect((await create('missing', '/definitely/not/a/real/dir')).cwdReadableByDaemon).toBe(true) + }) + + it('omits the verdict when no cwd was requested', async () => { + expect((await create('no-cwd')).cwdReadableByDaemon).toBeUndefined() + }) + + it('omits the verdict on attach to an existing session', async () => { + await create('attach', process.cwd()) + const attached = await create('attach', process.cwd()) + expect(attached.isNew).toBe(false) + expect(attached.cwdReadableByDaemon).toBeUndefined() + }) +}) diff --git a/src/main/daemon/terminal-host-session-create.ts b/src/main/daemon/terminal-host-session-create.ts index 24f6cc2e867..9ee51c9968d 100644 --- a/src/main/daemon/terminal-host-session-create.ts +++ b/src/main/daemon/terminal-host-session-create.ts @@ -1,3 +1,4 @@ +import { accessSync, constants as fsConstants } from 'node:fs' import { buildStartupCommandSubmission } from '../../shared/startup-command-submission' import { resolvePtyOwnerBackend } from '../../shared/pty-owner-backend' import { getDaemonSessionResultMetadata } from './daemon-create-or-attach-result' @@ -88,6 +89,8 @@ async function spawnAndPublishSession( ctx: { size: { cols: number; rows: number }; wslDistro: string | undefined } ): Promise { const { size, wslDistro } = ctx + // Why before the fork: the shell's own cwd may already have fallen back, so probe the requested path. + const cwdReadableByDaemon = opts.cwd && !wslDistro ? isCwdReadableByThisProcess(opts.cwd) : null const subprocess = await deps.spawnSubprocess({ sessionId: opts.sessionId, cols: size.cols, @@ -150,7 +153,23 @@ async function spawnAndPublishSession( deps.onSessionCreated(opts.sessionId, opts.agentSessionGeneration, session.isAlive) const token = session.attachClient(opts.streamClient) - if (opts.command && !subprocess.startupCommandDeliveredInShellArgs) { + const startupCommandWritten = + Boolean(opts.command) && !subprocess.startupCommandDeliveredInShellArgs + // Why: without this, a missing command and a lost one log identically. + // Length, never the text -- launches can carry credentials. + try { + deps.reportReadinessEvent?.('startup-command-delivery', { + sessionId: opts.sessionId, + written: startupCommandWritten, + hasCommand: Boolean(opts.command), + commandLength: opts.command?.length ?? 0, + viaShellArgs: subprocess.startupCommandDeliveredInShellArgs === true, + queuedByShellReadyBarrier: shellReadySupported + }) + } catch { + // Diagnostics must never turn a live PTY into a failed create. + } + if (startupCommandWritten && opts.command) { const submit = process.platform === 'win32' ? '\r' : '\n' // Why: only Orca-wrapped shells advertise the paste-safe startup barrier. session.write( @@ -168,6 +187,20 @@ async function spawnAndPublishSession( shellState: session.shellState, incarnationId: session.incarnationId, ...getDaemonSessionResultMetadata(session), + ...(cwdReadableByDaemon !== null ? { cwdReadableByDaemon } : {}), attachToken: token } } + +// Why R_OK|X_OK: listing a directory needs read, and entering it needs search — both are what +// TCC withholds. A non-permission failure (ENOENT, ENOTDIR) reads as readable so it can never +// masquerade as a permission denial. +function isCwdReadableByThisProcess(cwd: string): boolean { + try { + accessSync(cwd, fsConstants.R_OK | fsConstants.X_OK) + return true + } catch (error) { + const code = (error as NodeJS.ErrnoException).code + return code !== 'EACCES' && code !== 'EPERM' + } +} diff --git a/src/main/daemon/terminal-host-startup.test.ts b/src/main/daemon/terminal-host-startup.test.ts index fafa34eedc0..e7b7c7b4c09 100644 --- a/src/main/daemon/terminal-host-startup.test.ts +++ b/src/main/daemon/terminal-host-startup.test.ts @@ -54,3 +54,79 @@ describe('TerminalHost startup command terminator', () => { expect(sub.write).toHaveBeenCalledWith(sent) }) }) + +// Why: a missing command and a lost one used to log identically. +describe('TerminalHost startup command delivery logging', () => { + let sub: SubprocessHandle + let events: { event: string; details: Record }[] + let host: TerminalHost + + beforeEach(() => { + sub = mockSubprocess() + events = [] + host = new TerminalHost({ + spawnSubprocess: () => sub, + reportReadinessEvent: (event, details) => events.push({ event, details }) + }) + }) + + const delivery = (): Record => + events.find((e) => e.event === 'startup-command-delivery')?.details ?? {} + + it('records a written startup command', async () => { + await host.createOrAttach({ + sessionId: 'delivery-written', + cols: 80, + rows: 24, + command: 'codex', + shellReadySupported: false, + streamClient: { onData: vi.fn(), onExit: vi.fn() } + }) + expect(delivery()).toMatchObject({ written: true, hasCommand: true, commandLength: 5 }) + }) + + it('records a session created with no startup command at all', async () => { + await host.createOrAttach({ + sessionId: 'delivery-none', + cols: 80, + rows: 24, + shellReadySupported: false, + streamClient: { onData: vi.fn(), onExit: vi.fn() } + }) + expect(delivery()).toMatchObject({ written: false, hasCommand: false, commandLength: 0 }) + expect(sub.write).not.toHaveBeenCalled() + }) + + it('never logs the command text, which can carry credentials', async () => { + await host.createOrAttach({ + sessionId: 'delivery-secret', + cols: 80, + rows: 24, + command: 'deploy --token=hunter2', + shellReadySupported: false, + streamClient: { onData: vi.fn(), onExit: vi.fn() } + }) + expect(JSON.stringify(delivery())).not.toContain('hunter2') + }) + + it('still delivers the command when the diagnostic sink throws', async () => { + host = new TerminalHost({ + spawnSubprocess: () => sub, + reportReadinessEvent: () => { + throw new Error('log sink unavailable') + } + }) + + await expect( + host.createOrAttach({ + sessionId: 'delivery-sink-failure', + cols: 80, + rows: 24, + command: 'codex', + shellReadySupported: false, + streamClient: { onData: vi.fn(), onExit: vi.fn() } + }) + ).resolves.toMatchObject({ isNew: true }) + expect(sub.write).toHaveBeenCalledWith(`codex${process.platform === 'win32' ? '\r' : '\n'}`) + }) +}) diff --git a/src/main/git/canonical-repo-key.test.ts b/src/main/git/canonical-repo-key.test.ts new file mode 100644 index 00000000000..87965bcaed6 --- /dev/null +++ b/src/main/git/canonical-repo-key.test.ts @@ -0,0 +1,78 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const gitExecFileAsyncMock = vi.hoisted(() => vi.fn()) + +vi.mock('./runner', async (importOriginal) => ({ + ...((await importOriginal()) as Record), + gitExecFileAsync: gitExecFileAsyncMock +})) + +import { + _resetCanonicalRepoKeyCacheForTests, + getCanonicalRepoKey, + readGitCommonDir +} from './canonical-repo-key' + +beforeEach(() => { + _resetCanonicalRepoKeyCacheForTests() + gitExecFileAsyncMock.mockReset() +}) + +afterEach(() => { + vi.restoreAllMocks() +}) + +describe('readGitCommonDir', () => { + it('reads the absolute answer modern Git gives', () => { + expect(readGitCommonDir('/repo/.git\n', '/repo/worktrees/a')).toBe('/repo/.git') + }) + + it('drops the flag Git older than 2.31 echoes back, and resolves the relative answer', () => { + // Without this every repository on such a host would answer `.git` and collide. + expect(readGitCommonDir('--path-format=absolute\n.git\n', '/repo')).toBe('/repo/.git') + }) + + it('resolves a WSL answer in Git execution space, not against the UNC path', () => { + expect(readGitCommonDir('.git\n', '//wsl$/Ubuntu/home/dev/repo')).toBe('/home/dev/repo/.git') + }) + + it('tolerates CRLF and blank lines', () => { + expect(readGitCommonDir('\r\n/repo/.git\r\n', '/repo')).toBe('/repo/.git') + }) + + it('returns undefined when Git printed nothing usable', () => { + expect(readGitCommonDir('\n', '/repo')).toBeUndefined() + }) +}) + +describe('getCanonicalRepoKey', () => { + it('gives every worktree of one repository the same key', async () => { + gitExecFileAsyncMock.mockResolvedValue({ stdout: '/repo/.git\n', stderr: '' }) + + await expect(getCanonicalRepoKey('/repo')).resolves.toBe('local::/repo/.git') + await expect(getCanonicalRepoKey('/repo/worktrees/a')).resolves.toBe('local::/repo/.git') + }) + + it('scopes the key to the execution host', async () => { + gitExecFileAsyncMock.mockResolvedValue({ stdout: '/home/dev/repo/.git\n', stderr: '' }) + + await expect( + getCanonicalRepoKey('//wsl$/Ubuntu/home/dev/repo', { wslDistro: 'Ubuntu' }) + ).resolves.toBe('wsl:Ubuntu::/home/dev/repo/.git') + }) + + it('caches so repeated arming costs no subprocess', async () => { + gitExecFileAsyncMock.mockResolvedValue({ stdout: '/repo/.git\n', stderr: '' }) + + await getCanonicalRepoKey('/repo') + await getCanonicalRepoKey('/repo') + + expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(1) + }) + + it('falls back to the caller path when Git cannot answer', async () => { + gitExecFileAsyncMock.mockRejectedValue(new Error('not a git repository')) + + await expect(getCanonicalRepoKey('/not-a-repo')).resolves.toBe('local::/not-a-repo') + }) +}) diff --git a/src/main/git/canonical-repo-key.ts b/src/main/git/canonical-repo-key.ts new file mode 100644 index 00000000000..1b06423bdc9 --- /dev/null +++ b/src/main/git/canonical-repo-key.ts @@ -0,0 +1,72 @@ +import { toWslExecutionSpace } from '../../shared/wsl-paths' +import { gitExecFileAsync } from './runner' +import { resolveRevParsePath } from './worktree-path-comparison' + +/** + * One repository on one execution host, named by its Git common dir. + * + * Shared by the fetch controller (which serializes fetches on it) and idle ref + * maintenance (which scopes all of its state to it), so both agree on what "the + * same repo" means across every worktree that points at it. + */ + +export type CanonicalRepoKeyOptions = { wslDistro?: string } + +const CACHE_MAX = 512 +const cache = new Map() + +/** + * Git < 2.31 ignores `--path-format=absolute`: it echoes the unrecognized flag, + * exits 0, and prints a relative `.git`. Taking the raw stdout there would give + * every repository on the host the same key. + */ +export function readGitCommonDir(stdout: string, repoPath: string): string | undefined { + const commonDir = stdout + .split('\n') + .map((line) => (line.endsWith('\r') ? line.slice(0, -1) : line)) + .findLast((line) => line.length > 0 && !line.startsWith('-')) + return commonDir ? resolveRevParsePath(toWslExecutionSpace(repoPath), commonDir) : undefined +} + +function remember(cacheKey: string, value: string): string { + cache.delete(cacheKey) + cache.set(cacheKey, value) + while (cache.size > CACHE_MAX) { + const oldest = cache.keys().next() + if (oldest.done) { + break + } + cache.delete(oldest.value) + } + return value +} + +/** `${runtimeKey}::${gitCommonDir}`, falling back to the caller's path. */ +export async function getCanonicalRepoKey( + repoPath: string, + options: CanonicalRepoKeyOptions = {} +): Promise { + const runtimeKey = options.wslDistro ? `wsl:${options.wslDistro}` : 'local' + const cacheKey = `${runtimeKey}::${repoPath}` + const cached = cache.get(cacheKey) + if (cached !== undefined) { + return remember(cacheKey, cached) + } + try { + const { stdout } = await gitExecFileAsync( + ['rev-parse', '--path-format=absolute', '--git-common-dir'], + { cwd: repoPath, ...options } + ) + const commonDir = readGitCommonDir(stdout, repoPath) + if (commonDir) { + return remember(cacheKey, `${runtimeKey}::${commonDir}`) + } + } catch { + // The caller path remains a safe serialization key when canonicalization fails. + } + return remember(cacheKey, cacheKey) +} + +export function _resetCanonicalRepoKeyCacheForTests(): void { + cache.clear() +} diff --git a/src/main/git/exact-ref-probe.ts b/src/main/git/exact-ref-probe.ts index 6b13cc718c5..96bb421b8e8 100644 --- a/src/main/git/exact-ref-probe.ts +++ b/src/main/git/exact-ref-probe.ts @@ -19,6 +19,8 @@ export type ExactRefProbeSetResult = { type ExactRefPresence = 'present' | 'absent' | 'unknown' const EXACT_REF_PROBE_CONCURRENCY = 8 +// SHA-1 and SHA-256 repositories both report a full object id here. +const OBJECT_ID_PATTERN = /^[0-9a-f]{40}(?:[0-9a-f]{24})?$/ export function isShowRefNoMatchError(error: unknown): boolean { const record = error && typeof error === 'object' ? (error as Record) : undefined @@ -126,3 +128,50 @@ export async function probeAnyExactRef( await Promise.all(Array.from({ length: workerCount }, () => probeNext())) return { found, unknown } } + +/** Runs Git with a stdin payload. Only hosts that can feed a child's stdin supply one. */ +export type ExactRefProbeStdinExec = ( + argv: string[], + options: ExactRefProbeExecOptions & { stdin: string } +) => Promise<{ stdout: string }> + +/** `cat-file --batch-check` reports every ref from one child, and reports a missing ref as data + * rather than a failed exit — so a batch stays as decidable as a per-ref `show-ref --verify`. + * A repo with many remotes otherwise pays one subprocess per remote on every conflict check. */ +export async function probeAnyExactRefBatched( + runGit: ExactRefProbeStdinExec, + refs: readonly string[], + options: ExactRefProbeExecOptions = {} +): Promise<{ found: boolean; unknown: boolean }> { + const uniqueRefs = [...new Set(refs)] + const safeRefs = uniqueRefs.filter((ref) => isSafeGitRefName(ref)) + if (safeRefs.length === 0) { + return { found: false, unknown: uniqueRefs.length > 0 } + } + let stdout: string + try { + ;({ stdout } = await runGit(['cat-file', '--batch-check'], { + ...options, + stdin: `${safeRefs.join('\n')}\n` + })) + } catch { + return { found: false, unknown: true } + } + const lines = stdout.split('\n').filter((line) => line.trim().length > 0) + // One line per input, in order; a short read means the batch never answered for the rest. + if (lines.length !== safeRefs.length) { + return { found: false, unknown: true } + } + let unknown = safeRefs.length !== uniqueRefs.length + for (const line of lines) { + const [head, type] = line.split(' ') + if (OBJECT_ID_PATTERN.test(head) && type !== undefined && type !== 'missing') { + return { found: true, unknown: false } + } + if (type !== 'missing') { + // `ambiguous`, or a spelling this Git reports differently; neither proves absence. + unknown = true + } + } + return { found: false, unknown } +} diff --git a/src/main/git/fork-remote-refspec.test.ts b/src/main/git/fork-remote-refspec.test.ts new file mode 100644 index 00000000000..3ac0f2fc3ef --- /dev/null +++ b/src/main/git/fork-remote-refspec.test.ts @@ -0,0 +1,219 @@ +import { describe, expect, it, vi, type Mock } from 'vitest' +import { + buildNarrowForkFetchRefspec, + ensureRemoteTracksBranchNarrowly, + getRemoteFetchRefspecs, + pruneUntrackedForkRemoteRefs, + removeStaleForkFetchRefspec, + wildcardForkFetchRefspec, + type GitExecFn +} from './fork-remote-refspec' + +type ExecMock = Mock + +const REPO = '/repo-root' + +// In-memory `remote..fetch`/`.tagOpt` config, mutated the way real `git config` would be. +function makeConfigExec(fetchByRemote: Record = {}): { + exec: ExecMock + tagOptByRemote: Record +} { + const tagOptByRemote: Record = {} + const exec = vi.fn(async (args: string[]) => { + const key = args[2] + if (args[0] === 'config' && args[1] === '--get-all' && key?.endsWith('.fetch')) { + const remoteName = key.slice('remote.'.length, -'.fetch'.length) + const values = fetchByRemote[remoteName] ?? [] + if (values.length === 0) { + throw new Error('key not found') + } + return { stdout: `${values.join('\n')}\n`, stderr: '' } + } + if (args[0] === 'config' && args[1] === '--unset-all' && key?.endsWith('.fetch')) { + const remoteName = key.slice('remote.'.length, -'.fetch'.length) + fetchByRemote[remoteName] = [] + return { stdout: '', stderr: '' } + } + if (args[0] === 'config' && args[1] === '--add' && key?.endsWith('.fetch')) { + const remoteName = key.slice('remote.'.length, -'.fetch'.length) + fetchByRemote[remoteName] = [...(fetchByRemote[remoteName] ?? []), args[3]!] + return { stdout: '', stderr: '' } + } + if (args[0] === 'config' && args[1]?.endsWith('.tagOpt')) { + const remoteName = args[1].slice('remote.'.length, -'.tagOpt'.length) + tagOptByRemote[remoteName] = args[2]! + return { stdout: '', stderr: '' } + } + return { stdout: '', stderr: '' } + }) + return { exec, tagOptByRemote } +} + +describe('buildNarrowForkFetchRefspec / wildcardForkFetchRefspec', () => { + it('builds the narrow (trailing-* suffixed) and wide refspec shapes', () => { + expect(buildNarrowForkFetchRefspec('fork', 'feature/fix')).toBe( + '+refs/heads/feature/fix*:refs/remotes/fork/feature/fix*' + ) + expect(wildcardForkFetchRefspec('fork')).toBe('+refs/heads/*:refs/remotes/fork/*') + }) +}) + +describe('getRemoteFetchRefspecs', () => { + it('returns [] when the remote has no configured refspec', async () => { + const { exec } = makeConfigExec() + await expect(getRemoteFetchRefspecs(exec, REPO, 'fork')).resolves.toEqual([]) + }) + + it('returns every configured refspec', async () => { + const { exec } = makeConfigExec({ + fork: ['+refs/heads/a:refs/remotes/fork/a', '+refs/heads/b:refs/remotes/fork/b'] + }) + await expect(getRemoteFetchRefspecs(exec, REPO, 'fork')).resolves.toEqual([ + '+refs/heads/a:refs/remotes/fork/a', + '+refs/heads/b:refs/remotes/fork/b' + ]) + }) +}) + +describe('ensureRemoteTracksBranchNarrowly', () => { + it('replaces the wide default refspec with a single trailing-* narrow one', async () => { + const { exec, tagOptByRemote } = makeConfigExec({ fork: [wildcardForkFetchRefspec('fork')] }) + + await ensureRemoteTracksBranchNarrowly(exec, REPO, 'fork', 'main') + + await expect(getRemoteFetchRefspecs(exec, REPO, 'fork')).resolves.toEqual([ + '+refs/heads/main*:refs/remotes/fork/main*' + ]) + expect(tagOptByRemote.fork).toBe('--no-tags') + }) + + it('adds a second branch alongside an already-narrow one instead of replacing it', async () => { + const { exec } = makeConfigExec({ fork: ['+refs/heads/main*:refs/remotes/fork/main*'] }) + + await ensureRemoteTracksBranchNarrowly(exec, REPO, 'fork', 'feature') + + await expect(getRemoteFetchRefspecs(exec, REPO, 'fork')).resolves.toEqual([ + '+refs/heads/main*:refs/remotes/fork/main*', + '+refs/heads/feature*:refs/remotes/fork/feature*' + ]) + }) + + it('is a no-op for a branch already narrowly tracked (idempotent)', async () => { + const { exec } = makeConfigExec({ fork: ['+refs/heads/main*:refs/remotes/fork/main*'] }) + + await ensureRemoteTracksBranchNarrowly(exec, REPO, 'fork', 'main') + + const addCalls = exec.mock.calls.filter(([args]) => args[1] === '--add') + expect(addCalls).toEqual([]) + }) + + it('replaces a stray literal (non-suffixed) entry for the same branch with the suffixed form', async () => { + const { exec } = makeConfigExec({ fork: ['+refs/heads/main:refs/remotes/fork/main'] }) + + await ensureRemoteTracksBranchNarrowly(exec, REPO, 'fork', 'main') + + await expect(getRemoteFetchRefspecs(exec, REPO, 'fork')).resolves.toEqual([ + '+refs/heads/main*:refs/remotes/fork/main*' + ]) + }) + + it('leaves a different branch entry untouched when replacing a stray literal', async () => { + const { exec } = makeConfigExec({ + fork: [ + '+refs/heads/main:refs/remotes/fork/main', + '+refs/heads/other*:refs/remotes/fork/other*' + ] + }) + + await ensureRemoteTracksBranchNarrowly(exec, REPO, 'fork', 'main') + + await expect(getRemoteFetchRefspecs(exec, REPO, 'fork')).resolves.toEqual([ + '+refs/heads/other*:refs/remotes/fork/other*', + '+refs/heads/main*:refs/remotes/fork/main*' + ]) + }) +}) + +describe('removeStaleForkFetchRefspec', () => { + it('drops only the refspec whose source matches the stale branch', async () => { + const { exec } = makeConfigExec({ + fork: ['+refs/heads/gone:refs/remotes/fork/gone', '+refs/heads/keep:refs/remotes/fork/keep'] + }) + + await expect(removeStaleForkFetchRefspec(exec, REPO, 'fork', 'gone')).resolves.toBe(true) + await expect(getRemoteFetchRefspecs(exec, REPO, 'fork')).resolves.toEqual([ + '+refs/heads/keep:refs/remotes/fork/keep' + ]) + }) + + it('returns false and changes nothing when the branch is not tracked', async () => { + const { exec } = makeConfigExec({ fork: ['+refs/heads/keep:refs/remotes/fork/keep'] }) + + await expect(removeStaleForkFetchRefspec(exec, REPO, 'fork', 'gone')).resolves.toBe(false) + await expect(getRemoteFetchRefspecs(exec, REPO, 'fork')).resolves.toEqual([ + '+refs/heads/keep:refs/remotes/fork/keep' + ]) + }) +}) + +describe('pruneUntrackedForkRemoteRefs', () => { + function makeRefsExec(refs: string[]): { exec: Mock; refs: string[] } { + const state = [...refs] + const exec = vi.fn(async (args: string[]) => { + if (args[0] === 'for-each-ref') { + const prefix = args[2]! + return { + stdout: state.map((r) => `${prefix}${r}`).join('\n') + (state.length ? '\n' : ''), + stderr: '' + } + } + if (args[0] === 'update-ref' && args[1] === '-d') { + const refname = args[2]! + const idx = state.findIndex((r) => refname.endsWith(`/${r}`)) + if (idx !== -1) { + state.splice(idx, 1) + } + return { stdout: '', stderr: '' } + } + return { stdout: '', stderr: '' } + }) + return { exec, refs: state } + } + + it('deletes tracking refs outside the keep set and leaves the rest', async () => { + const { exec, refs } = makeRefsExec(['main', 'unrelated-1', 'unrelated-2']) + + const deleted = await pruneUntrackedForkRemoteRefs(exec, REPO, 'fork', new Set(['main'])) + + expect(deleted.sort()).toEqual( + ['refs/remotes/fork/unrelated-1', 'refs/remotes/fork/unrelated-2'].sort() + ) + expect(refs).toEqual(['main']) + }) + + it('never deletes HEAD even if not in the keep set', async () => { + const { exec, refs } = makeRefsExec(['HEAD', 'main']) + + await pruneUntrackedForkRemoteRefs(exec, REPO, 'fork', new Set(['main'])) + + expect(refs).toEqual(['HEAD', 'main']) + }) + + it('is a no-op when nothing is stray', async () => { + const { exec, refs } = makeRefsExec(['main']) + + const deleted = await pruneUntrackedForkRemoteRefs(exec, REPO, 'fork', new Set(['main'])) + + expect(deleted).toEqual([]) + expect(refs).toEqual(['main']) + }) + + it("keeps a ref that shares a branch prefix, matching the refspec's own trailing-* match", async () => { + const { exec, refs } = makeRefsExec(['fix', 'fix-extra', 'unrelated']) + + const deleted = await pruneUntrackedForkRemoteRefs(exec, REPO, 'fork', new Set(['fix'])) + + expect(deleted).toEqual(['refs/remotes/fork/unrelated']) + expect(refs.sort()).toEqual(['fix', 'fix-extra']) + }) +}) diff --git a/src/main/git/fork-remote-refspec.ts b/src/main/git/fork-remote-refspec.ts new file mode 100644 index 00000000000..5bf53cfe7f0 --- /dev/null +++ b/src/main/git/fork-remote-refspec.ts @@ -0,0 +1,184 @@ +// Why: a fork-PR remote added with a bare `git remote add` writes the default +// `+refs/heads/*:refs/remotes//*` refspec, so any later plain `git fetch ` +// (user, agent, or Orca's own Fetch action) imports the fork's entire branch set -- +// a large fork can carry 1000+ branches. Every mint/reuse/migration path funnels +// through `ensureRemoteTracksBranchNarrowly` so a fork remote never tracks more than +// the branches Orca actually knows about (see #17828). +// +// The tracked-branch refspec source carries a trailing `*` (`refs/heads/*`) +// rather than being a literal exact match. This is deliberate: Orca is terminal-centric +// (agents run raw git in worktrees), and a *bare* `git fetch` inside a fork-PR worktree +// resolves to this remote via `branch..remote` -- it is the single most common +// fetch shape here, more common than `git fetch `. A literal refspec +// makes that fetch (and `git fetch `) hard-fail with `couldn't find remote ref` +// the moment the tracked branch is deleted/renamed upstream, where the old wide default +// silently no-op'd. A trailing `*` keeps git's wildcard zero-match tolerance (verified +// against real git: exit 0, and `--prune` correctly reclaims the ref once it can't be +// found) while still bounding the import to branches sharing that literal prefix -- +// not the fork's entire branch set. The residual widening (an unrelated sibling branch +// that happens to share the prefix, e.g. `fix` also matching `fix-v2`) is accepted as +// far narrower than the bug this fixes. +export type GitExecFn = ( + args: string[], + cwd: string +) => Promise<{ stdout: string; stderr?: string }> + +export function buildNarrowForkFetchRefspec(remoteName: string, branchName: string): string { + return `+refs/heads/${branchName}*:refs/remotes/${remoteName}/${branchName}*` +} + +export function wildcardForkFetchRefspec(remoteName: string): string { + return `+refs/heads/*:refs/remotes/${remoteName}/*` +} + +/** `[]` when the remote has no configured fetch refspec (or doesn't exist). */ +export async function getRemoteFetchRefspecs( + execGit: GitExecFn, + repoPath: string, + remoteName: string +): Promise { + try { + const { stdout } = await execGit( + ['config', '--get-all', `remote.${remoteName}.fetch`], + repoPath + ) + return stdout + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean) + } catch { + return [] + } +} + +function refspecSource(refspec: string): string { + return refspec.replace(/^\+/, '').split(':')[0]! +} + +/** + * True only if `remote..url` is actually set. Deliberately plumbing (`config --get`), + * not porcelain `git remote get-url` -- the latter falls back to echoing the remote *name* + * as a bogus "URL" when the section exists but has no url key (verified against real git), + * which would hide exactly the config-only ghost state this guards against (see + * `worktree-push-target-refspec-migration.ts`'s concurrent-removal race with #17842's + * reconciliation sweep). + */ +export async function remoteHasUrl( + execGit: GitExecFn, + repoPath: string, + remoteName: string +): Promise { + try { + await execGit(['config', '--get', `remote.${remoteName}.url`], repoPath) + return true + } catch { + return false + } +} + +/** + * Adds `branchName` to `remoteName`'s tracked set without dropping any other branch + * already tracked (a sibling worktree on the same fork may track a different branch -- + * see #17828 reuse-path discussion on why this widens rather than replaces). Replaces + * the wide default wildcard refspec outright since nothing should still depend on it. + * Also pins `tagOpt=--no-tags` so tags never auto-follow into the shared namespace. + */ +export async function ensureRemoteTracksBranchNarrowly( + execGit: GitExecFn, + repoPath: string, + remoteName: string, + branchName: string +): Promise { + const desired = buildNarrowForkFetchRefspec(remoteName, branchName) + const existing = await getRemoteFetchRefspecs(execGit, repoPath, remoteName) + if (!existing.includes(desired)) { + // Strip the wide default outright, and any stray literal (non-suffixed) entry for + // this exact branch -- e.g. a hand-edited config -- since it would shadow the same + // source prefix and defeats the point of the trailing `*`. + const literalForBranch = `refs/heads/${branchName}` + const toDrop = existing.includes(wildcardForkFetchRefspec(remoteName)) + ? existing + : existing.filter((refspec) => refspecSource(refspec) === literalForBranch) + if (toDrop.length > 0) { + const surviving = existing.filter((refspec) => !toDrop.includes(refspec)) + await execGit(['config', '--unset-all', `remote.${remoteName}.fetch`], repoPath) + for (const refspec of surviving) { + await execGit(['config', '--add', `remote.${remoteName}.fetch`, refspec], repoPath) + } + } + await execGit(['config', '--add', `remote.${remoteName}.fetch`, desired], repoPath) + } + await execGit(['config', `remote.${remoteName}.tagOpt`, '--no-tags'], repoPath) +} + +/** + * Removes every `remote..fetch` entry, leaving the remote pushable but importing + * nothing on a plain fetch. For a fork remote with no branch pinning it at all (no + * worktree metadata, no `branch.*.remote`/`.pushRemote` config), there's nothing to + * narrow *to* -- but leaving the wide default in place means the next plain fetch still + * re-imports the fork's entire branch set. See the migration sweep's caller for the + * provenance check gating when this is safe to call. + */ +export async function clearForkRemoteFetchRefspec( + execGit: GitExecFn, + repoPath: string, + remoteName: string +): Promise { + await execGit(['config', '--unset-all', `remote.${remoteName}.fetch`], repoPath).catch(() => {}) +} + +/** + * Deletes remote-tracking refs under `refs/remotes//` that fall outside + * `keepBranches`. Needed because migrating away from the old wide default leaves behind + * refs for every branch the earlier wide fetch already pulled in, and a plain fetch under + * the new narrow refspec never revisits (or reclaims) a branch outside its own prefix. + * A tracking ref is kept if its branch name equals, or starts with, an entry in + * `keepBranches` -- matching what `buildNarrowForkFetchRefspec`'s trailing `*` would also + * match, so this never deletes a ref the configured refspec will just re-fetch anyway. + * Returns the deleted ref names. Never touches `HEAD`. + */ +export async function pruneUntrackedForkRemoteRefs( + execGit: GitExecFn, + repoPath: string, + remoteName: string, + keepBranches: ReadonlySet +): Promise { + const prefix = `refs/remotes/${remoteName}/` + const { stdout } = await execGit(['for-each-ref', '--format=%(refname)', prefix], repoPath).catch( + () => ({ stdout: '' }) + ) + const deleted: string[] = [] + for (const refname of stdout + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean)) { + const branch = refname.slice(prefix.length) + const kept = branch === 'HEAD' || [...keepBranches].some((keep) => branch.startsWith(keep)) + if (kept) { + continue + } + await execGit(['update-ref', '-d', refname], repoPath) + deleted.push(refname) + } + return deleted +} + +/** Drops the one refspec whose source is `refs/heads/`, keeping the rest. */ +export async function removeStaleForkFetchRefspec( + execGit: GitExecFn, + repoPath: string, + remoteName: string, + staleBranchName: string +): Promise { + const existing = await getRemoteFetchRefspecs(execGit, repoPath, remoteName) + const staleSource = `refs/heads/${staleBranchName}` + const surviving = existing.filter((refspec) => refspecSource(refspec) !== staleSource) + if (surviving.length === existing.length) { + return false + } + await execGit(['config', '--unset-all', `remote.${remoteName}.fetch`], repoPath) + for (const refspec of surviving) { + await execGit(['config', '--add', `remote.${remoteName}.fetch`, refspec], repoPath) + } + return true +} diff --git a/src/main/git/fork-remote-stale-branch-refspec.test.ts b/src/main/git/fork-remote-stale-branch-refspec.test.ts new file mode 100644 index 00000000000..50cb4ee445b --- /dev/null +++ b/src/main/git/fork-remote-stale-branch-refspec.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it, vi } from 'vitest' +import type { GitExecFn } from './fork-remote-refspec' +import { + fetchForkRemoteWithStaleRefspecRepair, + parseMissingForkBranchRef +} from './fork-remote-stale-branch-refspec' + +describe('parseMissingForkBranchRef', () => { + it('extracts the missing ref from a "couldn\'t find remote ref" fatal', () => { + expect(parseMissingForkBranchRef("fatal: couldn't find remote ref refs/heads/gone\n")).toBe( + 'refs/heads/gone' + ) + }) + + it('returns null for unrelated stderr', () => { + expect(parseMissingForkBranchRef('fatal: Authentication failed\n')).toBeNull() + }) +}) + +function makeExec(getAllStdout: string): GitExecFn { + return vi.fn(async (args: string[]) => { + if (args[1] === '--get-all') { + return { stdout: getAllStdout, stderr: '' } + } + return { stdout: '', stderr: '' } + }) +} + +describe('fetchForkRemoteWithStaleRefspecRepair', () => { + it('retries once after dropping a stale refspec, and returns on success', async () => { + const exec = makeExec( + '+refs/heads/gone:refs/remotes/fork/gone\n+refs/heads/keep:refs/remotes/fork/keep\n' + ) + let attempts = 0 + const runFetch = vi.fn(async () => { + attempts += 1 + if (attempts === 1) { + throw Object.assign(new Error('boom'), { + stderr: "fatal: couldn't find remote ref refs/heads/gone\n" + }) + } + }) + + await fetchForkRemoteWithStaleRefspecRepair(exec, '/repo', 'fork', runFetch) + + expect(runFetch).toHaveBeenCalledTimes(2) + }) + + it('rethrows immediately when the error is not a stale-refspec failure', async () => { + const exec = makeExec('+refs/heads/keep:refs/remotes/fork/keep\n') + const runFetch = vi.fn(async () => { + throw new Error('network unreachable') + }) + + await expect( + fetchForkRemoteWithStaleRefspecRepair(exec, '/repo', 'fork', runFetch) + ).rejects.toThrow('network unreachable') + expect(runFetch).toHaveBeenCalledTimes(1) + }) + + it('rethrows when the reported stale ref is not actually a tracked refspec (fail safe, no infinite loop)', async () => { + const exec = makeExec('+refs/heads/keep:refs/remotes/fork/keep\n') + const runFetch = vi.fn(async () => { + throw Object.assign(new Error('boom'), { + stderr: "fatal: couldn't find remote ref refs/heads/not-tracked\n" + }) + }) + + await expect( + fetchForkRemoteWithStaleRefspecRepair(exec, '/repo', 'fork', runFetch) + ).rejects.toThrow('boom') + expect(runFetch).toHaveBeenCalledTimes(1) + }) + + it('resolves multiple stale refspecs across repeated attempts', async () => { + let refspecs = [ + '+refs/heads/gone-a:refs/remotes/fork/gone-a', + '+refs/heads/gone-b:refs/remotes/fork/gone-b', + '+refs/heads/keep:refs/remotes/fork/keep' + ] + const exec = vi.fn(async (args: string[]) => { + if (args[1] === '--get-all') { + return { stdout: refspecs.length ? `${refspecs.join('\n')}\n` : '', stderr: '' } + } + if (args[1] === '--unset-all') { + refspecs = [] + return { stdout: '', stderr: '' } + } + if (args[1] === '--add') { + refspecs.push(args[3]!) + return { stdout: '', stderr: '' } + } + return { stdout: '', stderr: '' } + }) + // Why: mirrors real git reporting exactly one missing ref per fetch attempt (see #17828 PR). + const staleBranches = ['refs/heads/gone-a', 'refs/heads/gone-b'] + const runFetch = vi.fn(async () => { + const stillStale = staleBranches.find((branch) => refspecs.some((r) => r.includes(branch))) + if (stillStale) { + throw Object.assign(new Error('boom'), { + stderr: `fatal: couldn't find remote ref ${stillStale}\n` + }) + } + }) + + await fetchForkRemoteWithStaleRefspecRepair(exec, '/repo', 'fork', runFetch) + + expect(runFetch).toHaveBeenCalledTimes(3) + expect(refspecs).toEqual(['+refs/heads/keep:refs/remotes/fork/keep']) + }) +}) diff --git a/src/main/git/fork-remote-stale-branch-refspec.ts b/src/main/git/fork-remote-stale-branch-refspec.ts new file mode 100644 index 00000000000..992e7c28f69 --- /dev/null +++ b/src/main/git/fork-remote-stale-branch-refspec.ts @@ -0,0 +1,50 @@ +// Why: Orca's fork-remote refspecs (#17828) carry a trailing `*` specifically so a +// deleted/renamed upstream branch degrades to a silent zero-match fetch, not a hard +// failure -- see `buildNarrowForkFetchRefspec`'s comment. That is now the primary +// defense. This wrapper is a cheap backstop for the one case the `*` doesn't cover: a +// truly literal (non-wildcard) `refs/heads/` refspec somehow ends up configured +// (hand-edited `.git/config`, a future regression, etc), which -- unlike a wide refspec's +// silently-skipped wildcards -- exits nonzero with "couldn't find remote ref" and fetches +// NOTHING for that remote, not even other branches it also tracks. +import { extractExecError } from './exec-error' +import { removeStaleForkFetchRefspec, type GitExecFn } from './fork-remote-refspec' + +const MISSING_REMOTE_REF_PATTERN = /couldn't find remote ref (refs\/heads\/\S+)/ + +export function parseMissingForkBranchRef(stderr: string): string | null { + return MISSING_REMOTE_REF_PATTERN.exec(stderr)?.[1] ?? null +} + +// Bounds the repair loop; git reports one missing ref per fetch attempt (see #17828 PR +// description), so this comfortably covers even a fork remote tracking many stale branches. +const MAX_STALE_REFSPEC_REPAIR_ATTEMPTS = 20 + +/** + * Runs `runFetch`, and on a "couldn't find remote ref" failure for a refspec this remote + * tracks, drops that one stale refspec and retries -- so a branch deleted upstream degrades + * to "no longer updates for that branch" instead of "fetch fails for the whole remote". + */ +export async function fetchForkRemoteWithStaleRefspecRepair( + execGit: GitExecFn, + repoPath: string, + remoteName: string, + runFetch: () => Promise +): Promise { + for (let attempt = 0; attempt < MAX_STALE_REFSPEC_REPAIR_ATTEMPTS; attempt += 1) { + try { + await runFetch() + return + } catch (error) { + const staleRef = parseMissingForkBranchRef(extractExecError(error).stderr) + const staleBranch = staleRef?.replace(/^refs\/heads\//, '') + if ( + !staleBranch || + !(await removeStaleForkFetchRefspec(execGit, repoPath, remoteName, staleBranch)) + ) { + throw error + } + // loop: retry now that the dead refspec is gone + } + } + throw new Error(`Exceeded stale fork-remote refspec repair attempts for "${remoteName}"`) +} diff --git a/src/main/git/local-repo-ref-maintenance.test.ts b/src/main/git/local-repo-ref-maintenance.test.ts new file mode 100644 index 00000000000..f6a411733c3 --- /dev/null +++ b/src/main/git/local-repo-ref-maintenance.test.ts @@ -0,0 +1,182 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const gitExecFileAsyncMock = vi.hoisted(() => vi.fn()) +const readRepoCommonDirFromGitMock = vi.hoisted(() => vi.fn()) + +vi.mock('./runner', async (importOriginal) => ({ + ...((await importOriginal()) as Record), + gitExecFileAsync: gitExecFileAsyncMock +})) + +vi.mock('./worktree-list-reader', async (importOriginal) => ({ + ...((await importOriginal()) as Record), + readRepoCommonDirFromGit: readRepoCommonDirFromGitMock +})) + +import { _resetCanonicalRepoKeyCacheForTests } from './canonical-repo-key' +import { + _resetLocalRepoRefMaintenanceForTests, + armLocalRepoRefMaintenance, + createLocalRepoRefMaintenanceTarget, + getLocalRepoRefMaintenance, + setRepoMaintenanceActivityProbe, + withRepoRefMaintenancePaused +} from './local-repo-ref-maintenance' + +const NO_ABORT = new AbortController().signal + +function target(wslDistro?: string): ReturnType { + return createLocalRepoRefMaintenanceTarget({ + key: 'local::/repo/.git', + repoPath: wslDistro ? '//wsl$/Ubuntu/home/dev/repo' : '/repo', + ...(wslDistro ? { wslDistro } : {}) + }) +} + +beforeEach(() => { + gitExecFileAsyncMock.mockReset() + readRepoCommonDirFromGitMock.mockReset() + delete process.env.ORCA_DISABLE_REPO_REF_MAINTENANCE + _resetCanonicalRepoKeyCacheForTests() + _resetLocalRepoRefMaintenanceForTests() +}) + +afterEach(() => { + delete process.env.ORCA_DISABLE_REPO_REF_MAINTENANCE + _resetLocalRepoRefMaintenanceForTests() + vi.restoreAllMocks() +}) + +describe('local repo ref maintenance target', () => { + it('never hands the pack child an abort signal', async () => { + // Killing a `pack-refs` strands a `refs/**` lock about one time in five, and + // on Windows a force-kill inside the rewrite strands `packed-refs.lock` + // every time. The child must always be allowed to finish. + readRepoCommonDirFromGitMock.mockResolvedValue('/repo/.git') + gitExecFileAsyncMock.mockResolvedValue({ stdout: '', stderr: '' }) + + await target().packRefs({ setHeld: () => {} }) + + const packCall = gitExecFileAsyncMock.mock.calls.find( + ([argv]) => (argv as string[])[0] === 'pack-refs' + ) + expect(packCall?.[1]).not.toHaveProperty('signal') + }) + + it('runs pack-refs at the background tier with a long deadline', async () => { + gitExecFileAsyncMock.mockResolvedValue({ stdout: '', stderr: '' }) + + await target().packRefs({ setHeld: () => {} }) + + expect(gitExecFileAsyncMock).toHaveBeenCalledWith( + ['pack-refs', '--all', '--prune'], + expect.objectContaining({ cwd: '/repo', admissionTier: 'background', timeout: 15 * 60_000 }) + ) + }) + + it('reads either Git auto-maintenance opt-out, and unset keys as consent', async () => { + for (const stdout of [ + 'maintenance.auto false\n', + 'gc.auto 0\n', + 'gc.auto 6700\nmaintenance.auto false\n' + ]) { + gitExecFileAsyncMock.mockResolvedValue({ stdout, stderr: '' }) + await expect(target().isOptedOut?.(NO_ABORT)).resolves.toBe(true) + } + + gitExecFileAsyncMock.mockResolvedValue({ + stdout: 'maintenance.auto true\ngc.auto 6700\n', + stderr: '' + }) + await expect(target().isOptedOut?.(NO_ABORT)).resolves.toBe(false) + + // `git config --get-regexp` exits non-zero when nothing matches. + gitExecFileAsyncMock.mockRejectedValue(new Error('exit 1')) + await expect(target().isOptedOut?.(NO_ABORT)).resolves.toBe(false) + }) + + it('walks the POSIX refs directory for a native repo', async () => { + readRepoCommonDirFromGitMock.mockResolvedValue('/repo/.git') + + await expect(target().resolveRefsDirectory(NO_ABORT)).resolves.toBe('/repo/.git/refs') + }) + + it('translates a WSL repo answer back to the UNC path the main process can open', async () => { + // Git answers in its own execution space, which for WSL is a Linux path. + readRepoCommonDirFromGitMock.mockResolvedValue('/home/dev/repo/.git') + + await expect(target('Ubuntu').resolveRefsDirectory(NO_ABORT)).resolves.toBe( + '\\\\wsl.localhost\\Ubuntu\\home\\dev\\repo\\.git\\refs' + ) + }) + + it('reports an unresolvable repository rather than guessing a path', async () => { + readRepoCommonDirFromGitMock.mockResolvedValue(undefined) + + await expect(target().resolveRefsDirectory(NO_ABORT)).resolves.toBeUndefined() + }) +}) + +describe('local repo ref maintenance scheduling', () => { + it('schedules nothing when the kill switch is set', () => { + process.env.ORCA_DISABLE_REPO_REF_MAINTENANCE = '1' + const arm = vi.spyOn(getLocalRepoRefMaintenance(), 'arm') + + armLocalRepoRefMaintenance({ key: 'local::/repo/.git', repoPath: '/repo' }) + + expect(arm).not.toHaveBeenCalled() + }) + + it('arms through the shared single-flight instance otherwise', () => { + const arm = vi.spyOn(getLocalRepoRefMaintenance(), 'arm') + + armLocalRepoRefMaintenance({ key: 'local::/repo/.git', repoPath: '/repo' }) + + expect(arm).toHaveBeenCalledTimes(1) + }) + + it('is free when nothing has ever been armed', async () => { + // The common case by far: no timers, no instance, no reason to pay anything. + await expect(withRepoRefMaintenancePaused('git-fetch', async () => 'done')).resolves.toBe( + 'done' + ) + }) + + it('holds the window shut for the duration of ref-touching work', async () => { + readRepoCommonDirFromGitMock.mockResolvedValue('/repo/.git') + _resetLocalRepoRefMaintenanceForTests({ quietPeriodMs: 1, looseRefThreshold: 0 }) + setRepoMaintenanceActivityProbe(() => false) + const maintenance = getLocalRepoRefMaintenance() + const packRefs = vi.fn(async () => {}) + maintenance.arm({ + key: 'local::/repo/.git', + resolveRefsDirectory: async () => '/repo/.git/refs', + packRefs + }) + + await withRepoRefMaintenancePaused('branch-delete', async () => { + await new Promise((resolve) => setTimeout(resolve, 25)) + expect(packRefs).not.toHaveBeenCalled() + }) + + await vi.waitFor(() => expect(packRefs).toHaveBeenCalledTimes(1)) + }) + + it('routes the app activity probe into the shared instance', async () => { + readRepoCommonDirFromGitMock.mockResolvedValue('/repo/.git') + let busy = true + setRepoMaintenanceActivityProbe(() => busy) + const maintenance = getLocalRepoRefMaintenance() + const packRefs = vi.fn(async () => {}) + + maintenance.arm({ + key: 'local::/repo/.git', + resolveRefsDirectory: async () => '/repo/.git/refs', + packRefs + }) + await maintenance.whenAttemptSettled() + + expect(packRefs).not.toHaveBeenCalled() + busy = false + }) +}) diff --git a/src/main/git/local-repo-ref-maintenance.ts b/src/main/git/local-repo-ref-maintenance.ts new file mode 100644 index 00000000000..e84f7d1ae30 --- /dev/null +++ b/src/main/git/local-repo-ref-maintenance.ts @@ -0,0 +1,272 @@ +import { posix, win32 } from 'node:path' +import { isWindowsAbsolutePathLike } from '../../shared/cross-platform-path' +import { RepoRefMaintenance } from '../../shared/repo-ref-maintenance' +import { + PACK_REFS_ARGS, + PACK_REFS_TIMEOUT_MS, + RefMaintenanceRepoLocked, + type PackedRefsLockReporter, + type RepoRefMaintenanceOptions, + type RepoRefMaintenanceTarget +} from '../../shared/repo-ref-maintenance-policy' +import { isWslUncPath, toWindowsWslPath } from '../../shared/wsl-paths' +import { withSpan } from '../observability/tracer' +import { PackRefsLockOwnership } from './pack-refs-lock-ownership' +import { gitExecFileAsync } from './runner' +import { readRepoCommonDirFromGit } from './worktree-list-reader' + +/** + * Main-process wiring for idle loose-ref packing on the local execution host + * (native and WSL). + * + * SSH-hosted repos are deliberately out of scope: the execution host owns + * anything that touches execution, so maintaining them means running host-side + * on the relay, which today has neither admission control nor spans. Keying all + * state by execution host is what keeps this path from reaching across. + */ + +export type RepoMaintenanceActivityProbe = () => boolean + +const REPO_BUSY_PROBE_MAX = 64 + +let activityProbe: RepoMaintenanceActivityProbe | null = null +let shared: RepoRefMaintenance | null = null +// Why keyed here rather than captured in the target: a repo can be armed from +// the fetch controller or from a user-initiated fetch, and every arming must see +// the same "this repo has work in flight" answer, not whichever closure was last. +const repoBusyProbes = new Map boolean>() + +/** Register the owner of "this repo has a fetch in flight" for `key`. */ +export function setRepoRefMaintenanceBusyProbe(key: string, probe: () => boolean): void { + repoBusyProbes.delete(key) + repoBusyProbes.set(key, probe) + while (repoBusyProbes.size > REPO_BUSY_PROBE_MAX) { + const oldest = repoBusyProbes.keys().next() + if (oldest.done) { + break + } + repoBusyProbes.delete(oldest.value) + } +} + +/** + * Register the app-wide "do not start maintenance now" signal. Owned by the + * main entry point because the inputs (live agents, battery, quit) are not + * visible from the git layer. + */ +export function setRepoMaintenanceActivityProbe(probe: RepoMaintenanceActivityProbe | null): void { + activityProbe = probe +} + +/** Support escape hatch: kills the sweep without touching the user's git config. */ +function isDisabled(): boolean { + return process.env.ORCA_DISABLE_REPO_REF_MAINTENANCE === '1' +} + +function localMaintenanceOptions(): RepoRefMaintenanceOptions { + return { + // Fail closed: without the app-level gate installed we cannot see agents, + // creates, or battery, and running blind is worse than not running. + isBusy: () => activityProbe?.() ?? true, + observe: (attempt) => + withSpan('repo.ref_maintenance', (span) => attempt(span), { + attributes: { kind: 'git', 'repo.maintenance_host': 'local' } + }), + onError: (error) => { + console.warn('[repo-ref-maintenance] attempt failed:', error) + } + } +} + +export function getLocalRepoRefMaintenance(): RepoRefMaintenance { + shared ??= new RepoRefMaintenance(localMaintenanceOptions()) + return shared +} + +/** + * Cancels every armed timer and waits out any `packed-refs` rewrite in progress. + * + * Deliberately does not kill the child. A pack orphaned by the app quitting + * finishes on its own; a pack signalled mid-prune strands a ref lock about one + * time in five, and on Windows a force-kill inside the rewrite strands + * `packed-refs.lock` every time -- which blocks every later ref deletion. + */ +export function disposeLocalRepoRefMaintenance(): Promise { + const settling = shared?.awaitPackedRefsLockRelease() ?? Promise.resolve() + shared?.dispose() + shared = null + repoBusyProbes.clear() + return settling +} + +/** + * Hold every repository open while `run` touches refs. + * + * A ref deletion needs `packed-refs.lock`, which a running pack holds only while + * it rewrites the file -- 0.03-1.37s of a 23-32s run. Waiting that out turns the + * collision into a short pause. Cancelling the pack instead would strand a + * `refs/**` lock about one time in five, which Git never clears, so the ref + * stays undeletable indefinitely. + */ +export async function withRepoRefMaintenancePaused( + reason: string, + run: () => Promise +): Promise { + // Taken unconditionally rather than only when something is already armed: a + // fetch inside `run` can arm the sweep, and one counter bump against an idle + // instance costs a microtask. This can rebuild the instance after the + // quit-time dispose; harmless, because a fresh one has no armed timers and its + // activity probe is gone, so it fails closed. + const release = await getLocalRepoRefMaintenance().pause(reason) + try { + return await run() + } finally { + release() + } +} + +/** Wait out a `packed-refs` rewrite without holding the window open. For shutdown. */ +export function awaitPackedRefsLockRelease(): Promise { + return shared ? shared.awaitPackedRefsLockRelease() : Promise.resolve() +} + +/** + * Count user-initiated ref work as activity and restart every armed countdown. + * + * Deliberately not keyed to a repo: resolving one would cost a `rev-parse` on a + * path the user is waiting on, and a manual fetch or pull says the user is at + * the keyboard, which is a reason to defer every repository. + */ +export function postponeRepoRefMaintenance(): void { + shared?.postponeAll() +} + +/** `overrides` preseeds the shared instance so a test can shorten the quiet period. */ +export function _resetLocalRepoRefMaintenanceForTests( + overrides?: Partial +): void { + shared?.dispose() + shared = overrides ? new RepoRefMaintenance({ ...localMaintenanceOptions(), ...overrides }) : null + activityProbe = null + repoBusyProbes.clear() +} + +/** + * Git reports the common dir in its own execution space, so a WSL repo answers + * with a Linux path the Windows main process cannot open. Translate it back to + * the UNC spelling for the dirent walk; the walk reads directories, not files, + * so the handful of round trips stays cheap even over the share. + */ +function refsDirectoryForMainProcess(commonDir: string, wslDistro: string | undefined): string { + if (wslDistro && !isWslUncPath(commonDir) && !isWindowsAbsolutePathLike(commonDir)) { + return win32.join(toWindowsWslPath(commonDir, wslDistro), 'refs') + } + // Decided by path syntax, not by platform: `win32.isAbsolute` accepts POSIX paths too. + return (isWindowsAbsolutePathLike(commonDir) ? win32 : posix).join(commonDir, 'refs') +} + +/** + * `maintenance.auto=false` and `gc.auto=0` are the two knobs a user reaches for + * to tell Git to stop maintaining a repository on its own. Orca sets both on its + * own fetches, but only as per-invocation `-c` flags, so this probe sees the + * user's persisted config and never Orca's own suppression. + */ +export function isGitAutoMaintenanceDisabled(configOutput: string): boolean { + return configOutput + .split('\n') + .map((line) => line.trim()) + .some((line) => line === 'maintenance.auto false' || line === 'gc.auto 0') +} + +/** + * The common dir in the spelling the main process can open. + * + * Derived from the converted refs path, not the raw one: a WSL answer arrives as + * a Linux path but converts to a UNC path with no `/` in it, so choosing the + * path flavour before conversion collapses the whole thing to `.`. + */ +function gitCommonDirForMainProcess(commonDir: string, wslDistro: string | undefined): string { + const refs = refsDirectoryForMainProcess(commonDir, wslDistro) + return (isWindowsAbsolutePathLike(refs) ? win32 : posix).dirname(refs) +} + +export type LocalRepoRefMaintenanceTargetArgs = { + /** `${runtimeKey}::${gitCommonDir}` -- already scoped to the execution host. */ + readonly key: string + readonly repoPath: string + readonly wslDistro?: string +} + +/** + * Record a write to this repo and restart its quiet-period countdown. The only + * entry point callers need: the kill switch is honoured before anything is + * scheduled, so a disabled build arms no timers at all. + */ +export function armLocalRepoRefMaintenance(args: LocalRepoRefMaintenanceTargetArgs): void { + if (isDisabled()) { + return + } + getLocalRepoRefMaintenance().arm(createLocalRepoRefMaintenanceTarget(args)) +} + +export function createLocalRepoRefMaintenanceTarget( + args: LocalRepoRefMaintenanceTargetArgs +): RepoRefMaintenanceTarget { + const gitOptions = args.wslDistro ? { wslDistro: args.wslDistro } : {} + // The engine always probes before it packs, so the pack reuses this answer + // rather than spending a second rev-parse on the same repository. + let commonDir: string | undefined + const resolveCommonDir = async (signal?: AbortSignal): Promise => { + commonDir ??= await readRepoCommonDirFromGit(args.repoPath, { + ...gitOptions, + ...(signal ? { signal } : {}) + }) + return commonDir + } + return { + key: args.key, + isBusy: () => repoBusyProbes.get(args.key)?.() ?? false, + async resolveRefsDirectory(signal: AbortSignal) { + const resolved = await resolveCommonDir(signal) + return resolved ? refsDirectoryForMainProcess(resolved, args.wslDistro) : undefined + }, + async isOptedOut(signal: AbortSignal) { + try { + const { stdout } = await gitExecFileAsync( + ['config', '--get-regexp', '^(maintenance\\.auto|gc\\.auto)$'], + { cwd: args.repoPath, ...gitOptions, admissionTier: 'background', signal } + ) + return isGitAutoMaintenanceDisabled(stdout) + } catch { + // Neither key set is the common case and exits non-zero; that is consent. + return false + } + }, + async packRefs(lock: PackedRefsLockReporter) { + const resolved = await resolveCommonDir() + const owner = resolved + ? new PackRefsLockOwnership(gitCommonDirForMainProcess(resolved, args.wslDistro)) + : null + const claim = owner ? await owner.claim() : { ok: true as const } + if (!claim.ok) { + throw new RefMaintenanceRepoLocked(claim.reason) + } + // Report the rewrite window rather than accepting a signal. A pack that is + // killed mid-prune strands a `refs/**` lock about one time in five, and + // Git never clears those; waiting out the window costs at most ~1.4s. + const watch = owner?.watchLock((held) => lock.setHeld(held)) + try { + await gitExecFileAsync([...PACK_REFS_ARGS], { + cwd: args.repoPath, + ...gitOptions, + admissionTier: 'background', + timeout: PACK_REFS_TIMEOUT_MS + }) + } finally { + watch?.stop() + lock.setHeld(false) + await owner?.release() + } + } + } +} diff --git a/src/main/git/pack-refs-lock-ownership.test.ts b/src/main/git/pack-refs-lock-ownership.test.ts new file mode 100644 index 00000000000..e181feb9976 --- /dev/null +++ b/src/main/git/pack-refs-lock-ownership.test.ts @@ -0,0 +1,192 @@ +import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { PackRefsLockOwnership } from './pack-refs-lock-ownership' + +const roots: string[] = [] + +async function gitCommonDir(): Promise { + const root = await mkdtemp(join(tmpdir(), 'orca-pack-refs-lock-')) + roots.push(root) + return root +} + +function paths(commonDir: string): { lock: string; marker: string } { + return { + lock: join(commonDir, 'packed-refs.lock'), + marker: join(commonDir, 'packed-refs.orca-owner') + } +} + +async function exists(path: string): Promise { + try { + await stat(path) + return true + } catch { + return false + } +} + +/** A pid that cannot be running: the kernel rejects it outright. */ +const DEAD_PID = 0x7fffffff +const ABANDONED_LOCK_AGE_MS = 15 * 60_000 +const PID_REUSE_HORIZON_MS = 24 * 60 * 60_000 + +/** `claim` takes `now`, so age cases need no sleeping and no mtime forgery. */ +function laterBy(ms: number): number { + return Date.now() + ms +} + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +describe('packed-refs lock ownership', () => { + it('claims a repository with no lock and records the owner', async () => { + const commonDir = await gitCommonDir() + const { marker } = paths(commonDir) + + await expect(new PackRefsLockOwnership(commonDir).claim()).resolves.toEqual({ ok: true }) + + await expect(readFile(marker, 'utf-8')).resolves.toContain(String(process.pid)) + }) + + it('drops the owner marker on release', async () => { + const commonDir = await gitCommonDir() + const ownership = new PackRefsLockOwnership(commonDir) + await ownership.claim() + + await ownership.release() + + await expect(exists(paths(commonDir).marker)).resolves.toBe(false) + }) + + it('refuses a lock it cannot prove is its own', async () => { + const commonDir = await gitCommonDir() + // A lock with no marker belongs to the user's own git, or to another tool. + await writeFile(paths(commonDir).lock, 'someone else') + + await expect(new PackRefsLockOwnership(commonDir).claim()).resolves.toMatchObject({ ok: false }) + await expect(exists(paths(commonDir).lock)).resolves.toBe(true) + }) + + it('refuses a lock whose recorded owner is still running', async () => { + const commonDir = await gitCommonDir() + const { lock, marker } = paths(commonDir) + await writeFile(lock, 'in progress') + await writeFile(marker, JSON.stringify({ pid: process.pid })) + + await expect( + new PackRefsLockOwnership(commonDir).claim(laterBy(ABANDONED_LOCK_AGE_MS + 1)) + ).resolves.toMatchObject({ ok: false }) + await expect(exists(lock)).resolves.toBe(true) + }) + + it('reclaims the lock its own dead process left behind', async () => { + // SIGKILL and power loss bypass git's cleanup, and git never clears this itself. + const commonDir = await gitCommonDir() + const { lock, marker } = paths(commonDir) + await writeFile(lock, 'abandoned mid-rewrite') + await writeFile(marker, JSON.stringify({ pid: DEAD_PID })) + + const claimed = await new PackRefsLockOwnership(commonDir).claim( + laterBy(ABANDONED_LOCK_AGE_MS + 1) + ) + + expect(claimed).toEqual({ ok: true }) + await expect(exists(lock)).resolves.toBe(false) + await expect(readFile(marker, 'utf-8')).resolves.toContain(String(process.pid)) + }) + + it('leaves a young lock alone even when the marker names a dead process', async () => { + // A marker outlives its lock, so a foreign lock can appear after our death. + // Age is the only thing separating our wreckage from somebody's live lock. + const commonDir = await gitCommonDir() + const { lock, marker } = paths(commonDir) + await writeFile(marker, JSON.stringify({ pid: DEAD_PID })) + await writeFile(lock, 'a different git process, started just now') + + await expect(new PackRefsLockOwnership(commonDir).claim()).resolves.toMatchObject({ ok: false }) + await expect(exists(lock)).resolves.toBe(true) + }) + + it('does not wedge a repository forever when the recorded pid was recycled', async () => { + const commonDir = await gitCommonDir() + const { lock, marker } = paths(commonDir) + await writeFile(lock, 'abandoned mid-rewrite') + // Our own pid stands in for a recycled one: alive, but not the process that wrote this. + await writeFile(marker, JSON.stringify({ pid: process.pid })) + + await expect( + new PackRefsLockOwnership(commonDir).claim(laterBy(ABANDONED_LOCK_AGE_MS + 1)) + ).resolves.toMatchObject({ ok: false }) + + await expect( + new PackRefsLockOwnership(commonDir).claim(laterBy(PID_REUSE_HORIZON_MS + 1)) + ).resolves.toEqual({ ok: true }) + await expect(exists(lock)).resolves.toBe(false) + }) + + it('refuses a lock whose marker is unreadable rather than guessing', async () => { + const commonDir = await gitCommonDir() + const { lock, marker } = paths(commonDir) + await writeFile(lock, 'in progress') + await writeFile(marker, 'not json') + + await expect( + new PackRefsLockOwnership(commonDir).claim(laterBy(PID_REUSE_HORIZON_MS + 1)) + ).resolves.toMatchObject({ ok: false }) + await expect(exists(lock)).resolves.toBe(true) + }) + + it('claims cleanly when a marker outlived its lock', async () => { + const commonDir = await gitCommonDir() + await writeFile(paths(commonDir).marker, JSON.stringify({ pid: DEAD_PID })) + + await expect(new PackRefsLockOwnership(commonDir).claim()).resolves.toEqual({ ok: true }) + }) +}) + +describe('stranded per-ref locks', () => { + it('clears the empty refs/**/*.lock files its own dead process left behind', async () => { + // `tempfile.c` opens the lock O_EXCL before linking it into the list the + // signal handler walks, so a kill in that window leaves a 0-byte file that + // Git never clears -- and `update-ref -d` on that ref then fails forever. + const commonDir = await gitCommonDir() + const namespace = join(commonDir, 'refs', 'remotes', 'origin') + await mkdir(namespace, { recursive: true }) + await writeFile(join(namespace, 'main.lock'), '') + await writeFile(join(namespace, 'main'), 'a'.repeat(40)) + await writeFile(paths(commonDir).marker, JSON.stringify({ pid: DEAD_PID })) + + await new PackRefsLockOwnership(commonDir).claim(laterBy(ABANDONED_LOCK_AGE_MS + 1)) + + await expect(exists(join(namespace, 'main.lock'))).resolves.toBe(false) + // The ref itself is untouched. + await expect(exists(join(namespace, 'main'))).resolves.toBe(true) + }) + + it('leaves a non-empty ref lock alone, because a live writer is mid-write', async () => { + const commonDir = await gitCommonDir() + const namespace = join(commonDir, 'refs', 'heads') + await mkdir(namespace, { recursive: true }) + await writeFile(join(namespace, 'busy.lock'), 'b'.repeat(40)) + await writeFile(paths(commonDir).marker, JSON.stringify({ pid: DEAD_PID })) + + await new PackRefsLockOwnership(commonDir).claim(laterBy(ABANDONED_LOCK_AGE_MS + 1)) + + await expect(exists(join(namespace, 'busy.lock'))).resolves.toBe(true) + }) + + it('leaves ref locks alone when there is no marker naming a dead process', async () => { + const commonDir = await gitCommonDir() + const namespace = join(commonDir, 'refs', 'heads') + await mkdir(namespace, { recursive: true }) + await writeFile(join(namespace, 'other.lock'), '') + + await new PackRefsLockOwnership(commonDir).claim(laterBy(ABANDONED_LOCK_AGE_MS + 1)) + + await expect(exists(join(namespace, 'other.lock'))).resolves.toBe(true) + }) +}) diff --git a/src/main/git/pack-refs-lock-ownership.ts b/src/main/git/pack-refs-lock-ownership.ts new file mode 100644 index 00000000000..9e7273b143b --- /dev/null +++ b/src/main/git/pack-refs-lock-ownership.ts @@ -0,0 +1,202 @@ +import { readFile, readdir, rm, stat, writeFile } from 'node:fs/promises' +import { posix, win32 } from 'node:path' +import { isWindowsAbsolutePathLike } from '../../shared/cross-platform-path' +import { + PACK_REFS_TIMEOUT_MS, + PACKED_REFS_LOCK_POLL_MS +} from '../../shared/repo-ref-maintenance-policy' + +/** No legitimate `pack-refs` outlives its own deadline, so an older lock is abandoned. */ +const ABANDONED_LOCK_AGE_MS = PACK_REFS_TIMEOUT_MS + +/** Beyond this a recorded pid may have been recycled, so it stops being evidence of life. */ +const PID_REUSE_HORIZON_MS = 24 * 60 * 60_000 + +/** The ref tree is wide but shallow; this only stops a pathological walk. */ +const REF_LOCK_SCAN_CEILING = 4096 + +/** + * Makes a `packed-refs.lock` Orca left behind attributable, and only that one. + * + * Git registers signal handlers that clean the lock up, but SIGKILL and power + * loss bypass them, and Git never removes a stale `packed-refs.lock` on its own + * -- every later ref deletion in that repository fails until someone deletes a + * file they have never heard of. Recording our pid beside the lock lets a later + * run recognise its own wreckage. + * + * Three independent conditions must all hold before anything is unlinked, + * because deleting a lock somebody else is holding is far worse than declining + * to pack: a marker must exist at all, the lock must be older than any + * `pack-refs` could legitimately run for, and the recorded process must be gone. + * A marker can outlive its lock, so age is what separates "our wreckage" from a + * foreign lock that happened to appear afterwards. + */ +export class PackRefsLockOwnership { + private readonly lockPath: string + private readonly markerPath: string + + constructor(gitCommonDir: string) { + const path = isWindowsAbsolutePathLike(gitCommonDir) ? win32 : posix + this.lockPath = path.join(gitCommonDir, 'packed-refs.lock') + this.markerPath = path.join(gitCommonDir, 'packed-refs.orca-owner') + } + + /** Refused when the lock belongs to something we cannot prove is our own wreckage. */ + async claim(now = Date.now()): Promise { + const reclaim = await this.reclaimAbandonedLock(now) + if (!reclaim.ok) { + return reclaim + } + // Per-ref strands outlive their pack and are invisible to Git, which never + // clears a `refs/**\/*.lock` it did not create in this process. + await this.reclaimStrandedRefLocks(now) + try { + await writeFile(this.markerPath, JSON.stringify({ pid: process.pid }), 'utf-8') + } catch { + // Losing the marker only costs attribution on the next run, never correctness. + } + return { ok: true } + } + + /** + * Poll `packed-refs.lock` so the scheduler knows when the exclusive rewrite + * window opens and closes. Cheap: one `stat` on a fixed path. + */ + watchLock(report: (held: boolean) => void): { stop: () => void } { + let stopped = false + let last = false + const tick = async (): Promise => { + if (stopped) { + return + } + const held = (await fileAgeMs(this.lockPath, Date.now())) !== null + if (!stopped && held !== last) { + last = held + report(held) + } + } + const timer = setInterval(() => void tick(), PACKED_REFS_LOCK_POLL_MS) + timer.unref?.() + void tick() + return { + stop: () => { + stopped = true + clearInterval(timer) + } + } + } + + async release(): Promise { + await rm(this.markerPath, { force: true }).catch(() => {}) + } + + private async reclaimAbandonedLock(now: number): Promise { + const lockAgeMs = await fileAgeMs(this.lockPath, now) + if (lockAgeMs === null) { + return { ok: true } + } + // No marker means the lock is not ours to reason about, let alone remove. + const marker = await readOwnerMarker(this.markerPath) + if (marker === null) { + return { ok: false, reason: 'held by another process' } + } + if (lockAgeMs < ABANDONED_LOCK_AGE_MS) { + // Ours, but too young to be certain the writer is gone. Worth retrying soon. + return { ok: false, reason: 'our own lock, not yet old enough to reclaim' } + } + // Past the pid-reuse horizon the pid proves nothing, and a lock this old is + // abandoned whoever wrote it -- otherwise a recycled pid would wedge the + // repository permanently. + if (isProcessAlive(marker.pid) && lockAgeMs < PID_REUSE_HORIZON_MS) { + return { ok: false, reason: 'the recorded owner is still running' } + } + await rm(this.lockPath, { force: true }).catch(() => {}) + await rm(this.markerPath, { force: true }).catch(() => {}) + return { ok: true } + } + + /** + * Clear `refs/**\/*.lock` files a dead pack of ours left behind. + * + * `tempfile.c` opens the lock `O_EXCL` before `activate_tempfile()` links it + * into the list the signal handler walks, so a kill inside that window leaves + * a 0-byte file. Afterwards `update-ref -d` and any fetch touching that ref + * fail with `cannot lock ref ... File exists`, forever. Same three conditions + * as the packed-refs lock, plus a size check: a live writer's lock is not empty. + */ + private async reclaimStrandedRefLocks(now: number): Promise { + const marker = await readOwnerMarker(this.markerPath) + if (marker === null || isProcessAlive(marker.pid)) { + return + } + const markerAgeMs = await fileAgeMs(this.markerPath, now) + if (markerAgeMs === null || markerAgeMs < ABANDONED_LOCK_AGE_MS) { + return + } + const path = isWindowsAbsolutePathLike(this.markerPath) ? win32 : posix + const pending = [path.join(path.dirname(this.markerPath), 'refs')] + let visited = 0 + while (pending.length > 0) { + const directory = pending.pop() + if (directory === undefined || (visited += 1) > REF_LOCK_SCAN_CEILING) { + return + } + let entries: { name: string; isDirectory: () => boolean }[] + try { + entries = await readdir(directory, { withFileTypes: true }) + } catch { + continue + } + for (const entry of entries) { + const full = path.join(directory, entry.name) + if (entry.isDirectory()) { + pending.push(full) + } else if (entry.name.endsWith('.lock') && (await isEmptyFile(full))) { + await rm(full, { force: true }).catch(() => {}) + } + } + } + } +} + +export type PackRefsLockClaim = { ok: true } | { ok: false; reason: string } + +/** A strand from the `O_EXCL` window is 0 bytes; a live writer's lock is not. */ +async function isEmptyFile(path: string): Promise { + try { + return (await stat(path)).size === 0 + } catch { + return false + } +} + +async function readOwnerMarker(path: string): Promise<{ pid: number } | null> { + try { + const raw = (await readFile(path, 'utf-8')).slice(0, 256) + const pid = (JSON.parse(raw) as { pid?: unknown }).pid + return typeof pid === 'number' && Number.isInteger(pid) && pid > 0 ? { pid } : null + } catch { + return null + } +} + +/** Null when the file does not exist. Uses stat: the lock holds a whole packed-refs. */ +async function fileAgeMs(path: string, now: number): Promise { + try { + return Math.max(0, now - (await stat(path)).mtimeMs) + } catch (error) { + return (error as NodeJS.ErrnoException).code === 'ENOENT' ? null : 0 + } +} + +function isProcessAlive(pid: number): boolean { + if (pid === process.pid) { + return true + } + try { + process.kill(pid, 0) + return true + } catch (error) { + return (error as NodeJS.ErrnoException).code !== 'ESRCH' + } +} diff --git a/src/main/git/remote.test.ts b/src/main/git/remote.test.ts index 811fed50fec..11ac2c21264 100644 --- a/src/main/git/remote.test.ts +++ b/src/main/git/remote.test.ts @@ -780,6 +780,60 @@ describe('git remote operations', () => { ]) }) + it('drops a stale branch-specific refspec and retries when the fork branch was deleted upstream (#17828)', async () => { + let fetchAttempts = 0 + gitExecFileAsyncMock.mockImplementation(async (args: string[]) => { + if (args[0] === 'check-ref-format') { + return { stdout: '', stderr: '' } + } + if (args[0] === 'fetch') { + fetchAttempts += 1 + if (fetchAttempts === 1) { + throw Object.assign(new Error("fatal: couldn't find remote ref refs/heads/gone"), { + stderr: "fatal: couldn't find remote ref refs/heads/gone\n" + }) + } + return { stdout: '', stderr: '' } + } + if (args[0] === 'config' && args[1] === '--get-all') { + return { + stdout: + '+refs/heads/gone:refs/remotes/fork/gone\n+refs/heads/keep:refs/remotes/fork/keep\n', + stderr: '' + } + } + return { stdout: '', stderr: '' } + }) + + await gitFetch('/repo', { remoteName: 'fork', branchName: 'keep' }) + + expect(fetchAttempts).toBe(2) + expect(gitExecFileAsyncMock).toHaveBeenCalledWith( + ['config', '--unset-all', 'remote.fork.fetch'], + { cwd: '/repo' } + ) + expect(gitExecFileAsyncMock).toHaveBeenCalledWith( + ['config', '--add', 'remote.fork.fetch', '+refs/heads/keep:refs/remotes/fork/keep'], + { cwd: '/repo' } + ) + }) + + it('surfaces the original fetch error when it is not a stale-refspec failure', async () => { + gitExecFileAsyncMock.mockImplementation(async (args: string[]) => { + if (args[0] === 'check-ref-format') { + return { stdout: '', stderr: '' } + } + if (args[0] === 'fetch') { + throw new Error('network unreachable') + } + return { stdout: '', stderr: '' } + }) + + await expect(gitFetch('/repo', { remoteName: 'fork', branchName: 'keep' })).rejects.toThrow( + 'network unreachable' + ) + }) + it('normalizes fetch authentication errors to a friendly message', async () => { gitExecFileAsyncMock.mockRejectedValueOnce(new Error('Authentication failed')) diff --git a/src/main/git/remote.ts b/src/main/git/remote.ts index c3e389d8478..2baf3b77137 100644 --- a/src/main/git/remote.ts +++ b/src/main/git/remote.ts @@ -7,8 +7,13 @@ import { gitRefTargetsBranchOnRemote } from '../../shared/git-remote-branch-name import type { GitPushTarget } from '../../shared/worktree/types' import type { GitRuntimeOptions } from './git-runtime-options' import { gitOptionsForWorktree } from './git-runtime-options' +import { + postponeRepoRefMaintenance, + withRepoRefMaintenancePaused +} from './local-repo-ref-maintenance' import { validateGitPushTarget } from './push-target-validation' import { gitExecFileAsync } from './runner' +import { fetchForkRemoteWithStaleRefspecRepair } from './fork-remote-stale-branch-refspec' import { runWithGitReadCacheInvalidation } from './status' import { runWithGitWorktreeOperationLock } from '../../shared/git-worktree-operation-lock' @@ -259,8 +264,11 @@ export async function gitPull( // Why: plain `git pull` uses the user's configured pull strategy (merge by // default) so diverged branches reconcile instead of erroring out. Conflicts // surface through the existing conflict-resolution flow. - await runWithGitWorktreeOperationLock(worktreePath, options.signal, () => - runWithGitReadCacheInvalidation(() => gitPullWithArgs(worktreePath, [], pushTarget, options)) + postponeRepoRefMaintenance() + await withRepoRefMaintenancePaused('git-pull', () => + runWithGitWorktreeOperationLock(worktreePath, options.signal, () => + runWithGitReadCacheInvalidation(() => gitPullWithArgs(worktreePath, [], pushTarget, options)) + ) ) } @@ -269,9 +277,12 @@ export async function gitFastForward( pushTarget?: GitPushTarget, options: GitRuntimeOptions = {} ): Promise { - await runWithGitWorktreeOperationLock(worktreePath, options.signal, () => - runWithGitReadCacheInvalidation(() => - gitPullWithArgs(worktreePath, ['--ff-only'], pushTarget, options) + postponeRepoRefMaintenance() + await withRepoRefMaintenancePaused('git-fast-forward', () => + runWithGitWorktreeOperationLock(worktreePath, options.signal, () => + runWithGitReadCacheInvalidation(() => + gitPullWithArgs(worktreePath, ['--ff-only'], pushTarget, options) + ) ) ) } @@ -281,16 +292,28 @@ export async function gitFetch( pushTarget?: GitPushTarget, options: GitRuntimeOptions = {} ): Promise { + // `--prune` deletes remote-tracking refs, which needs the `packed-refs` lock a + // running idle pack holds while it rewrites -- ~1.4s at most. This is the user + // clicking Fetch, so wait that window out rather than letting it fail on the lock. + postponeRepoRefMaintenance() try { - if (pushTarget) { - const target = await validateGitPushTarget(worktreePath, pushTarget, options) - await gitExecFileAsync( - ['fetch', '--prune', target.remoteName], - gitOptionsForWorktree(worktreePath, options) - ) - return - } - await gitExecFileAsync(['fetch', '--prune'], gitOptionsForWorktree(worktreePath, options)) + await withRepoRefMaintenancePaused('git-fetch', async () => { + if (pushTarget) { + const target = await validateGitPushTarget(worktreePath, pushTarget, options) + const runtimeOptions = gitOptionsForWorktree(worktreePath, options) + await fetchForkRemoteWithStaleRefspecRepair( + (args, cwd) => gitExecFileAsync(args, { ...runtimeOptions, cwd }), + worktreePath, + target.remoteName, + () => + gitExecFileAsync(['fetch', '--prune', target.remoteName], runtimeOptions).then( + () => undefined + ) + ) + return + } + await gitExecFileAsync(['fetch', '--prune'], gitOptionsForWorktree(worktreePath, options)) + }) } catch (error) { throw new Error(normalizeGitErrorMessage(error, 'fetch')) } diff --git a/src/main/git/remove-worktree-test-harness.ts b/src/main/git/remove-worktree-test-harness.ts index 9d9a7d18b14..1f8be9b7fcc 100644 --- a/src/main/git/remove-worktree-test-harness.ts +++ b/src/main/git/remove-worktree-test-harness.ts @@ -2,6 +2,7 @@ import { expect, type Mock } from 'vitest' import { clearGitCapabilityStateForTests } from './git-capability-state' import { _resetWorktreeScanCacheForTests } from './worktree' +import { __resetSparseCheckoutStateCacheForTests } from './worktree-sparse-checkout-cache' export type MockResult = { error?: Error @@ -89,6 +90,7 @@ export type WorktreeTrashMocks = { export function resetWorktreeRemovalState(trashMocks: WorktreeTrashMocks): void { clearGitCapabilityStateForTests() _resetWorktreeScanCacheForTests() + __resetSparseCheckoutStateCacheForTests() // Default: the checkout cannot be renamed aside, so removal deletes it in place. trashMocks.moveWorktreeDirectoryToTrashMock.mockReset() trashMocks.moveWorktreeDirectoryToTrashMock.mockResolvedValue(undefined) diff --git a/src/main/git/remove-worktree.test.ts b/src/main/git/remove-worktree.test.ts index be1ddad90e4..006ee3087bb 100644 --- a/src/main/git/remove-worktree.test.ts +++ b/src/main/git/remove-worktree.test.ts @@ -54,6 +54,10 @@ import { } from './remove-worktree-test-harness' import { removeWorktree, WORKTREE_REMOVAL_REGISTRATION_TIMEOUT_MS } from './worktree' +import { + __getSparseCheckoutStateCacheSizeForTests, + detectSparseCheckoutCached +} from './worktree-sparse-checkout-cache' const mockGitCommands = createGitCommandMocker(gitExecFileAsyncMock) const getGitCalls = createGitCallReader(gitExecFileAsyncMock) @@ -650,3 +654,43 @@ branch refs/heads/main expect(calls).not.toContain('git worktree prune') }) }) + +describe('removeWorktree sparse-checkout cache invalidation', () => { + beforeEach(() => { + resetWorktreeGitMocks({ + gitExecFileAsyncMock, + gitExecFileSyncMock, + translateWslOutputPathsMock, + statMock, + readFileMock, + resolveGitDirMock + }) + }) + + it('drops the removed worktree path so a re-created worktree at the same path is re-detected', async () => { + await detectSparseCheckoutCached('/repo', '/repo-feature') + expect(__getSparseCheckoutStateCacheSizeForTests()).toBe(1) + + mockGitCommands({ + 'git worktree list --porcelain': { + stdout: `worktree /repo +HEAD abc123 +branch refs/heads/main + +worktree /repo-feature +HEAD def456 +branch refs/heads/feature/test +` + } + }) + + await removeWorktree('/repo', '/repo-feature', false, { deleteBranch: false }) + + // Why not assert size 0: the pre-removal `listWorktrees` lookup inside `performRemoveWorktree` + // re-annotates every row it saw (including the untouched main worktree), caching a fresh entry + // for `/repo`. Only the removed path's own entry must be gone, proven by a fresh stat call below. + const statCallsBefore = statMock.mock.calls.length + expect(await detectSparseCheckoutCached('/repo', '/repo-feature')).toBe(false) + expect(statMock.mock.calls.length).toBeGreaterThan(statCallsBefore) + }) +}) diff --git a/src/main/git/repo-branch-conflict-real-git.test.ts b/src/main/git/repo-branch-conflict-real-git.test.ts new file mode 100644 index 00000000000..34092273eda --- /dev/null +++ b/src/main/git/repo-branch-conflict-real-git.test.ts @@ -0,0 +1,49 @@ +import { execFileSync } from 'node:child_process' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { getBranchConflictKind } from './repo-branch-conflict' + +describe('branch conflict real Git contract', () => { + const tempPaths: string[] = [] + + afterEach(() => { + for (const path of tempPaths.splice(0)) { + rmSync(path, { recursive: true, force: true }) + } + }) + + it('decides remote conflicts from one batched probe across many remotes', async () => { + const repoPath = mkdtempSync(join(tmpdir(), 'orca-branch-conflict-')) + tempPaths.push(repoPath) + const git = (...args: string[]): string => + execFileSync('git', args, { cwd: repoPath, encoding: 'utf8' }) + + git('init', '--quiet') + git('config', 'user.name', 'Orca Test') + git('config', 'user.email', 'orca@example.test') + git('config', 'commit.gpgSign', 'false') + git('config', 'core.hooksPath', '.git/no-hooks') + writeFileSync(join(repoPath, 'fixture.txt'), 'base\n') + git('add', 'fixture.txt') + git('commit', '--quiet', '-m', 'base') + const head = git('rev-parse', 'HEAD').trim() + + // Many remotes is the shape that used to cost one subprocess each. + for (let index = 0; index < 12; index += 1) { + git('remote', 'add', `remote${index}`, 'https://example.test/repo.git') + } + git('update-ref', 'refs/remotes/remote7/taken', head) + + await expect(getBranchConflictKind(repoPath, 'taken')).resolves.toBe('remote') + await expect(getBranchConflictKind(repoPath, 'free')).resolves.toBeNull() + // The allowed base ref is the one remote spelling that is not a conflict. + await expect( + getBranchConflictKind(repoPath, 'taken', 'refs/remotes/remote7/taken') + ).resolves.toBeNull() + + git('branch', 'local-only', head) + await expect(getBranchConflictKind(repoPath, 'local-only')).resolves.toBe('local') + }) +}) diff --git a/src/main/git/repo-branch-conflict.test.ts b/src/main/git/repo-branch-conflict.test.ts index dab8dd396d6..873c8bd3340 100644 --- a/src/main/git/repo-branch-conflict.test.ts +++ b/src/main/git/repo-branch-conflict.test.ts @@ -134,3 +134,123 @@ describe('getBranchConflictKindViaExec', () => { expect(exec).not.toHaveBeenCalled() }) }) + +describe('getBranchConflictKindViaExec batched remote probe', () => { + function remoteNames(count: number): string { + return `${Array.from({ length: count }, (_, index) => `remote${index}`).join('\n')}\n` + } + + function baseExec(calls: string[][]): (argv: string[]) => Promise<{ stdout: string }> { + return async (argv) => { + calls.push(argv) + if (argv[0] === 'rev-parse') { + throw new Error('local branch is absent') + } + if (argv[0] === 'remote') { + return { stdout: remoteNames(3) } + } + throw new Error(`unexpected git command: ${argv.join(' ')}`) + } + } + + it('asks one batched child instead of one probe per remote', async () => { + const calls: string[][] = [] + const stdinPayloads: (string | undefined)[] = [] + const exec = baseExec(calls) + const batched = async ( + argv: string[], + options: { stdin: string } + ): Promise<{ stdout: string }> => { + calls.push(argv) + stdinPayloads.push(options.stdin) + return { + stdout: [ + 'refs/remotes/remote0/feature missing', + 'refs/remotes/remote1/feature missing', + 'refs/remotes/remote2/feature missing' + ].join('\n') + } + } + + await expect( + getBranchConflictKindViaExec(exec, 'feature', undefined, {}, batched) + ).resolves.toBeNull() + expect(calls).toEqual([ + ['rev-parse', '--verify', 'refs/heads/feature'], + ['remote'], + ['cat-file', '--batch-check'] + ]) + expect(stdinPayloads).toEqual([ + 'refs/remotes/remote0/feature\nrefs/remotes/remote1/feature\nrefs/remotes/remote2/feature\n' + ]) + }) + + it('reports a remote conflict from the batched answer', async () => { + const calls: string[][] = [] + const exec = baseExec(calls) + const batched = async (): Promise<{ stdout: string }> => ({ + stdout: [ + 'refs/remotes/remote0/feature missing', + `${'a'.repeat(40)} commit 214`, + 'refs/remotes/remote2/feature missing' + ].join('\n') + }) + + await expect( + getBranchConflictKindViaExec(exec, 'feature', undefined, {}, batched) + ).resolves.toBe('remote') + }) + + it('falls back to per-ref probes when the batch cannot answer', async () => { + const calls: string[][] = [] + const exec = async (argv: string[]): Promise<{ stdout: string }> => { + calls.push(argv) + if (argv[0] === 'rev-parse') { + throw new Error('local branch is absent') + } + if (argv[0] === 'remote') { + return { stdout: remoteNames(3) } + } + if (argv[0] === 'show-ref') { + if (argv[4] === 'refs/remotes/remote1/feature') { + return { stdout: 'abc refs/remotes/remote1/feature\n' } + } + throw Object.assign(new Error('missing'), { code: 1, stderr: '' }) + } + throw new Error(`unexpected git command: ${argv.join(' ')}`) + } + const batched = async (): Promise<{ stdout: string }> => { + throw new Error('cat-file is unavailable') + } + + await expect( + getBranchConflictKindViaExec(exec, 'feature', undefined, {}, batched) + ).resolves.toBe('remote') + expect(calls.filter((argv) => argv[0] === 'show-ref')).toHaveLength(3) + }) + + it('treats a short batch read as undecided rather than as absence', async () => { + const calls: string[][] = [] + const exec = async (argv: string[]): Promise<{ stdout: string }> => { + calls.push(argv) + if (argv[0] === 'rev-parse') { + throw new Error('local branch is absent') + } + if (argv[0] === 'remote') { + return { stdout: remoteNames(3) } + } + if (argv[0] === 'show-ref') { + throw Object.assign(new Error('missing'), { code: 1, stderr: '' }) + } + throw new Error(`unexpected git command: ${argv.join(' ')}`) + } + const batched = async (): Promise<{ stdout: string }> => ({ + stdout: 'refs/remotes/remote0/feature missing' + }) + + await expect( + getBranchConflictKindViaExec(exec, 'feature', undefined, {}, batched) + ).resolves.toBeNull() + expect(calls.filter((argv) => argv[0] === 'show-ref')).toHaveLength(3) + }) +}) diff --git a/src/main/git/repo-branch-conflict.ts b/src/main/git/repo-branch-conflict.ts index 162d5ef53b3..c799d3770be 100644 --- a/src/main/git/repo-branch-conflict.ts +++ b/src/main/git/repo-branch-conflict.ts @@ -4,8 +4,10 @@ import { gitExecFileAsync } from './runner' import { isSafeGitRefName } from '../../shared/git-status-upstream-ref' import { probeAnyExactRef, + probeAnyExactRefBatched, type ExactRefProbeExec, - type ExactRefProbeExecOptions + type ExactRefProbeExecOptions, + type ExactRefProbeStdinExec } from './exact-ref-probe' export type BranchConflictKind = 'local' | 'remote' @@ -79,12 +81,31 @@ function buildRemoteBranchConflictRefs( return [...refs] } +/** One batched child answers for every remote; the per-ref probes only run when the host cannot + * feed stdin, or when the batch came back undecided. */ +async function probeAnyRemoteConflictRef( + exec: ExactRefProbeExec, + batchedExec: ExactRefProbeStdinExec | undefined, + candidateRefs: readonly string[], + probeOptions: ExactRefProbeExecOptions +): Promise<{ found: boolean }> { + if (batchedExec) { + // A present ref is always decisive, so `found` never survives with `unknown` set. + const batched = await probeAnyExactRefBatched(batchedExec, candidateRefs, probeOptions) + if (!batched.unknown) { + return { found: batched.found } + } + } + return probeAnyExactRef(exec, candidateRefs, probeOptions) +} + /** Run branch-conflict policy through the host that owns Git execution. */ export async function getBranchConflictKindViaExec( exec: ExactRefProbeExec, branchName: string, allowedBaseRef?: string, - options: ExactRefProbeExecOptions = {} + options: ExactRefProbeExecOptions = {}, + batchedExec?: ExactRefProbeStdinExec ): Promise { if (!canQueryRemoteBranchName(branchName)) { return null @@ -104,7 +125,12 @@ export async function getBranchConflictKindViaExec( return null } - const { found: hasRemoteConflict } = await probeAnyExactRef(exec, candidateRefs, probeOptions) + const { found: hasRemoteConflict } = await probeAnyRemoteConflictRef( + exec, + batchedExec, + candidateRefs, + probeOptions + ) return hasRemoteConflict ? 'remote' : null } catch { @@ -119,15 +145,22 @@ export function getBranchConflictKind( options: LocalGitExecOptions = {} ): Promise { const execOptions = gitExecOptions(path, options) + const runLocalGit = ( + argv: string[], + commandOptions?: ExactRefProbeExecOptions & { stdin?: string } + ): Promise<{ stdout: string }> => + gitExecFileAsync(argv, { + ...execOptions, + ...(commandOptions?.maxBuffer === undefined ? {} : { maxBuffer: commandOptions.maxBuffer }), + ...(commandOptions?.timeoutMs === undefined ? {} : { timeout: commandOptions.timeoutMs }), + ...(commandOptions?.stdin === undefined ? {} : { stdin: commandOptions.stdin }) + }) return getBranchConflictKindViaExec( - (argv, commandOptions) => - gitExecFileAsync(argv, { - ...execOptions, - ...(commandOptions?.maxBuffer === undefined ? {} : { maxBuffer: commandOptions.maxBuffer }), - ...(commandOptions?.timeoutMs === undefined ? {} : { timeout: commandOptions.timeoutMs }) - }), + runLocalGit, branchName, - allowedBaseRef + allowedBaseRef, + {}, + (argv, commandOptions) => runLocalGit(argv, commandOptions) ) } diff --git a/src/main/git/repo-ref-maintenance-real-git.test.ts b/src/main/git/repo-ref-maintenance-real-git.test.ts new file mode 100644 index 00000000000..30c67b0365a --- /dev/null +++ b/src/main/git/repo-ref-maintenance-real-git.test.ts @@ -0,0 +1,290 @@ +import { execFileSync } from 'node:child_process' +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { countLooseRefs } from '../../shared/loose-ref-count' +import { RepoRefMaintenance } from '../../shared/repo-ref-maintenance' +import { + _resetLocalRepoRefMaintenanceForTests, + createLocalRepoRefMaintenanceTarget, + getLocalRepoRefMaintenance, + setRepoMaintenanceActivityProbe +} from './local-repo-ref-maintenance' +import { forceDeleteLocalBranch } from './worktree-branch-removal' + +const roots: string[] = [] +// Large enough that the deferral ladder (1x, 2x, 4x ... capped at 8x) outlasts +// three real `pack-refs` runs before the deferral budget is spent. +const QUIET_MS = 25 +const THRESHOLD = 20 + +function git(cwd: string, args: string[]): string { + return execFileSync('git', args, { + cwd, + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'pipe'] + }).trim() +} + +/** A repo whose only loose-ref backlog is the one the test asks for. */ +async function createRepo(looseRefs: number): Promise<{ repoPath: string; refsDir: string }> { + const root = await mkdtemp(join(tmpdir(), 'orca-ref-maintenance-git-')) + roots.push(root) + const repoPath = join(root, 'repo') + execFileSync('git', ['init', '--quiet', repoPath]) + git(repoPath, ['symbolic-ref', 'HEAD', 'refs/heads/main']) + git(repoPath, ['config', 'user.email', 'test@example.com']) + git(repoPath, ['config', 'user.name', 'Test User']) + await writeFile(join(repoPath, 'file.txt'), 'one\n') + git(repoPath, ['add', 'file.txt']) + git(repoPath, ['commit', '--quiet', '-m', 'initial']) + const head = git(repoPath, ['rev-parse', 'HEAD']) + // Written directly: `update-ref` for thousands of refs is the slow part of the fixture. + const namespace = join(repoPath, '.git', 'refs', 'remotes', 'origin') + await mkdir(namespace, { recursive: true }) + for (let index = 0; index < looseRefs; index += 1) { + await writeFile(join(namespace, `branch-${index}`), `${head}\n`) + } + return { repoPath, refsDir: join(repoPath, '.git', 'refs') } +} + +function createMaintenance(onPackRefs: () => void = () => {}): { + maintenance: RepoRefMaintenance + arm: (repoPath: string) => void +} { + const maintenance = new RepoRefMaintenance({ + quietPeriodMs: QUIET_MS, + looseRefThreshold: THRESHOLD + }) + return { + maintenance, + arm: (repoPath: string) => { + const target = createLocalRepoRefMaintenanceTarget({ + key: `local::${repoPath}`, + repoPath + }) + maintenance.arm({ + ...target, + packRefs: async (signal) => { + onPackRefs() + await target.packRefs(signal) + } + }) + } + } +} + +async function settle(maintenance: RepoRefMaintenance): Promise { + await new Promise((resolve) => setTimeout(resolve, QUIET_MS * 4)) + await maintenance.whenAttemptSettled() +} + +/** Deferred repos re-arm for another quiet period, so drain rather than count rounds. */ +async function settleUntil( + maintenance: RepoRefMaintenance, + done: () => Promise +): Promise { + for (let round = 0; round < 100; round += 1) { + if (await done()) { + return + } + await settle(maintenance) + } +} + +afterEach(async () => { + _resetLocalRepoRefMaintenanceForTests() + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +describe('idle ref maintenance against real Git', () => { + it('packs a backlogged repository down to zero loose refs', async () => { + const { repoPath, refsDir } = await createRepo(THRESHOLD + 30) + const { maintenance, arm } = createMaintenance() + + await expect(countLooseRefs(refsDir, 10_000)).resolves.toMatchObject({ + count: THRESHOLD + 31 + }) + + arm(repoPath) + await settle(maintenance) + maintenance.dispose() + + await expect(countLooseRefs(refsDir, 10_000)).resolves.toEqual({ count: 0, saturated: false }) + // The refs survived the move into packed-refs; nothing was lost. + expect(git(repoPath, ['for-each-ref', '--format=%(refname)']).split('\n')).toHaveLength( + THRESHOLD + 31 + ) + expect(git(repoPath, ['rev-parse', '--verify', 'refs/remotes/origin/branch-0'])).toMatch( + /^[0-9a-f]{40}$/ + ) + }, 30_000) + + it('leaves a healthy repository untouched', async () => { + const { repoPath, refsDir } = await createRepo(2) + let packed = 0 + const { maintenance, arm } = createMaintenance(() => { + packed += 1 + }) + + arm(repoPath) + await settle(maintenance) + maintenance.dispose() + + expect(packed).toBe(0) + await expect(countLooseRefs(refsDir, 10_000)).resolves.toMatchObject({ count: 3 }) + }, 30_000) + + it('honours maintenance.auto=false in the repository config', async () => { + const { repoPath, refsDir } = await createRepo(THRESHOLD + 30) + git(repoPath, ['config', 'maintenance.auto', 'false']) + let packed = 0 + const { maintenance, arm } = createMaintenance(() => { + packed += 1 + }) + + arm(repoPath) + await settle(maintenance) + maintenance.dispose() + + expect(packed).toBe(0) + await expect(countLooseRefs(refsDir, 10_000)).resolves.toMatchObject({ + count: THRESHOLD + 31 + }) + }, 30_000) + + it('runs one repository at a time even when several go quiet together', async () => { + const repos = await Promise.all([ + createRepo(THRESHOLD + 5), + createRepo(THRESHOLD + 5), + createRepo(THRESHOLD + 5) + ]) + let concurrent = 0 + let peak = 0 + const maintenance = new RepoRefMaintenance({ + quietPeriodMs: QUIET_MS, + looseRefThreshold: THRESHOLD + }) + for (const { repoPath } of repos) { + const target = createLocalRepoRefMaintenanceTarget({ + key: `local::${repoPath}`, + repoPath + }) + maintenance.arm({ + ...target, + packRefs: async (signal) => { + concurrent += 1 + peak = Math.max(peak, concurrent) + try { + await target.packRefs(signal) + } finally { + concurrent -= 1 + } + } + }) + } + + const allPacked = async (): Promise => { + const counts = await Promise.all(repos.map(({ refsDir }) => countLooseRefs(refsDir, 10_000))) + return counts.every((scan) => scan.count === 0) + } + await settleUntil(maintenance, allPacked) + maintenance.dispose() + + expect(peak).toBe(1) + for (const { refsDir } of repos) { + await expect(countLooseRefs(refsDir, 10_000)).resolves.toEqual({ + count: 0, + saturated: false + }) + } + }, 60_000) +}) + +describe('yielding the repository to work that deletes refs', () => { + it('waits for the packed-refs lock and succeeds while the prune continues', async () => { + // The pack is never killed. `packed-refs.lock` is held for ~1.4s of a 30s + // run; the rest is the prune, during which a concurrent `update-ref -d` + // succeeds on its own because per-ref locks last microseconds. Signalling + // the child there strands a `refs/**` lock Git never clears. + const { repoPath } = await createRepo(0) + git(repoPath, ['branch', 'doomed']) + const head = git(repoPath, ['rev-parse', 'refs/heads/doomed']) + + let packing = false + let releaseLock: (() => void) | undefined + _resetLocalRepoRefMaintenanceForTests({ quietPeriodMs: QUIET_MS, looseRefThreshold: 1 }) + setRepoMaintenanceActivityProbe(() => false) + getLocalRepoRefMaintenance().arm({ + key: `local::${repoPath}`, + resolveRefsDirectory: async () => join(repoPath, '.git', 'refs'), + packRefs: async (lock) => { + packing = true + lock.setHeld(true) + // Stands in for the rewrite window, then the long prune that follows it. + await new Promise((resolve) => { + releaseLock = () => { + lock.setHeld(false) + resolve() + } + }) + } + }) + for (let attempt = 0; attempt < 200 && !packing; attempt += 1) { + await new Promise((resolve) => setTimeout(resolve, QUIET_MS)) + } + expect(packing).toBe(true) + + // The real deletion path, which routes through withRepoRefMaintenancePaused. + let deleted = false + const deletion = forceDeleteLocalBranch(repoPath, 'doomed', head).then(() => { + deleted = true + }) + + // It must still be waiting: the rewrite window is open. + await new Promise((resolve) => setTimeout(resolve, QUIET_MS * 4)) + expect(deleted).toBe(false) + expect(git(repoPath, ['branch', '--list', 'doomed'])).toContain('doomed') + + // Releasing the window is enough -- the pack is never cancelled. + releaseLock?.() + await deletion + expect(deleted).toBe(true) + expect(git(repoPath, ['branch', '--list', 'doomed'])).toBe('') + }, 30_000) + + it('does not block the caller once the rewrite window has closed', async () => { + // The prune phase is concurrency-safe, so a caller arriving during it pays + // nothing at all. + const { repoPath } = await createRepo(0) + git(repoPath, ['branch', 'doomed']) + const head = git(repoPath, ['rev-parse', 'refs/heads/doomed']) + + let pruning = false + let finishPrune: (() => void) | undefined + _resetLocalRepoRefMaintenanceForTests({ quietPeriodMs: QUIET_MS, looseRefThreshold: 1 }) + setRepoMaintenanceActivityProbe(() => false) + getLocalRepoRefMaintenance().arm({ + key: `local::${repoPath}`, + resolveRefsDirectory: async () => join(repoPath, '.git', 'refs'), + packRefs: async (lock) => { + lock.setHeld(true) + lock.setHeld(false) + pruning = true + await new Promise((resolve) => { + finishPrune = resolve + }) + } + }) + for (let attempt = 0; attempt < 200 && !pruning; attempt += 1) { + await new Promise((resolve) => setTimeout(resolve, QUIET_MS)) + } + + const startedAt = Date.now() + await expect(forceDeleteLocalBranch(repoPath, 'doomed', head)).resolves.toBeUndefined() + expect(Date.now() - startedAt).toBeLessThan(2_000) + + finishPrune?.() + }, 30_000) +}) diff --git a/src/main/git/source-control/file-diff.ts b/src/main/git/source-control/file-diff.ts index 5595f0f6804..a16fe079cfd 100644 --- a/src/main/git/source-control/file-diff.ts +++ b/src/main/git/source-control/file-diff.ts @@ -1,5 +1,6 @@ import * as path from 'node:path' import type { GitDiffResult } from '../../../shared/git-diff-compare-types' +import { resolveWorktreeHostPath } from '../../../shared/git-metadata-path' import { stableInFlightKey } from '../../../shared/in-flight-promise-dedupe' import type { GitRuntimeOptions } from '../git-runtime-options' import { gitRuntimeOptionsKey } from './git-runtime-options-cache-key' @@ -7,6 +8,7 @@ import { gitDiffReadDedupe, settledDiffCache } from './git-read-cache-invalidati import { readWorktreeDiffStamp } from './worktree-diff-stamp' import { buildDiffResult } from './diff-result' import { + type GitBlobReadResult, readGitBlobAtIndexPath, readGitBlobAtOidPath, readUnstagedLeftBlob, @@ -76,7 +78,7 @@ async function loadDiffThroughSettledCache( // lands entirely inside that window would otherwise leave the fence covering only the git read. const readGeneration = settledDiffCache.beginRead() // A staged diff compares HEAD to the index, so the working tree is not one of its inputs. - const stamp = await readWorktreeDiffStamp(worktreePath, filePath, !staged) + const stamp = await readWorktreeDiffStamp(worktreePath, filePath, !staged, options) const cached = settledDiffCache.get(readKey, stamp) if (cached) { return cached @@ -173,11 +175,15 @@ async function loadDiff( } else { // The left chain (index→HEAD) is sequential within itself, but the working // tree read is a plain fs read that does not depend on it. + // Git can run in the distro against a raw Linux worktree path while Node reads it through Win32. + const hostWorktreePath = resolveWorktreeHostPath(worktreePath, options) const [leftBlob, workingTreeBlob] = await Promise.all([ compareAgainstHead ? readGitBlobAtOidPath(worktreePath, 'HEAD', filePath, options) : readUnstagedLeftBlob(worktreePath, filePath, options), - readWorkingTreeFile(path.join(worktreePath, filePath)) + hostWorktreePath + ? readWorkingTreeFile(path.join(hostWorktreePath, filePath)) + : Promise.resolve(UNSPELLABLE_WORKING_TREE_READ) ]) originalContent = leftBlob.content originalIsBinary = leftBlob.isBinary @@ -205,6 +211,14 @@ async function loadDiff( return { result, reusable: !readFailed } } +/** A worktree path with no host spelling is a read failure, never a proven deletion. */ +const UNSPELLABLE_WORKING_TREE_READ: GitBlobReadResult = { + content: '', + isBinary: false, + exists: true, + failed: true +} + function notReusable(result: GitDiffResult): LoadedDiff { return { result, reusable: false } } diff --git a/src/main/git/source-control/git-conflict-operation.ts b/src/main/git/source-control/git-conflict-operation.ts index 7c8ebd70312..7d9bb5d86d9 100644 --- a/src/main/git/source-control/git-conflict-operation.ts +++ b/src/main/git/source-control/git-conflict-operation.ts @@ -9,8 +9,11 @@ import { resolveGitDir } from './resolve-git-dir' // Why: the git-status → existsSync race can miss a transient HEAD; fall back to 'unknown' for one poll cycle. // Why: detect rebase from rebase-merge/ or rebase-apply/ dirs (persist all steps), not REBASE_HEAD (partial, lingers → stale badge). -export async function detectConflictOperation(worktreePath: string): Promise { - const gitDir = await resolveGitDir(worktreePath) +export async function detectConflictOperation( + worktreePath: string, + options: Pick = {} +): Promise { + const gitDir = await resolveGitDir(worktreePath, options) const mergeHead = path.join(gitDir, 'MERGE_HEAD') const cherryPickHead = path.join(gitDir, 'CHERRY_PICK_HEAD') const rebaseMergeDir = path.join(gitDir, 'rebase-merge') diff --git a/src/main/git/source-control/resolve-git-dir.ts b/src/main/git/source-control/resolve-git-dir.ts index 3b9b668ef77..867bfb58599 100644 --- a/src/main/git/source-control/resolve-git-dir.ts +++ b/src/main/git/source-control/resolve-git-dir.ts @@ -1,14 +1,25 @@ import { readFile } from 'node:fs/promises' import * as path from 'node:path' +import { resolveGitMetadataPath, resolveWorktreeHostPath } from '../../../shared/git-metadata-path' import { parseGitdirMarkerPayload } from '../../../shared/gitdir-marker-payload' +import type { GitRuntimeOptions } from '../git-runtime-options' -export async function resolveGitDir(worktreePath: string): Promise { - const dotGitPath = path.join(worktreePath, '.git') +export async function resolveGitDir( + worktreePath: string, + options: Pick = {} +): Promise { + // Why: git in a WSL distro reports the worktree in the guest namespace, but this read and the + // pointer resolve below both run in the Windows main process, where that spelling names nothing. + // A relative pointer (`worktree.useRelativePaths`) resolves against it too, so a guest-spelled + // base would make Win32 resolve it drive-relative. + // Null only for an empty path; the caller's spelling keeps the pre-existing fallback. + const hostWorktreePath = resolveWorktreeHostPath(worktreePath, options) ?? worktreePath + const dotGitPath = path.join(hostWorktreePath, '.git') try { const gitDir = parseGitdirMarkerPayload(await readFile(dotGitPath, 'utf-8')) if (gitDir) { - return path.resolve(worktreePath, gitDir) + return resolveGitMetadataPath(hostWorktreePath, gitDir, options) ?? dotGitPath } } catch { // `.git` is likely a directory in a non-worktree checkout. diff --git a/src/main/git/source-control/status-branch-line-total-input.ts b/src/main/git/source-control/status-branch-line-total-input.ts index 5603d55bd4c..a29f2b54a71 100644 --- a/src/main/git/source-control/status-branch-line-total-input.ts +++ b/src/main/git/source-control/status-branch-line-total-input.ts @@ -8,6 +8,7 @@ import { import type { GitRuntimeOptions } from '../git-runtime-options' import { gitReadOptionsForWorktree } from '../git-runtime-options' import { gitExecFileAsync, gitOptionalLocksDisabledEnv } from '../runner' +import { resolveWorktreeFilesystemPath } from './worktree-filesystem-path' import type { GetStatusOptions } from './get-status-options' /** Undefined — and therefore zero extra work — unless the caller asked for a total we can know exact. */ @@ -28,6 +29,8 @@ export function createBranchLineTotalInput( compute: () => computeGitBranchLineTotal({ worktreePath, + // Why: the untracked tally reads files directly, so it needs Node's spelling, not git's. + filesystemWorktreePath: resolveWorktreeFilesystemPath(worktreePath, options), // Why: the same path can be a different filesystem per WSL distro. hostKey: options.wslDistro ?? 'native', mergeBase, diff --git a/src/main/git/source-control/status-conflict-entries.ts b/src/main/git/source-control/status-conflict-entries.ts index f4d765d48ce..180e3d673b8 100644 --- a/src/main/git/source-control/status-conflict-entries.ts +++ b/src/main/git/source-control/status-conflict-entries.ts @@ -1,4 +1,4 @@ -import { existsSync } from 'node:fs' +import { access } from 'node:fs/promises' import * as path from 'node:path' import type { GitConflictKind, @@ -78,10 +78,15 @@ async function getConflictCompatibilityStatus( return 'deleted' } + // Why async: on a WSL worktree this path is a `\\wsl.localhost\...` share, and a sync probe + // per asymmetric conflict blocks the Electron main thread for a 9p round trip each. try { - return existsSync(path.join(worktreePath, filePath)) ? 'modified' : 'deleted' - } catch { - // Why: on an fs check failure, 'modified' is safer — it keeps the row visible rather than falsely showing 'deleted'. + await access(path.join(worktreePath, filePath)) return 'modified' + } catch (error) { + // Why: only a definite "not there" reads as deleted; any other fs failure keeps the row visible + // rather than falsely showing 'deleted'. + const code = (error as NodeJS.ErrnoException).code + return code === 'ENOENT' || code === 'ENOTDIR' ? 'deleted' : 'modified' } } diff --git a/src/main/git/source-control/status-line-stats.ts b/src/main/git/source-control/status-line-stats.ts index 61e552d65a9..c0d7416b8d8 100644 --- a/src/main/git/source-control/status-line-stats.ts +++ b/src/main/git/source-control/status-line-stats.ts @@ -9,6 +9,7 @@ import { import type { GitRuntimeOptions } from '../git-runtime-options' import { gitReadOptionsForWorktree } from '../git-runtime-options' import { gitExecFileAsync, gitOptionalLocksDisabledEnv } from '../runner' +import { resolveWorktreeFilesystemPath } from './worktree-filesystem-path' async function runNumstat( worktreePath: string, @@ -56,7 +57,12 @@ export async function attachLineStats( const [stagedStats, unstagedStats, untrackedStats] = await Promise.all([ hasStaged ? runNumstat(worktreePath, true, options) : Promise.resolve(emptyStats), hasUnstaged ? runNumstat(worktreePath, false, options) : Promise.resolve(emptyStats), - collectUntrackedAdditions(worktreePath, untrackedPaths, options.signal) + // Why: git took the guest path, but this read goes straight through Node's own namespace. + collectUntrackedAdditions( + resolveWorktreeFilesystemPath(worktreePath, options), + untrackedPaths, + options.signal + ) ]) for (const entry of entries) { const area = entry.area diff --git a/src/main/git/source-control/status-read.ts b/src/main/git/source-control/status-read.ts index ae1fbbec8df..cb21aeee1e2 100644 --- a/src/main/git/source-control/status-read.ts +++ b/src/main/git/source-control/status-read.ts @@ -12,6 +12,7 @@ import { clearGitStatusLineStatsCacheKey, reuseOrRecomputeGitStatusLineStats } from '../../../shared/git-status-line-stats-cache' +import { resolveWorktreeHostPath } from '../../../shared/git-metadata-path' import { gitOptionalLocksDisabledEnv, gitStreamStdout } from '../runner' import { findExistingWorktreeSymlinkPaths } from '../worktree-symlink-detection' import type { GetStatusOptions } from './get-status-options' @@ -80,14 +81,19 @@ function getStatusReadKey(worktreePath: string, options: GetStatusOptions): stri async function dropSharedSymlinkUntrackedEntries( worktreePath: string, entries: GitStatusEntry[], - sharedLinkPaths: readonly string[] + options: GetStatusOptions ): Promise { + const sharedLinkPaths = options.sharedLinkPaths ?? [] // Why: a clean tree has no untracked entries, so this costs nothing on the // common status-poll path — no syscall, no config read, no subprocess. if (sharedLinkPaths.length === 0 || !entries.some((entry) => entry.area === 'untracked')) { return } - const sharedLinks = new Set(await findExistingWorktreeSymlinkPaths(worktreePath, sharedLinkPaths)) + const sharedLinks = new Set( + await findExistingWorktreeSymlinkPaths(worktreePath, sharedLinkPaths, { + wslDistro: options.wslDistro + }) + ) if (sharedLinks.size === 0) { return } @@ -112,7 +118,7 @@ async function runGetStatus( const limit = resolveGitStatusLimit(options.limit) // Why: detectConflictOperation and git status are independent, so run them concurrently to save I/O latency. - const conflictPromise = detectConflictOperation(worktreePath) + const conflictPromise = detectConflictOperation(worktreePath, options) // Why: core.quotePath=false keeps non-ASCII paths as raw UTF-8, not octal escapes, so entry.path is readable and lookups match. const statusArgs = [ '-c', @@ -167,6 +173,8 @@ async function runGetStatus( const entries: GitStatusEntry[] = [] const { head, branch, upstreamName, upstreamAheadBehind } = parser.branch + // Why: git runs in the distro and answers in its namespace; the working-tree probes below run here. + const hostWorktreePath = resolveWorktreeHostPath(worktreePath, options) ?? worktreePath // Why: resolve deferred conflicts in Git's output order so the cap cannot hide // an early conflict behind ordinary rows that appeared later in the stream. @@ -177,14 +185,14 @@ async function runGetStatus( if (record.type === 'entry') { entries.push(record.entry) } else { - const unmergedEntry = await parseUnmergedEntry(worktreePath, record.line) + const unmergedEntry = await parseUnmergedEntry(hostWorktreePath, record.line) if (unmergedEntry) { entries.push(unmergedEntry) } } } - await dropSharedSymlinkUntrackedEntries(worktreePath, entries, options.sharedLinkPaths ?? []) + await dropSharedSymlinkUntrackedEntries(worktreePath, entries, options) if (statusSucceeded && !didHitLimit && shouldProbeEffectiveUpstreamStatus(branch, upstreamName)) { const branchName = getShortBranchName(branch) diff --git a/src/main/git/source-control/worktree-diff-stamp.ts b/src/main/git/source-control/worktree-diff-stamp.ts index f24130d274b..3fdfa6b03e5 100644 --- a/src/main/git/source-control/worktree-diff-stamp.ts +++ b/src/main/git/source-control/worktree-diff-stamp.ts @@ -1,5 +1,7 @@ import { readFile, stat } from 'node:fs/promises' import * as path from 'node:path' +import { resolveWorktreeHostPath } from '../../../shared/git-metadata-path' +import type { GitRuntimeOptions } from '../git-runtime-options' import { resolveGitDir } from './resolve-git-dir' /** @@ -68,11 +70,20 @@ export function isDiffStampClockSkewed(stamp: WorktreeDiffStamp): boolean { export async function readWorktreeDiffStamp( worktreePath: string, filePath: string, - includeWorkingTree: boolean + includeWorkingTree: boolean, + options: Pick = {} ): Promise { const capturedAtMs = Date.now() try { - const gitDir = await resolveGitDir(worktreePath) + // Git can run in the distro against a raw Linux worktree path while Node stats it through Win32. + const hostWorktreePath = resolveWorktreeHostPath(worktreePath, options) + if (!hostWorktreePath) { + // Only an empty worktree path lands here, and nothing about it is provably unchanged. + return null + } + // Why still pass options: the host spelling above only encodes the distro when it lands on a + // UNC share, so a drvfs-spelled worktree needs it again to resolve a non-drvfs gitdir pointer. + const gitDir = await resolveGitDir(hostWorktreePath, options) const [head, index, gitmodules, workingTree] = await Promise.all([ readHeadComponent(gitDir), // Over-invalidates on purpose: git run outside Orca (a terminal `git status`/`git add`) @@ -81,9 +92,9 @@ export async function readWorktreeDiffStamp( // index from the stamp, because `git add` then becomes invisible and the cache serves a // pre-staging diff. readFileStampComponent(path.join(gitDir, 'index')), - readFileStampComponent(path.join(worktreePath, '.gitmodules')), + readFileStampComponent(path.join(hostWorktreePath, '.gitmodules')), includeWorkingTree - ? readWorkingTreeComponent(path.join(worktreePath, filePath)) + ? readWorkingTreeComponent(path.join(hostWorktreePath, filePath)) : Promise.resolve(ABSENT) ]) if (!head) { diff --git a/src/main/git/source-control/worktree-filesystem-path.ts b/src/main/git/source-control/worktree-filesystem-path.ts new file mode 100644 index 00000000000..f028ae2602d --- /dev/null +++ b/src/main/git/source-control/worktree-filesystem-path.ts @@ -0,0 +1,37 @@ +import { resolveGitMetadataPath } from '../../../shared/git-metadata-path' +import type { GitRuntimeOptions } from '../git-runtime-options' + +const GUEST_ROOTED_PATH = /^\/(?!\/)/ + +/** + * Whether a worktree path is a guest spelling the resolver can safely re-spell. + * + * Single leading slash only: `//wsl.localhost/...` and `//wsl$/...` are UNC spellings that also + * start with `/`, and translating one prepends the distro root a second time. Trim-stable only: + * the resolver returns `rawPath.trim()`, which would silently point a worktree whose name has + * edge whitespace (legal on ext4) at a different directory. + */ +function isRespellableGuestPath(worktreePath: string): boolean { + return GUEST_ROOTED_PATH.test(worktreePath) && worktreePath === worktreePath.trim() +} + +/** + * The worktree path as this process must spell it to open files inside it. + * + * Why: git executing in a WSL distro takes and reports guest paths, but Node reads them back + * through Win32, where `/home/me/repo` is drive-relative and `/mnt/c/repo` means `C:\mnt\c\repo`. + * Only reads that bypass git — direct lstat/open on working-tree files — need this; anything + * handed to the git runner keeps the execution host's spelling. + */ +export function resolveWorktreeFilesystemPath( + worktreePath: string, + options: GitRuntimeOptions = {} +): string { + // The resolver is already an identity off win32; short-circuiting makes that structural. + if (process.platform !== 'win32' || !isRespellableGuestPath(worktreePath)) { + return worktreePath + } + // Unreachable: the resolver returns null only for an empty pointer, which the guard rejects. + // Kept so the never-null contract stays the resolver's to state, not ours to assert. + return resolveGitMetadataPath('', worktreePath, options) ?? worktreePath +} diff --git a/src/main/git/status-conflict-operations.test.ts b/src/main/git/status-conflict-operations.test.ts index 5b61932578f..dc2c75afa12 100644 --- a/src/main/git/status-conflict-operations.test.ts +++ b/src/main/git/status-conflict-operations.test.ts @@ -147,4 +147,100 @@ describe('detectConflictOperation', () => { await expect(detectConflictOperation('/repo')).resolves.toBe('unknown') }) + + // Both cases below assert the probed prefix rather than the joined string so they exercise + // Win32 pointer resolution on every host, where `path.join` still uses the host separator. + it('probes the drive spelling of a drvfs gitdir pointer on Windows', async () => { + const platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('win32') + readFileMock.mockResolvedValue('gitdir: /mnt/c/Users/me/repo/.git/worktrees/feature\n') + accessMock.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' })) + + try { + await expect(detectConflictOperation(String.raw`C:\Users\me\repo\feature`)).resolves.toBe( + 'unknown' + ) + for (const [target] of accessMock.mock.calls) { + expect(target).toContain(String.raw`C:\Users\me\repo\.git\worktrees\feature`) + } + expect(accessMock).toHaveBeenCalledTimes(4) + } finally { + platformSpy.mockRestore() + } + }) + + // The worktree path itself can be guest-spelled (git in the distro reports it that way), so the + // gitfile read has to be translated too or it ENOENTs before any pointer resolution happens. + it('reads the gitfile at the host spelling of a guest-spelled worktree', async () => { + const platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('win32') + readFileMock.mockResolvedValue('gitdir: /mnt/c/Users/me/repo/.git/worktrees/feature\n') + accessMock.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' })) + + try { + await expect(detectConflictOperation('/mnt/c/Users/me/repo/feature')).resolves.toBe('unknown') + expect(readFileMock).toHaveBeenCalledTimes(1) + expect(readFileMock.mock.calls[0][0]).toContain(String.raw`C:\Users\me\repo\feature`) + } finally { + platformSpy.mockRestore() + } + }) + + // A plain clone inside the distro has a `.git` directory, so the gitfile read fails and the + // markers are probed under the worktree itself — that fallback needs the host spelling too. + it('probes a directory .git under the distro share when the caller names one', async () => { + const platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('win32') + readFileMock.mockRejectedValue(Object.assign(new Error('EISDIR'), { code: 'EISDIR' })) + accessMock.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' })) + + try { + await expect( + detectConflictOperation('/home/me/repo/feature', { wslDistro: 'Ubuntu' }) + ).resolves.toBe('unknown') + expect(readFileMock.mock.calls[0][0]).toContain( + String.raw`\\wsl.localhost\Ubuntu\home\me\repo\feature` + ) + for (const [target] of accessMock.mock.calls) { + expect(target).toContain(String.raw`\\wsl.localhost\Ubuntu\home\me\repo\feature`) + } + expect(accessMock).toHaveBeenCalledTimes(4) + } finally { + platformSpy.mockRestore() + } + }) + + // `git worktree repair --relative-paths` (2.48+) writes `gitdir: ../../..`, which the guest + // spelling of the worktree would resolve drive-relative on Win32. + it('resolves a relative gitdir pointer against the host spelling of the worktree', async () => { + const platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('win32') + readFileMock.mockResolvedValue('gitdir: ../.git/worktrees/feature\n') + accessMock.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' })) + + try { + await expect(detectConflictOperation('/mnt/c/Users/me/repo/feature')).resolves.toBe('unknown') + for (const [target] of accessMock.mock.calls) { + expect(target).toContain(String.raw`C:\Users\me\repo\.git\worktrees\feature`) + } + expect(accessMock).toHaveBeenCalledTimes(4) + } finally { + platformSpy.mockRestore() + } + }) + + it('probes the distro UNC share when the caller names one', async () => { + const platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('win32') + readFileMock.mockResolvedValue('gitdir: /home/me/repo/.git/worktrees/feature\n') + accessMock.mockImplementation(async (target: string) => + target.includes(String.raw`\\wsl.localhost\Ubuntu\home\me\repo\.git\worktrees\feature`) && + target.endsWith('MERGE_HEAD') + ? undefined + : Promise.reject(Object.assign(new Error('ENOENT'), { code: 'ENOENT' })) + ) + + try { + await expect( + detectConflictOperation(String.raw`C:\Users\me\repo\feature`, { wslDistro: 'Ubuntu' }) + ).resolves.toBe('merge') + } finally { + platformSpy.mockRestore() + } + }) }) diff --git a/src/main/git/status-diff-settled-cache.test.ts b/src/main/git/status-diff-settled-cache.test.ts index 1e5cf2231b1..40d455441bf 100644 --- a/src/main/git/status-diff-settled-cache.test.ts +++ b/src/main/git/status-diff-settled-cache.test.ts @@ -1,4 +1,5 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import * as path from 'node:path' import type * as BoundedFileReader from '../../shared/node-bounded-file-reader' import { createBoundedFileReaderModuleMock, createGitRunnerModuleMock } from './status-test-harness' @@ -149,6 +150,86 @@ describe('settled diff cache', () => { expect(settledDiffCache.stats().hits).toBe(1) }) + // A WSL-routed read whose worktree path never went through UNC translation: git resolves + // `/home/...` inside the distro, but every Node read here has to be spelled for Win32. + describe('on a Windows host reading a raw Linux WSL worktree path', () => { + const WSL_WORKTREE = '/home/me/repo/feature' + const WSL_OPTIONS = { wslDistro: 'Ubuntu' } + const HOST_WORKTREE = String.raw`\\wsl.localhost\Ubuntu\home\me\repo\feature` + let platformDescriptor: PropertyDescriptor | undefined + + beforeEach(() => { + platformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + files.clear() + // `.git` is a directory here, so the gitdir is the worktree path plus a segment. + writeFile(path.join(HOST_WORKTREE, '.git/HEAD'), 'ref: refs/heads/main\n') + writeFile(path.join(HOST_WORKTREE, '.git/refs/heads/main'), `${'a'.repeat(40)}\n`) + writeFile(path.join(HOST_WORKTREE, '.git/index'), 'index-bytes') + writeFile(path.join(HOST_WORKTREE, FILE), 'working-tree-content') + }) + + afterEach(() => { + if (platformDescriptor) { + Object.defineProperty(process, 'platform', platformDescriptor) + } + }) + + it('reads the working tree through the host spelling instead of reporting a deletion', async () => { + const result = await getDiff(WSL_WORKTREE, FILE, false, false, WSL_OPTIONS) + + expect(result.modifiedContent).toBe('working-tree-content') + expect(readFileMock).toHaveBeenCalledWith(path.join(HOST_WORKTREE, FILE)) + expect(readFileMock).not.toHaveBeenCalledWith(path.join(WSL_WORKTREE, FILE)) + }) + + it('stamps through the host spelling so the second read does not respawn git', async () => { + await getDiff(WSL_WORKTREE, FILE, false, false, WSL_OPTIONS) + const spawnsAfterFirst = blobReadCount() + + await getDiff(WSL_WORKTREE, FILE, false, false, WSL_OPTIONS) + + expect(spawnsAfterFirst).toBeGreaterThan(0) + expect(blobReadCount()).toBe(spawnsAfterFirst) + expect(settledDiffCache.stats().hits).toBe(1) + expect(statMock).toHaveBeenCalledWith(path.join(HOST_WORKTREE, '.git/index')) + }) + + // Each stamp component has to be stat'd under the host spelling too: one that permanently + // misses is a constant, so the stamp stops moving and the cache serves a stale diff. + it('invalidates when the working tree file is edited under the host spelling', async () => { + await getDiff(WSL_WORKTREE, FILE, false, false, WSL_OPTIONS) + const spawnsAfterFirst = blobReadCount() + + writeFile(path.join(HOST_WORKTREE, FILE), 'edited-in-another-editor') + const second = await getDiff(WSL_WORKTREE, FILE, false, false, WSL_OPTIONS) + + expect(blobReadCount()).toBeGreaterThan(spawnsAfterFirst) + expect(second.modifiedContent).toBe('edited-in-another-editor') + }) + + it('invalidates when .gitmodules appears under the host spelling', async () => { + await getDiff(WSL_WORKTREE, FILE, false, false, WSL_OPTIONS) + const spawnsAfterFirst = blobReadCount() + + writeFile(path.join(HOST_WORKTREE, '.gitmodules'), '[submodule "vendor"]\n') + await getDiff(WSL_WORKTREE, FILE, false, false, WSL_OPTIONS) + + expect(blobReadCount()).toBeGreaterThan(spawnsAfterFirst) + expect(statMock).toHaveBeenCalledWith(path.join(HOST_WORKTREE, '.gitmodules')) + }) + }) + + // An empty worktree path has no host spelling, and `path.join('', x)` is a *relative* path: + // every fs read would land in the process cwd, which in dev is Orca's own checkout. + it('reads nothing relative to the cwd when the worktree path has no host spelling', async () => { + await getDiff('', FILE, false) + + expect(statMock).not.toHaveBeenCalledWith(FILE) + expect(readFileMock).not.toHaveBeenCalledWith('.git', 'utf-8') + expect(settledDiffCache.stats().entries).toBe(0) + }) + // The four invalidation axes, one per diff input. Each proves the stale result is // never served, which matters more than any of the hits above. it.each([ diff --git a/src/main/git/status-line-stats-host-paths.test.ts b/src/main/git/status-line-stats-host-paths.test.ts new file mode 100644 index 00000000000..96abc34d8de --- /dev/null +++ b/src/main/git/status-line-stats-host-paths.test.ts @@ -0,0 +1,156 @@ +/** + * Untracked line counts come from direct lstat/open calls, not from git, so on a Windows host + * driving a WSL distro they must be issued against the Win32 spelling of the worktree. These + * assert the exact path that reaches the filesystem: translated for a guest-rooted worktree, + * verbatim for one that is already spelled in this process's own namespace. + */ +import * as path from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type * as FsPromises from 'node:fs/promises' +import type * as BoundedFileReader from '../../shared/node-bounded-file-reader' +import type { GitStatusEntry } from '../../shared/git-status-types' +import { invalidateGitBranchLineTotalInFlight } from '../../shared/git-branch-line-total' + +const { lstatPaths, untrackedFiles } = vi.hoisted(() => ({ + lstatPaths: [] as string[], + untrackedFiles: new Map() +})) + +vi.mock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + lstat: async (target: string) => { + lstatPaths.push(target) + const contents = untrackedFiles.get(target) + if (contents === undefined) { + throw Object.assign(new Error(`ENOENT: ${target}`), { code: 'ENOENT' }) + } + return { + size: Buffer.byteLength(contents), + // Distinct per read so the stat-keyed untracked cache never serves another case's entry. + mtimeMs: lstatPaths.length, + ctimeMs: lstatPaths.length, + isSymbolicLink: () => false, + isFile: () => true + } + } + } +}) + +vi.mock('../../shared/node-bounded-file-reader', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + readNodeFileWithinLimit: async (target: string) => { + const contents = untrackedFiles.get(target) + if (contents === undefined) { + throw Object.assign(new Error(`ENOENT: ${target}`), { code: 'ENOENT' }) + } + return { buffer: Buffer.from(contents) } + } + } +}) + +const { gitExecFileAsyncMock } = vi.hoisted(() => ({ gitExecFileAsyncMock: vi.fn() })) + +vi.mock('./runner', () => ({ + gitExecFileAsync: gitExecFileAsyncMock, + gitExecFileAsyncBuffer: vi.fn(), + gitOptionalLocksDisabledEnv: (env: NodeJS.ProcessEnv = process.env) => env, + gitStreamStdout: vi.fn() +})) + +import { attachLineStats } from './source-control/status-line-stats' +import { createBranchLineTotalInput } from './source-control/status-branch-line-total-input' + +const GUEST_WORKTREE = '/home/me/repo' +const UNC_WORKTREE = String.raw`\\wsl.localhost\Ubuntu\home\me\repo` +const MERGE_BASE = 'a'.repeat(40) + +function untrackedEntry(entryPath: string): GitStatusEntry { + return { path: entryPath, area: 'untracked', status: 'added' } +} + +describe('untracked line stats on a WSL worktree read by a Windows host', () => { + beforeEach(() => { + lstatPaths.length = 0 + untrackedFiles.clear() + gitExecFileAsyncMock.mockReset() + gitExecFileAsyncMock.mockResolvedValue({ stdout: '', stderr: '' }) + invalidateGitBranchLineTotalInFlight() + vi.spyOn(process, 'platform', 'get').mockReturnValue('win32') + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('counts an untracked file through the distro UNC spelling of the worktree', async () => { + const target = path.join(UNC_WORKTREE, 'fresh.txt') + untrackedFiles.set(target, 'one\ntwo\nthree\n') + const entries = [untrackedEntry('fresh.txt')] + + const complete = await attachLineStats(GUEST_WORKTREE, entries, { wslDistro: 'Ubuntu' }) + + expect(lstatPaths).toEqual([target]) + expect(entries[0].added).toBe(3) + expect(complete).toBe(true) + }) + + it('adds untracked lines to the branch total through the same spelling', async () => { + const target = path.join(UNC_WORKTREE, 'fresh.txt') + untrackedFiles.set(target, 'one\ntwo\nthree\n') + const input = createBranchLineTotalInput( + GUEST_WORKTREE, + [untrackedEntry('fresh.txt')], + { wslDistro: 'Ubuntu', branchLineTotalMergeBase: MERGE_BASE }, + true + ) + + const total = await input?.compute() + + expect(lstatPaths).toEqual([target]) + expect(total?.added).toBe(3) + }) + + it('maps a drvfs worktree onto its drive spelling', async () => { + const target = path.join(String.raw`C:\repo`, 'fresh.txt') + untrackedFiles.set(target, 'one\n') + const entries = [untrackedEntry('fresh.txt')] + + await attachLineStats('/mnt/c/repo', entries, { wslDistro: 'Ubuntu' }) + + expect(lstatPaths).toEqual([target]) + expect(entries[0].added).toBe(1) + }) + + // One case per spelling in a single test: the guest row pins that re-spelling still happens, so + // the whole test dies if the production hunks are reverted, and each verbatim row pins one half + // of the guard that keeps a path this process can already open from being re-spelt onto nothing. + it('re-spells the guest form only, leaving every host-openable spelling byte-identical', async () => { + const cases: { worktree: string; expected: string }[] = [ + { worktree: GUEST_WORKTREE, expected: UNC_WORKTREE }, + { worktree: UNC_WORKTREE, expected: UNC_WORKTREE }, + { + worktree: '//wsl.localhost/Ubuntu/home/me/repo', + expected: '//wsl.localhost/Ubuntu/home/me/repo' + }, + { worktree: '//wsl$/Ubuntu/home/me/repo', expected: '//wsl$/Ubuntu/home/me/repo' }, + { worktree: '/home/me/my repo ', expected: '/home/me/my repo ' } + ] + + for (const { worktree, expected } of cases) { + lstatPaths.length = 0 + untrackedFiles.clear() + const target = path.join(expected, 'fresh.txt') + untrackedFiles.set(target, 'one\ntwo\n') + const entries = [untrackedEntry('fresh.txt')] + + await attachLineStats(worktree, entries, { wslDistro: 'Ubuntu' }) + + expect(lstatPaths, worktree).toEqual([target]) + expect(entries[0].added, worktree).toBe(2) + } + }) +}) diff --git a/src/main/git/status.test.ts b/src/main/git/status.test.ts index b8ab930cc86..4675e3a57a2 100644 --- a/src/main/git/status.test.ts +++ b/src/main/git/status.test.ts @@ -15,8 +15,7 @@ const { readFileMock, statMock, rmMock, - accessMock, - existsSyncMock + accessMock } = vi.hoisted(() => ({ gitExecFileAsyncMock: vi.fn(), gitExecFileAsyncBufferMock: vi.fn(), @@ -26,8 +25,7 @@ const { readFileMock: vi.fn(), statMock: vi.fn(), rmMock: vi.fn(), - accessMock: vi.fn(), - existsSyncMock: vi.fn() + accessMock: vi.fn() })) vi.mock('./runner', () => @@ -49,11 +47,6 @@ vi.mock('fs/promises', () => }) ) -// Why still here: unmerged-entry parsing probes the working tree through node:fs directly. -vi.mock('fs', () => ({ - existsSync: existsSyncMock -})) - vi.mock('../../shared/node-bounded-file-reader', async (importOriginal) => createBoundedFileReaderModuleMock(await importOriginal(), { readFileMock, @@ -71,7 +64,6 @@ describe('getStatus', () => { gitStreamOptionsMock.mockReset() lstatMock.mockReset() readFileMock.mockReset() - existsSyncMock.mockReset() accessMock.mockReset() accessMock.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' })) // Why: untracked line counting stats a file before reading it; any @@ -130,11 +122,9 @@ describe('getStatus', () => { }) }) - it('falls back to modified when the filesystem existence check throws', async () => { + it('falls back to modified when the working-tree probe fails for a non-absence reason', async () => { readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n') - existsSyncMock.mockImplementation(() => { - throw new Error('stat failed') - }) + accessMock.mockRejectedValue(Object.assign(new Error('EIO'), { code: 'EIO' })) gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: 'u AU N... 100644 100644 100644 100644 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb cccccccccccccccccccccccccccccccccccccccc src/new.ts\n' @@ -146,6 +136,61 @@ describe('getStatus', () => { expect(result.entries[0]?.conflictKind).toBe('added_by_us') }) + // Why both cases normalize separators: git reports the worktree in the WSL guest namespace, and + // the assertion is about which path is probed, not which separator this host's `path` emits. + it('probes the conflict working tree through the distro spelling on Windows', async () => { + const platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('win32') + readFileMock.mockResolvedValue('gitdir: /home/me/repo/.git/worktrees/feature\n') + accessMock.mockImplementation(async (target: string) => { + if (String(target).endsWith('new.ts')) { + return undefined + } + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) + }) + gitExecFileAsyncMock.mockResolvedValueOnce({ + stdout: + 'u DU N... 100644 100644 100644 100644 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb cccccccccccccccccccccccccccccccccccccccc src/new.ts\n' + }) + + try { + const result = await getStatus('/home/me/repo/feature', { wslDistro: 'Ubuntu' }) + + const probed = accessMock.mock.calls.map(([target]) => String(target).replaceAll('\\', '/')) + expect(probed).toContain('//wsl.localhost/Ubuntu/home/me/repo/feature/src/new.ts') + expect(result.entries[0]?.status).toBe('modified') + expect(result.entries[0]?.conflictKind).toBe('deleted_by_us') + // The conflict-marker probes travel the same way. + expect( + probed.filter((target) => + target.startsWith('//wsl.localhost/Ubuntu/home/me/repo/.git/worktrees/feature/') + ) + ).toHaveLength(4) + } finally { + platformSpy.mockRestore() + } + }) + + it('probes shared symlinks through the distro spelling on Windows', async () => { + const platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('win32') + readFileMock.mockResolvedValue('gitdir: /home/me/repo/.git/worktrees/feature\n') + lstatMock.mockResolvedValue({ isSymbolicLink: () => true }) + gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '? node_modules\n' }) + + try { + const result = await getStatus('/home/me/repo/feature', { + wslDistro: 'Ubuntu', + sharedLinkPaths: ['node_modules'] + }) + + expect(String(lstatMock.mock.calls[0]?.[0]).replaceAll('\\', '/')).toContain( + '//wsl.localhost/Ubuntu/home/me/repo/feature/node_modules' + ) + expect(result.entries).toEqual([]) + } finally { + platformSpy.mockRestore() + } + }) + it('passes core.quotePath=false and round-trips UTF-8 paths', async () => { readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n') gitExecFileAsyncMock.mockResolvedValueOnce({ @@ -632,7 +677,6 @@ describe('getStatus', () => { it('caps unmerged conflicts and keeps the visible conflict rows', async () => { readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n') - existsSyncMock.mockReturnValue(true) const lines = [ 'u UU S... 160000 160000 160000 160000 aa bb cc vendor/submodule', ...Array.from( @@ -655,7 +699,6 @@ describe('getStatus', () => { it('keeps an early conflict ahead of later ordinary rows at the cap', async () => { readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n') - existsSyncMock.mockReturnValue(true) const lines = [ '? before.ts', 'u UU N... 100644 100644 100644 100644 aa bb cc conflict.ts', diff --git a/src/main/git/worktree-add.ts b/src/main/git/worktree-add.ts index 3cd761f4d13..ea6ec704b46 100644 --- a/src/main/git/worktree-add.ts +++ b/src/main/git/worktree-add.ts @@ -4,6 +4,7 @@ import type { LocalBaseRefUpdateSuggestion } from '../../shared/worktree/base-ref-drift-types' import { windowsLongPathGitArgs } from '../../shared/windows-long-path-git-args' +import { withRepoRefMaintenancePaused } from './local-repo-ref-maintenance' import { gitExecFileAsync } from './runner' import { runWithGitReadCacheInvalidation } from './status' import { invalidateWslLinkedWorktreeGitRouting } from './wsl-linked-worktree-git-routing' @@ -149,15 +150,17 @@ export async function addWorktree( options: AddWorktreeOptions = {} ): Promise { try { - return await runWithGitReadCacheInvalidation(() => - performAddWorktree( - repoPath, - worktreePath, - branch, - baseBranch, - refreshLocalBaseRef, - noCheckout, - options + return await withRepoRefMaintenancePaused('worktree-add', () => + runWithGitReadCacheInvalidation(() => + performAddWorktree( + repoPath, + worktreePath, + branch, + baseBranch, + refreshLocalBaseRef, + noCheckout, + options + ) ) ) } finally { diff --git a/src/main/git/worktree-base-divergence-real-git.test.ts b/src/main/git/worktree-base-divergence-real-git.test.ts new file mode 100644 index 00000000000..e17192cf914 --- /dev/null +++ b/src/main/git/worktree-base-divergence-real-git.test.ts @@ -0,0 +1,99 @@ +import { execFileSync } from 'node:child_process' +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { + measureRetargetDivergence, + RETARGET_MAX_COMMIT_DIVERGENCE +} from './worktree-base-divergence' + +const tempRoots: string[] = [] + +function git(cwd: string, args: string[]): string { + return execFileSync('git', args, { + cwd, + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'pipe'] + }).trim() +} + +async function createRepo(): Promise { + const root = await mkdtemp(join(tmpdir(), 'orca-base-divergence-')) + tempRoots.push(root) + const repoPath = join(root, 'repo') + execFileSync('git', ['init', '--quiet', repoPath]) + git(repoPath, ['symbolic-ref', 'HEAD', 'refs/heads/main']) + git(repoPath, ['config', 'user.email', 'test@example.com']) + git(repoPath, ['config', 'user.name', 'Test User']) + await writeFile(join(repoPath, 'version.txt'), 'one\n') + git(repoPath, ['add', 'version.txt']) + git(repoPath, ['commit', '--quiet', '-m', 'initial']) + return repoPath +} + +function commitEmpty(repoPath: string, count: number): void { + for (let index = 0; index < count; index += 1) { + git(repoPath, ['commit', '--quiet', '--allow-empty', '-m', `commit ${index}`]) + } +} + +afterEach(async () => { + await Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +describe('measureRetargetDivergence with real Git', () => { + it('allows the drift between a local branch and its remote-tracking copy', async () => { + const repoPath = await createRepo() + git(repoPath, ['update-ref', 'refs/remotes/origin/main', 'HEAD']) + commitEmpty(repoPath, 5) + git(repoPath, ['update-ref', 'refs/remotes/origin/main', 'HEAD']) + git(repoPath, ['reset', '--hard', '--quiet', 'HEAD~3']) + + await expect( + measureRetargetDivergence(repoPath, 'refs/heads/main', 'refs/remotes/origin/main') + ).resolves.toBe('within') + }) + + it('counts drift in both directions', async () => { + const repoPath = await createRepo() + const forkPoint = git(repoPath, ['rev-parse', 'HEAD']) + commitEmpty(repoPath, RETARGET_MAX_COMMIT_DIVERGENCE) + git(repoPath, ['update-ref', 'refs/remotes/origin/main', 'HEAD']) + git(repoPath, ['reset', '--hard', '--quiet', forkPoint]) + commitEmpty(repoPath, 1) + + // 100 ahead + 1 behind is over the cap even though neither side alone exceeds it. + await expect( + measureRetargetDivergence(repoPath, 'refs/heads/main', 'refs/remotes/origin/main') + ).resolves.toBe('exceeded') + }) + + it('refuses a base that has drifted past the cap', async () => { + const repoPath = await createRepo() + git(repoPath, ['update-ref', 'refs/remotes/origin/main', 'HEAD']) + commitEmpty(repoPath, RETARGET_MAX_COMMIT_DIVERGENCE + 1) + + await expect( + measureRetargetDivergence(repoPath, 'refs/remotes/origin/main', 'refs/heads/main') + ).resolves.toBe('exceeded') + }) + + it('refuses unrelated histories, which share no commits at all', async () => { + const repoPath = await createRepo() + git(repoPath, ['checkout', '--quiet', '--orphan', 'unrelated']) + git(repoPath, ['commit', '--quiet', '--allow-empty', '-m', 'unrelated root']) + + await expect( + measureRetargetDivergence(repoPath, 'refs/heads/main', 'refs/heads/unrelated') + ).resolves.toBe('exceeded') + }) + + it('reports an unreadable ref as unverifiable, not as excess drift', async () => { + const repoPath = await createRepo() + + await expect( + measureRetargetDivergence(repoPath, 'refs/heads/main', 'refs/heads/missing') + ).resolves.toBe('unknown') + }) +}) diff --git a/src/main/git/worktree-base-divergence.test.ts b/src/main/git/worktree-base-divergence.test.ts new file mode 100644 index 00000000000..88eec6a6b11 --- /dev/null +++ b/src/main/git/worktree-base-divergence.test.ts @@ -0,0 +1,181 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ gitExecFileAsync: vi.fn() })) + +vi.mock('./runner', () => ({ gitExecFileAsync: mocks.gitExecFileAsync })) + +import { GIT_READ_TIMEOUT_MS } from './command-runner/git-command-timeout' +import { WSL_GIT_READ_ENVIRONMENT_WAIT_MS } from './wsl-git-read-environment' +import { + measureRetargetDivergence, + RETARGET_DIVERGENCE_BUDGET_MS +} from './worktree-base-divergence' + +type ExecOptions = { cwd: string; timeout?: number; wslDistro?: string; signal?: AbortSignal } + +function callOptions(): ExecOptions[] { + return mocks.gitExecFileAsync.mock.calls.map((call) => call[1] as ExecOptions) +} + +function subcommands(): string[] { + return mocks.gitExecFileAsync.mock.calls.map((call) => (call[0] as string[])[0]!) +} + +function answerProbes(count: string, mergeBase = 'abc123\n') { + mocks.gitExecFileAsync.mockImplementation(async (args: string[]) => + args[0] === 'merge-base' ? { stdout: mergeBase } : { stdout: count } + ) +} + +function exitCodeError(code: number): Error & { code: number } { + return Object.assign(new Error('git exited'), { code }) +} + +beforeEach(() => { + mocks.gitExecFileAsync.mockReset() +}) + +describe('measureRetargetDivergence deadlines', () => { + it('puts every probe under one shared budget, not a budget each', async () => { + answerProbes('3\n') + + await expect( + measureRetargetDivergence('/repo', 'refs/heads/main', 'refs/remotes/origin/main') + ).resolves.toBe('within') + + expect(subcommands()).toEqual(['rev-list', 'rev-list', 'merge-base']) + const signals = callOptions().map((options) => options.signal) + // One signal object across all three: the counts and merge-base are staged, so per-probe + // budgets would let the check cost the sum of them. + expect(new Set(signals).size).toBe(1) + expect(signals[0]).toBeInstanceOf(AbortSignal) + }) + + it('also gives each probe a command timeout well below git default read deadline', async () => { + answerProbes('3\n') + + await measureRetargetDivergence('/repo', 'refs/heads/main', 'refs/remotes/origin/main') + + // The signal covers admission queueing and the WSL environment wait, which start before a + // command timeout exists; the timeout still covers a hung spawn. + for (const options of callOptions()) { + expect(options.timeout).toBe(RETARGET_DIVERGENCE_BUDGET_MS) + } + expect(RETARGET_DIVERGENCE_BUDGET_MS).toBeLessThan(GIT_READ_TIMEOUT_MS) + }) + + it('really aborts the in-flight probes when the shared budget expires', async () => { + // A probe that behaves like a slow walk: it produces nothing on its own and only settles when + // its signal fires. If the budget never fired, or never reached the probe, this hangs and the + // test fails on its own timeout rather than passing on a signal that does nothing. + mocks.gitExecFileAsync.mockImplementation( + (_args: string[], options: ExecOptions) => + new Promise((_resolve, reject) => { + options.signal?.addEventListener( + 'abort', + () => + reject( + Object.assign(new Error('The operation was aborted.'), { name: 'AbortError' }) + ), + { once: true } + ) + }) + ) + + await expect( + measureRetargetDivergence('/repo', 'refs/heads/main', 'refs/remotes/origin/main', { + budgetMsForTest: 25 + }) + ).resolves.toBe('unknown') + }) + + it('clears the WSL read-environment wait, which starts before any command timeout', () => { + // Equal to it would make the first WSL-routed create of a session `unknown` by construction, + // and the WSL numbers meaningless. + expect(RETARGET_DIVERGENCE_BUDGET_MS).toBeGreaterThan(WSL_GIT_READ_ENVIRONMENT_WAIT_MS) + expect(RETARGET_DIVERGENCE_BUDGET_MS).toBeLessThan(GIT_READ_TIMEOUT_MS) + }) + + it('stops the probes when the create itself is cancelled, without waiting for the budget', async () => { + mocks.gitExecFileAsync.mockImplementation( + (_args: string[], options: ExecOptions) => + new Promise((_resolve, reject) => { + options.signal?.addEventListener( + 'abort', + () => + reject( + Object.assign(new Error('The operation was aborted.'), { name: 'AbortError' }) + ), + { once: true } + ) + }) + ) + const controller = new AbortController() + const pending = measureRetargetDivergence( + '/repo', + 'refs/heads/main', + 'refs/remotes/origin/main', + // A budget long enough that only the caller's cancellation can end this in time. + { signal: controller.signal, budgetMsForTest: 60_000 } + ) + controller.abort() + + await expect(pending).resolves.toBe('unknown') + }) + + it('reports a blown deadline as unverifiable rather than as excess drift', async () => { + mocks.gitExecFileAsync.mockRejectedValue(new Error('git timed out.')) + + await expect( + measureRetargetDivergence('/repo', 'refs/heads/main', 'refs/remotes/origin/main') + ).resolves.toBe('unknown') + // A count that never answered must not go on to spend a merge-base walk. + expect(subcommands()).not.toContain('merge-base') + }) + + it('separates merge-base saying no from merge-base failing', async () => { + mocks.gitExecFileAsync.mockImplementation(async (args: string[]) => { + if (args[0] === 'merge-base') { + // Exit 1 is Git's answer for unrelated histories. + throw exitCodeError(1) + } + return { stdout: '2\n' } + }) + await expect( + measureRetargetDivergence('/repo', 'refs/heads/main', 'refs/remotes/origin/main') + ).resolves.toBe('exceeded') + + mocks.gitExecFileAsync.mockReset() + mocks.gitExecFileAsync.mockImplementation(async (args: string[]) => { + if (args[0] === 'merge-base') { + // A timeout carries no exit code and must not be read as "no common ancestor". + throw new Error('git timed out.') + } + return { stdout: '2\n' } + }) + await expect( + measureRetargetDivergence('/repo', 'refs/heads/main', 'refs/remotes/origin/main') + ).resolves.toBe('unknown') + }) + + it('reports drift past the cap without spending a merge-base walk', async () => { + answerProbes('101\n') + + await expect( + measureRetargetDivergence('/repo', 'refs/heads/main', 'refs/remotes/origin/main') + ).resolves.toBe('exceeded') + expect(subcommands()).not.toContain('merge-base') + }) + + it('routes every probe to the caller-named WSL distro', async () => { + answerProbes('1\n') + + await measureRetargetDivergence('/repo', 'refs/heads/main', 'refs/remotes/origin/main', { + wslDistro: 'Ubuntu' + }) + + for (const options of callOptions()) { + expect(options).toMatchObject({ cwd: '/repo', wslDistro: 'Ubuntu' }) + } + }) +}) diff --git a/src/main/git/worktree-base-divergence.ts b/src/main/git/worktree-base-divergence.ts new file mode 100644 index 00000000000..c7c924c2f24 --- /dev/null +++ b/src/main/git/worktree-base-divergence.ts @@ -0,0 +1,165 @@ +import { WSL_GIT_READ_ENVIRONMENT_WAIT_MS } from './wsl-git-read-environment' +import { gitExecFileAsync } from './runner' + +export type RetargetDivergenceOptions = { + wslDistro?: string + /** The create's own cancellation signal. Without it a cancelled create leaves these probes + * running until the budget expires. */ + signal?: AbortSignal + /** Shortens only the end-to-end budget so a test can observe a real abort; production always + * uses the constant. Mirrors `timeoutMsForTest` on the git exec options. */ + budgetMsForTest?: number +} + +/** `unknown` is deliberately not folded into `exceeded`: "the bound says no" and "the bound could + * not be evaluated" have different causes and different fixes, and only the second one means a + * retarget that would have been cheap was skipped. `unknown` covers a blown deadline, a + * cancelled create, and an ordinary Git failure alike — it is "no answer", not "slow". */ +export type RetargetDivergence = 'within' | 'exceeded' | 'unknown' + +/** + * How far two bases may drift and still be worth retargeting a prepared checkout between. + * + * Measured on a 21,715-file repo: a local `main` and its `origin/main` were 5 commits and 74 + * files apart, while an abandoned fork's `main` — same branch name, so the same base family — + * was 8,173 commits and 21,708 files from `origin/main`, i.e. a whole-tree checkout. A commit + * count separates those by three orders of magnitude, so it is the cheap proxy for the tree diff + * the retarget reset would have to write. + */ +export const RETARGET_MAX_COMMIT_DIVERGENCE = 100 + +/** + * Headroom for the walk itself, on top of the worst pre-spawn wait. + * + * ~3x the slowest walk measured on the 12GB/80k-ref repo (180ms to reject, 57ms to allow), so a + * cold WSL environment probe cannot eat the whole budget and make the answer `unknown` by + * construction. + */ +const RETARGET_DIVERGENCE_WALK_HEADROOM_MS = 500 + +/** + * End-to-end deadline for the whole check, not per probe. + * + * Derived from the WSL read-environment wait rather than picked: `git-exec-file` awaits that probe + * before a command timeout even exists, so a budget merely equal to it would guarantee `unknown` + * on the first WSL-routed create of a session and make the WSL numbers meaningless. Deriving it + * keeps that relationship explicit instead of coincidental. + * + * Sized against what it competes with: this exists only to decide whether to skip a ~4.1s p50 cold + * `worktree add`, so when it expires the create pays the budget and then does that add anyway. The + * total stays under that add even on Windows, where a killed probe also awaits `taskkill /t`. + * + * It must be a signal, not just a per-command timeout, because a per-command timeout starts only + * after `git-exec-file` has awaited admission and the WSL read-environment probe, and because the + * counts and `merge-base` are staged — two per-probe budgets in sequence would be twice the number + * written here. + */ +export const RETARGET_DIVERGENCE_BUDGET_MS = + WSL_GIT_READ_ENVIRONMENT_WAIT_MS + RETARGET_DIVERGENCE_WALK_HEADROOM_MS + +function probeOptions( + repoPath: string, + options: RetargetDivergenceOptions, + signal: AbortSignal +): { cwd: string; wslDistro?: string; signal: AbortSignal; timeout: number } { + // Built field by field rather than spread: the caller's bag carries a test-only key that must + // never reach git's exec options. + // Both bounds: the signal covers the pre-spawn waits (admission queue, WSL environment) that a + // command timeout cannot see, and the timeout keeps the bounded tree-kill path for a hung spawn. + return { + cwd: repoPath, + ...(options.wslDistro ? { wslDistro: options.wslDistro } : {}), + signal, + timeout: RETARGET_DIVERGENCE_BUDGET_MS + } +} + +/** Commits reachable from `toRef` but not `fromRef`, capped; null when the probe was unusable. */ +async function countCommitsAhead( + repoPath: string, + fromRef: string, + toRef: string, + options: RetargetDivergenceOptions, + signal: AbortSignal +): Promise { + try { + // `--max-count` stops the walk, so an unrelated history costs a bounded number of commits + // rather than a full traversal. Both flags predate the Git 2.25 baseline. + // `--end-of-options` (Git 2.24) because a range whose left side began with `-` would + // otherwise parse as an option; callers only pass `refs/`-qualified names today, and this + // keeps that from being load-bearing. + const { stdout } = await gitExecFileAsync( + [ + 'rev-list', + '--count', + `--max-count=${RETARGET_MAX_COMMIT_DIVERGENCE + 1}`, + '--end-of-options', + `${fromRef}..${toRef}` + ], + probeOptions(repoPath, options, signal) + ) + const count = Number.parseInt(stdout.trim(), 10) + return Number.isNaN(count) ? null : count + } catch { + return null + } +} + +/** True/false when Git decided, null when the probe was unusable. */ +async function hasCommonHistory( + repoPath: string, + leftRef: string, + rightRef: string, + options: RetargetDivergenceOptions, + signal: AbortSignal +): Promise { + try { + const { stdout } = await gitExecFileAsync( + ['merge-base', '--end-of-options', leftRef, rightRef], + probeOptions(repoPath, options, signal) + ) + return stdout.trim().length > 0 + } catch (error) { + // Exit 1 is `merge-base` reporting no common ancestor, which is an answer. A timeout or abort + // carries no exit code and must not be read as one. + return (error as { code?: unknown }).code === 1 ? false : null + } +} + +/** + * Whether retargeting a checkout prepared at `preparedBase` onto `targetBase` stays cheap. + * + * Fails closed on error, slowness, and cancellation alike: only a positive `within` authorizes + * reusing the checkout, so every other outcome lands on the cold create path. + */ +export async function measureRetargetDivergence( + repoPath: string, + preparedBase: string, + targetBase: string, + options: RetargetDivergenceOptions = {} +): Promise { + const budget = AbortSignal.timeout(options.budgetMsForTest ?? RETARGET_DIVERGENCE_BUDGET_MS) + // Combined so cancelling the create stops the probes immediately rather than at the deadline. + const signal = options.signal ? AbortSignal.any([options.signal, budget]) : budget + // Both directions: commits the target adds decide what the reset writes, commits only the + // preparation has decide what it must delete. + const [ahead, behind] = await Promise.all([ + countCommitsAhead(repoPath, preparedBase, targetBase, options, signal), + countCommitsAhead(repoPath, targetBase, preparedBase, options, signal) + ]) + if (ahead === null || behind === null) { + return 'unknown' + } + if (ahead + behind > RETARGET_MAX_COMMIT_DIVERGENCE) { + return 'exceeded' + } + // Only now: `merge-base` has no `--max-count`, so on unrelated histories it would walk both of + // them in full. Reaching here already proved neither side is more than the cap ahead of the + // other, which bounds that walk — and unrelated histories of any size fail the counts first. + // Required because unrelated histories replace the whole tree however few commits they carry. + const shareHistory = await hasCommonHistory(repoPath, preparedBase, targetBase, options, signal) + if (shareHistory === null) { + return 'unknown' + } + return shareHistory ? 'within' : 'exceeded' +} diff --git a/src/main/git/worktree-base-ref-probe.ts b/src/main/git/worktree-base-ref-probe.ts index 8e44877ab76..87c761ebbcc 100644 --- a/src/main/git/worktree-base-ref-probe.ts +++ b/src/main/git/worktree-base-ref-probe.ts @@ -42,6 +42,21 @@ export async function hasWorktreeBaseCommitRef( return (await resolveWorktreeBaseCommitOid(repoPath, qualifiedRef, options)) !== null } +/** + * The qualified ref a worktree base names in this repo, or the base unchanged when nothing + * matches. Callers that key on a base must compare this, not the raw string, or `main` and + * `refs/heads/main` look like different bases. + */ +export function resolveLocalWorktreeBaseRef( + repoPath: string, + baseRef: string, + options: GitExecOptions = {} +): Promise { + return resolveWorktreeAddBaseRef(baseRef, (qualifiedRef) => + hasWorktreeBaseCommitRef(repoPath, qualifiedRef, options) + ) +} + /** * Whether a worktree base — a qualified ref, a short branch or remote name, or a * full commit id — already resolves in this repo's own object/ref store. diff --git a/src/main/git/worktree-branch-removal.ts b/src/main/git/worktree-branch-removal.ts index 5e64b4a582d..08b6b21a1e3 100644 --- a/src/main/git/worktree-branch-removal.ts +++ b/src/main/git/worktree-branch-removal.ts @@ -4,6 +4,7 @@ import { } from '../../shared/git-branch-cleanup' import type { RemoveWorktreeResult } from '../../shared/worktree/create-types' import { withLocalGitCapabilityCacheForExecution } from './git-capability-state' +import { withRepoRefMaintenancePaused } from './local-repo-ref-maintenance' import { gitExecFileAsync } from './runner' import { parseWorktreeList } from './worktree-list-parser' import type { GitWorktreeExecOptions, RemoveWorktreeOptions } from './worktree-operation-options' @@ -152,7 +153,11 @@ export async function forceDeleteLocalBranch( } // Why: stale toast actions must not delete a branch that moved; `update-ref -d` deletes only if the ref still == expectedHead. try { - await runGit(['update-ref', '-d', `refs/heads/${branchName}`, expectedHead], repoPath) + // `update-ref -d` needs the packed-refs lock a running idle pack holds while + // it rewrites; waits it out rather than cancelling the pack. + await withRepoRefMaintenancePaused('branch-delete', () => + runGit(['update-ref', '-d', `refs/heads/${branchName}`, expectedHead], repoPath) + ) } catch { throw new Error( `Local branch "${branchName}" changed after the workspace was deleted. Review it before deleting it.` diff --git a/src/main/git/worktree-create-preparation-real-git.test.ts b/src/main/git/worktree-create-preparation-real-git.test.ts index bdb1e9fcc4b..63f8021a7ae 100644 --- a/src/main/git/worktree-create-preparation-real-git.test.ts +++ b/src/main/git/worktree-create-preparation-real-git.test.ts @@ -68,6 +68,55 @@ describe('prepared worktree creation with real Git', () => { expect(await listWorktrees(repoPath, { includeCreatePreparations: true })).toHaveLength(1) }) + it('lands a cross-base retarget on exactly the requested commit', async () => { + const { repoPath, root } = await createRepo() + const preparationRoot = join(root, WORKTREE_CREATE_PREPARATION_DIRECTORY) + const preparedPath = join(preparationRoot, `${process.pid}-retarget`) + const finalPath = join(root, 'retargeted-worktree') + await mkdir(preparationRoot, { recursive: true }) + + await writeFile(join(repoPath, 'shared.txt'), 'kept\n') + git(repoPath, ['add', 'shared.txt']) + git(repoPath, ['commit', '--quiet', '-m', 'local main']) + const localMainHead = git(repoPath, ['rev-parse', 'HEAD']) + + // A remote-tracking `main` that diverged: different content, an extra file, and one deletion. + git(repoPath, ['checkout', '--quiet', '-b', 'upstream-main']) + await writeFile(join(repoPath, 'version.txt'), 'two\n') + await writeFile(join(repoPath, 'only-upstream.txt'), 'upstream\n') + git(repoPath, ['rm', '--quiet', 'shared.txt']) + git(repoPath, ['add', 'version.txt', 'only-upstream.txt']) + git(repoPath, ['commit', '--quiet', '-m', 'upstream main']) + git(repoPath, ['update-ref', 'refs/remotes/origin/main', 'HEAD']) + git(repoPath, ['checkout', '--quiet', 'main']) + git(repoPath, ['branch', '--quiet', '-D', 'upstream-main']) + + await prepareWorktreeCreateCheckout( + repoPath, + preparedPath, + 'refs/remotes/origin/main', + createWorktreePreparationLockReason('retarget-test') + ) + expect(git(preparedPath, ['rev-parse', 'HEAD'])).not.toBe(localMainHead) + + await finalizePreparedWorktree(repoPath, preparedPath, finalPath, 'feature/retargeted', 'main') + + expect(git(finalPath, ['rev-parse', 'HEAD'])).toBe(localMainHead) + // A retarget that left stale files behind would be a wrong checkout, not just a slow one. + expect(git(finalPath, ['status', '--porcelain'])).toBe('') + expect((await readFile(join(finalPath, 'version.txt'), 'utf8')).replaceAll('\r\n', '\n')).toBe( + 'one\n' + ) + expect((await readFile(join(finalPath, 'shared.txt'), 'utf8')).replaceAll('\r\n', '\n')).toBe( + 'kept\n' + ) + await expect(readFile(join(finalPath, 'only-upstream.txt'), 'utf8')).rejects.toThrow() + expect(git(finalPath, ['branch', '--show-current'])).toBe('feature/retargeted') + expect(git(finalPath, ['config', '--get', 'branch.feature/retargeted.base'])).toBe( + 'refs/heads/main' + ) + }) + it('hides the preparation, retargets an advanced base, and attaches the final branch', async () => { const { repoPath, root } = await createRepo() const preparationRoot = join(root, WORKTREE_CREATE_PREPARATION_DIRECTORY) diff --git a/src/main/git/worktree-create-preparation.ts b/src/main/git/worktree-create-preparation.ts index 3be0fee1648..b60dc01ec33 100644 --- a/src/main/git/worktree-create-preparation.ts +++ b/src/main/git/worktree-create-preparation.ts @@ -10,6 +10,7 @@ import { WORKTREE_REMOVAL_REGISTRATION_TIMEOUT_MS } from './worktree' import { hasWorktreeBaseCommitRef } from './worktree-base-ref-probe' +import { withRepoRefMaintenancePaused } from './local-repo-ref-maintenance' import { gitExecFileAsync } from './runner' import { runWithGitReadCacheInvalidation } from './status' import { invalidateWslLinkedWorktreeGitRouting } from './wsl-linked-worktree-git-routing' @@ -69,46 +70,48 @@ export async function prepareWorktreeCreateCheckout( options: GitWorktreeExecOptions = {} ): Promise { try { - await runWithGitReadCacheInvalidation(async () => { - const effectiveBase = await resolveWorktreeAddBaseRef(baseBranch, (qualifiedRef) => - hasWorktreeBaseCommitRef(repoPath, qualifiedRef, options) - ) - try { - await gitExecFileAsync( - [ - ...windowsLongPathGitArgs(repoPath), - 'worktree', - 'add', - '--detach', - '--no-checkout', - worktreePath, - effectiveBase - ], - { ...gitExecOptions(repoPath, options), timeout: resolveWorktreeAddTimeoutMs() } + await withRepoRefMaintenancePaused('worktree-prepare', () => + runWithGitReadCacheInvalidation(async () => { + const effectiveBase = await resolveWorktreeAddBaseRef(baseBranch, (qualifiedRef) => + hasWorktreeBaseCommitRef(repoPath, qualifiedRef, options) ) - // The add just wrote the marker; drop any pre-create route before the reset routes Git. - invalidateWslLinkedWorktreeGitRouting(worktreePath) - // Why: reset materializes files without running user post-checkout hooks before submit. - await gitExecFileAsync( - [...windowsLongPathGitArgs(worktreePath), 'reset', '--hard', effectiveBase], - { ...gitExecOptions(worktreePath, options), timeout: resolveWorktreeAddTimeoutMs() } - ) - await gitExecFileAsync( - [ - ...windowsLongPathGitArgs(repoPath), - 'worktree', - 'lock', - '--reason', - lockReason, - worktreePath - ], - { ...gitExecOptions(repoPath, options), timeout: resolveWorktreeAddTimeoutMs() } - ) - } catch (error) { - await performDiscardPreparedWorktree(repoPath, worktreePath, options).catch(() => {}) - throw error - } - }) + try { + await gitExecFileAsync( + [ + ...windowsLongPathGitArgs(repoPath), + 'worktree', + 'add', + '--detach', + '--no-checkout', + worktreePath, + effectiveBase + ], + { ...gitExecOptions(repoPath, options), timeout: resolveWorktreeAddTimeoutMs() } + ) + // The add just wrote the marker; drop any pre-create route before the reset routes Git. + invalidateWslLinkedWorktreeGitRouting(worktreePath) + // Why: reset materializes files without running user post-checkout hooks before submit. + await gitExecFileAsync( + [...windowsLongPathGitArgs(worktreePath), 'reset', '--hard', effectiveBase], + { ...gitExecOptions(worktreePath, options), timeout: resolveWorktreeAddTimeoutMs() } + ) + await gitExecFileAsync( + [ + ...windowsLongPathGitArgs(repoPath), + 'worktree', + 'lock', + '--reason', + lockReason, + worktreePath + ], + { ...gitExecOptions(repoPath, options), timeout: resolveWorktreeAddTimeoutMs() } + ) + } catch (error) { + await performDiscardPreparedWorktree(repoPath, worktreePath, options).catch(() => {}) + throw error + } + }) + ) } finally { notifyPreparedWorktreeMutation(repoPath) } diff --git a/src/main/git/worktree-diff-stamp-guest-gitdir.test.ts b/src/main/git/worktree-diff-stamp-guest-gitdir.test.ts new file mode 100644 index 00000000000..ba0308b0a12 --- /dev/null +++ b/src/main/git/worktree-diff-stamp-guest-gitdir.test.ts @@ -0,0 +1,60 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { readFileMock, statMock } = vi.hoisted(() => ({ + readFileMock: vi.fn(), + statMock: vi.fn() +})) + +vi.mock('node:fs/promises', () => ({ readFile: readFileMock, stat: statMock })) + +import { readWorktreeDiffStamp } from './source-control/worktree-diff-stamp' + +const slashed = (value: unknown): string => String(value).replaceAll('\\', '/') +const missing = () => Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) + +// A worktree on the Windows drive whose repo lives in the distro's own filesystem: the worktree +// resolves to a drive letter, so the gitdir pointer beside it — which is not drvfs — has no +// drive to derive and needs the distro the diff read already carries. +const HOST_WORKTREE = 'C:/wt/x' +const GUEST_GIT_DIR = '/home/me/repo/.git/worktrees/x' +const HOST_GIT_DIR = '//wsl.localhost/Ubuntu/home/me/repo/.git/worktrees/x' + +describe('readWorktreeDiffStamp with a non-drvfs gitdir pointer', () => { + beforeEach(() => { + readFileMock.mockReset() + statMock.mockReset() + readFileMock.mockImplementation(async (target: string) => { + const value = slashed(target) + if (value === `${HOST_WORKTREE}/.git`) { + return `gitdir: ${GUEST_GIT_DIR}\n` + } + // Detached HEAD, so the stamp needs no ref-store walk. + if (value === `${HOST_GIT_DIR}/HEAD`) { + return `${'a'.repeat(40)}\n` + } + throw missing() + }) + statMock.mockImplementation(async (target: string) => + slashed(target) === `${HOST_WORKTREE}/src/a.ts` + ? { mtimeMs: 1_000, size: 12, ino: 7 } + : Promise.reject(missing()) + ) + }) + + it('resolves the gitdir through the caller-named distro so the diff stays cacheable', async () => { + const platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('win32') + + try { + const stamp = await readWorktreeDiffStamp('/mnt/c/wt/x', 'src/a.ts', true, { + wslDistro: 'Ubuntu' + }) + + // Null here means "cannot prove unchanged", which is what an unreadable HEAD produces — + // correct, but it retires the settled-diff cache for every file in the worktree. + expect(stamp).not.toBeNull() + expect(stamp?.newestMtimeMs).toBe(1_000) + } finally { + platformSpy.mockRestore() + } + }) +}) diff --git a/src/main/git/worktree-diff-stamp-host-paths.test.ts b/src/main/git/worktree-diff-stamp-host-paths.test.ts new file mode 100644 index 00000000000..03fefe2dcf5 --- /dev/null +++ b/src/main/git/worktree-diff-stamp-host-paths.test.ts @@ -0,0 +1,59 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { readFileMock, statMock } = vi.hoisted(() => ({ + readFileMock: vi.fn(), + statMock: vi.fn() +})) + +vi.mock('fs/promises', () => ({ + readFile: readFileMock, + stat: statMock +})) + +import { readWorktreeDiffStamp } from './source-control/worktree-diff-stamp' + +const slashed = (value: unknown): string => String(value).replaceAll('\\', '/') +const missing = () => Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) + +// Why this file: git in a WSL distro reports the worktree as `/mnt/c/...`, and the gitdir resolve +// reaches it on its own. If the working-tree read did not travel the same way, the stamp would be +// built from a real HEAD and a permanently absent file — a settled diff that survives every edit. +describe('readWorktreeDiffStamp on a drvfs-spelled WSL worktree', () => { + beforeEach(() => { + readFileMock.mockReset() + statMock.mockReset() + readFileMock.mockImplementation(async (target: string) => { + const value = slashed(target) + if (value === 'C:/repo/wt/.git') { + return 'gitdir: /mnt/c/repo/.git/worktrees/wt\n' + } + // Detached HEAD, so the stamp needs no ref-store walk. + if (value === 'C:/repo/.git/worktrees/wt/HEAD') { + return `${'a'.repeat(40)}\n` + } + throw missing() + }) + statMock.mockImplementation(async (target: string) => + slashed(target) === 'C:/repo/wt/src/a.ts' + ? { mtimeMs: 1_000, size: 12, ino: 7 } + : Promise.reject(missing()) + ) + }) + + it('stamps the working-tree file through the same host spelling as the gitdir', async () => { + const platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('win32') + + try { + const stamp = await readWorktreeDiffStamp('/mnt/c/repo/wt', 'src/a.ts', true) + + expect(stamp).not.toBeNull() + expect(statMock.mock.calls.map(([target]) => slashed(target))).toContain( + 'C:/repo/wt/src/a.ts' + ) + // The working-tree component is what moves when the user edits, so it has to be present. + expect(stamp?.newestMtimeMs).toBe(1_000) + } finally { + platformSpy.mockRestore() + } + }) +}) diff --git a/src/main/git/worktree-list-porcelain.test.ts b/src/main/git/worktree-list-porcelain.test.ts index dd675832769..5ef3630639d 100644 --- a/src/main/git/worktree-list-porcelain.test.ts +++ b/src/main/git/worktree-list-porcelain.test.ts @@ -243,7 +243,13 @@ describe('listWorktrees', () => { isMainWorktree: false } ]) - expect(resolveGitDirMock).toHaveBeenCalledWith(featureWorktreePath) + // The second argument is what carries the distro when the caller has one; this listing has + // none, and the UNC repo path names the distro on its own. + const gitDirCall = resolveGitDirMock.mock.calls.find( + ([probed]) => probed === featureWorktreePath + ) + expect(gitDirCall).toBeDefined() + expect(gitDirCall?.[1]?.wslDistro).toBeUndefined() // Why: the detection path must not spawn a git subprocess per worktree — // the perf regression in #1131 came from `git sparse-checkout list` firing // on every poll. diff --git a/src/main/git/worktree-listing-created-sparse-distro.test.ts b/src/main/git/worktree-listing-created-sparse-distro.test.ts new file mode 100644 index 00000000000..2b3922d5b9b --- /dev/null +++ b/src/main/git/worktree-listing-created-sparse-distro.test.ts @@ -0,0 +1,105 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + readCheckedOutBranchRefMock, + readFileMock, + readRepoCommonDirFromGitMock, + readRepoLocationMock, + readWorktreeHeadOidMock, + realpathMock, + statMock +} = vi.hoisted(() => ({ + readCheckedOutBranchRefMock: vi.fn(), + readFileMock: vi.fn(), + readRepoCommonDirFromGitMock: vi.fn(), + readRepoLocationMock: vi.fn(), + readWorktreeHeadOidMock: vi.fn(), + realpathMock: vi.fn(), + statMock: vi.fn() +})) + +vi.mock('node:fs/promises', () => ({ + readFile: readFileMock, + realpath: realpathMock, + stat: statMock +})) +// Only Git is stubbed; the sparse probe below runs for real so the distro has somewhere to matter. +vi.mock('./worktree-list-reader', () => ({ + readCheckedOutBranchRef: readCheckedOutBranchRefMock, + readRepoCommonDirFromGit: readRepoCommonDirFromGitMock, + readRepoLocation: readRepoLocationMock, + readTranslatedWorktreeGraph: vi.fn(), + readWorktreeHeadOid: readWorktreeHeadOidMock, + readWorktreeList: vi.fn() +})) + +import { describeCreatedWorktree } from './worktree-listing' + +const slashed = (value: unknown): string => String(value).replaceAll('\\', '/') +const missing = () => Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) + +// The layout that needs the caller's distro: the repo lives in the distro, its worktrees on the +// Windows drive. `git worktree list` reports `/mnt/c/wt/x`, which translates to a drive letter that +// no longer names a distro, and the gitfile beside it points at a guest path with no drive to +// derive — so only the distro the create ran under can resolve it. +const REPO = '\\\\wsl.localhost\\Ubuntu\\home\\me\\repo' +const GUEST_WORKTREE = '/mnt/c/wt/x' +const HOST_WORKTREE = 'C:/wt/x' +const GUEST_GIT_DIR = '/home/me/repo/.git/worktrees/x' +const HOST_GIT_DIR = '//wsl.localhost/Ubuntu/home/me/repo/.git/worktrees/x' +const HEAD_OID = 'a'.repeat(40) + +describe('describeCreatedWorktree on a drvfs-spelled WSL worktree', () => { + beforeEach(() => { + readRepoLocationMock.mockReset() + readRepoLocationMock.mockResolvedValue({ + topLevel: GUEST_WORKTREE, + commonDir: '/home/me/repo/.git' + }) + readRepoCommonDirFromGitMock.mockReset() + readRepoCommonDirFromGitMock.mockResolvedValue('/home/me/repo/.git') + readCheckedOutBranchRefMock.mockReset() + readCheckedOutBranchRefMock.mockResolvedValue('refs/heads/feature') + readWorktreeHeadOidMock.mockReset() + readWorktreeHeadOidMock.mockResolvedValue(HEAD_OID) + realpathMock.mockReset() + realpathMock.mockRejectedValue(missing()) + readFileMock.mockReset() + readFileMock.mockImplementation(async (target: string) => { + const value = slashed(target) + if (value === `${HOST_WORKTREE}/.git`) { + return `gitdir: ${GUEST_GIT_DIR}\n` + } + // No `commondir`, so the gitdir is its own common dir and the config read stays in one + // namespace — the pointer resolve is the only thing under test. + if (value === `${HOST_GIT_DIR}/config`) { + return '[core]\n\tsparseCheckout = true\n' + } + throw missing() + }) + statMock.mockReset() + statMock.mockImplementation(async (target: string) => + slashed(target) === `${HOST_GIT_DIR}/info/sparse-checkout` + ? { isFile: () => true, size: 12 } + : Promise.reject(missing()) + ) + }) + + it('marks the recovered row sparse through the caller-named distro', async () => { + const platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('win32') + + try { + const described = await describeCreatedWorktree(REPO, 'C:\\wt\\x', 'feature', { + wslDistro: 'Ubuntu' + }) + + expect(described?.path && slashed(described.path)).toBe(HOST_WORKTREE) + expect(described?.isSparse).toBe(true) + expect(statMock.mock.calls.map(([target]) => slashed(target))).toContain( + `${HOST_GIT_DIR}/info/sparse-checkout` + ) + } finally { + platformSpy.mockRestore() + } + }) +}) diff --git a/src/main/git/worktree-listing-sparse-distro.test.ts b/src/main/git/worktree-listing-sparse-distro.test.ts new file mode 100644 index 00000000000..d08a7245e24 --- /dev/null +++ b/src/main/git/worktree-listing-sparse-distro.test.ts @@ -0,0 +1,68 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { GitWorktreeInfo } from '../../shared/worktree/types' + +const { detectSparseCheckoutMock, readWorktreeListMock, readTranslatedWorktreeGraphMock } = + vi.hoisted(() => ({ + detectSparseCheckoutMock: vi.fn(), + readWorktreeListMock: vi.fn(), + readTranslatedWorktreeGraphMock: vi.fn() + })) + +vi.mock('./worktree-sparse-state', () => ({ + detectSparseCheckout: detectSparseCheckoutMock, + resolveGitCommonDir: vi.fn() +})) +vi.mock('./worktree-list-reader', () => ({ + readCheckedOutBranchRef: vi.fn(), + readRepoCommonDirFromGit: vi.fn(), + readRepoLocation: vi.fn(), + readTranslatedWorktreeGraph: readTranslatedWorktreeGraphMock, + readWorktreeHeadOid: vi.fn(), + readWorktreeList: readWorktreeListMock +})) + +import { __resetSparseCheckoutStateCacheForTests } from './worktree-sparse-checkout-cache' +import { listWorktreesStrict, listWorktreesUnshared } from './worktree-listing' + +// A WSL repo's sparse probe is pure `fs`, so it only reaches the right namespace if the listing +// hands it the distro the git call already ran under. +const ROW: GitWorktreeInfo = { + path: 'C:\\wt\\x', + head: 'a'.repeat(40), + branch: 'refs/heads/feature', + isBare: false, + isMainWorktree: false +} + +describe('worktree listing sparse annotation', () => { + beforeEach(() => { + detectSparseCheckoutMock.mockReset() + detectSparseCheckoutMock.mockResolvedValue(false) + readWorktreeListMock.mockReset() + readWorktreeListMock.mockResolvedValue([ROW]) + readTranslatedWorktreeGraphMock.mockReset() + readTranslatedWorktreeGraphMock.mockResolvedValue([ROW]) + // The listing now reads through a repo-scoped cache; a warm entry would skip the probe. + __resetSparseCheckoutStateCacheForTests() + }) + + it('passes the listing distro to the strict-list sparse probe', async () => { + await listWorktreesStrict('\\\\wsl.localhost\\Ubuntu\\home\\me\\repo', { wslDistro: 'Ubuntu' }) + + expect(detectSparseCheckoutMock).toHaveBeenCalledWith( + 'C:\\wt\\x', + expect.objectContaining({ wslDistro: 'Ubuntu' }) + ) + }) + + it('passes the listing distro to the unshared-list sparse probe', async () => { + await listWorktreesUnshared('\\\\wsl.localhost\\Ubuntu\\home\\me\\repo', { + wslDistro: 'Ubuntu' + }) + + expect(detectSparseCheckoutMock).toHaveBeenCalledWith( + 'C:\\wt\\x', + expect.objectContaining({ wslDistro: 'Ubuntu' }) + ) + }) +}) diff --git a/src/main/git/worktree-listing.ts b/src/main/git/worktree-listing.ts index 3b600143410..a60819181d4 100644 --- a/src/main/git/worktree-listing.ts +++ b/src/main/git/worktree-listing.ts @@ -19,7 +19,8 @@ import { normalizeLocalBranchRef } from './worktree-operation-options' import { areWorktreePathsEqual, translateWorktreePath } from './worktree-path-comparison' -import { detectSparseCheckout, resolveGitCommonDir } from './worktree-sparse-state' +import { detectSparseCheckoutCached } from './worktree-sparse-checkout-cache' +import { resolveGitCommonDir } from './worktree-sparse-state' import { resolveGitDir } from './source-control/resolve-git-dir' const SPARSE_CHECKOUT_DETECTION_CONCURRENCY = 8 @@ -61,7 +62,7 @@ export async function listWorktreesUnshared( const visibleWorktrees = options.includeCreatePreparations ? worktrees : worktrees.filter((worktree) => !isWorktreeCreatePreparation(worktree)) - return annotateSparseCheckoutStatus(visibleWorktrees) + return annotateSparseCheckoutStatus(repoPath, visibleWorktrees, options) } catch (err) { if (getErrorCode(err) === 'ENOENT') { try { @@ -93,11 +94,13 @@ export async function listWorktreesStrict( const visibleWorktrees = options.includeCreatePreparations ? worktrees : worktrees.filter((worktree) => !isWorktreeCreatePreparation(worktree)) - return annotateSparseCheckoutStatus(visibleWorktrees) + return annotateSparseCheckoutStatus(repoPath, visibleWorktrees, options) } -async function annotateSparseCheckoutStatus( - worktrees: GitWorktreeInfo[] +export async function annotateSparseCheckoutStatus( + repoPath: string, + worktrees: GitWorktreeInfo[], + options: GitWorktreeExecOptions = {} ): Promise { const annotated = [...worktrees] let nextIndex = 0 @@ -110,7 +113,7 @@ async function annotateSparseCheckoutStatus( if (!worktree || worktree.isBare || worktree.isSparse) { continue } - const isSparse = await detectSparseCheckout(worktree.path) + const isSparse = await detectSparseCheckoutCached(repoPath, worktree.path, options) if (isSparse) { annotated[index] = { ...worktree, isSparse } } @@ -253,15 +256,19 @@ export async function describeCreatedWorktree( return undefined } } - const [described] = await annotateSparseCheckoutStatus([ - { - path: translateWorktreePath(created.topLevel, repoPath, options), - head, - branch: expectedRef, - isBare: false, - // `git worktree add` only ever produces a linked worktree. - isMainWorktree: false - } - ]) + const [described] = await annotateSparseCheckoutStatus( + repoPath, + [ + { + path: translateWorktreePath(created.topLevel, repoPath, options), + head, + branch: expectedRef, + isBare: false, + // `git worktree add` only ever produces a linked worktree. + isMainWorktree: false + } + ], + options + ) return described } diff --git a/src/main/git/worktree-move.test.ts b/src/main/git/worktree-move.test.ts index ccfc65e5fc8..9da3ddbb515 100644 --- a/src/main/git/worktree-move.test.ts +++ b/src/main/git/worktree-move.test.ts @@ -5,12 +5,14 @@ const { gitExecFileAsyncMock, gitExecFileSyncMock, translateWslOutputPathsMock, - moveWorktreeDirectoryToTrashMock + moveWorktreeDirectoryToTrashMock, + detectSparseCheckoutMock } = vi.hoisted(() => ({ gitExecFileAsyncMock: vi.fn(), gitExecFileSyncMock: vi.fn(), translateWslOutputPathsMock: vi.fn((output: string) => output), - moveWorktreeDirectoryToTrashMock: vi.fn() + moveWorktreeDirectoryToTrashMock: vi.fn(), + detectSparseCheckoutMock: vi.fn() })) vi.mock('./runner', () => ({ @@ -26,8 +28,17 @@ vi.mock('../worktree-trash', () => ({ scheduleWorktreeTrashDeletion: vi.fn() })) +vi.mock('./worktree-sparse-state', () => ({ + detectSparseCheckout: detectSparseCheckoutMock, + resolveGitCommonDir: vi.fn() +})) + import { moveWorktree } from './worktree' import { registerWorktreeSuiteHooks } from './worktree-test-harness' +import { + __getSparseCheckoutStateCacheSizeForTests, + detectSparseCheckoutCached +} from './worktree-sparse-checkout-cache' registerWorktreeSuiteHooks() @@ -51,4 +62,27 @@ describe('moveWorktree', () => { 'destination exists' ) }) + + it('drops cached sparse-checkout state for both the old and new path', async () => { + detectSparseCheckoutMock.mockResolvedValue(true) + await detectSparseCheckoutCached('/repo', '/ws/cunner') + await detectSparseCheckoutCached('/repo', '/ws/worktree-creation-spinner') + expect(__getSparseCheckoutStateCacheSizeForTests()).toBe(2) + + gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '', stderr: '' }) + await moveWorktree('/repo', '/ws/cunner', '/ws/worktree-creation-spinner') + + expect(__getSparseCheckoutStateCacheSizeForTests()).toBe(0) + }) + + it('drops cached sparse-checkout state for both paths even when the move fails', async () => { + detectSparseCheckoutMock.mockResolvedValue(true) + await detectSparseCheckoutCached('/repo', '/ws/cunner') + await detectSparseCheckoutCached('/repo', '/ws/taken') + + gitExecFileAsyncMock.mockRejectedValueOnce(new Error('fatal: destination exists')) + await expect(moveWorktree('/repo', '/ws/cunner', '/ws/taken')).rejects.toThrow() + + expect(__getSparseCheckoutStateCacheSizeForTests()).toBe(0) + }) }) diff --git a/src/main/git/worktree-move.ts b/src/main/git/worktree-move.ts index e0f460b3f40..efb7bb7157c 100644 --- a/src/main/git/worktree-move.ts +++ b/src/main/git/worktree-move.ts @@ -2,6 +2,7 @@ import { gitExecFileAsync } from './runner' import { runWithGitReadCacheInvalidation } from './status' import { invalidateWslLinkedWorktreeGitRouting } from './wsl-linked-worktree-git-routing' import { bumpWorktreeScanGeneration } from './worktree-scan-cache' +import { invalidateSparseCheckoutState } from './worktree-sparse-checkout-cache' /** * Move a worktree with `git worktree move` (not `fs.rename`, which corrupts the @@ -22,6 +23,8 @@ export async function moveWorktree( // A failed move can still have rewritten one `.git` marker, so re-probe both paths. invalidateWslLinkedWorktreeGitRouting(oldPath) invalidateWslLinkedWorktreeGitRouting(newPath) + invalidateSparseCheckoutState(repoPath, oldPath) + invalidateSparseCheckoutState(repoPath, newPath) bumpWorktreeScanGeneration(repoPath) } } diff --git a/src/main/git/worktree-path-comparison.ts b/src/main/git/worktree-path-comparison.ts index 6bd8fd7aacb..96d423c3caf 100644 --- a/src/main/git/worktree-path-comparison.ts +++ b/src/main/git/worktree-path-comparison.ts @@ -2,18 +2,22 @@ import { posix, win32 } from 'node:path' import type { GitWorktreeExecOptions } from './worktree-operation-options' import { translateWslOutputPaths } from './runner' +/** Normalize a worktree path for cross-platform comparison/keying: resolved, and case-folded on Windows syntax. */ +export function canonicalWorktreePath(pathValue: string, platform = process.platform): string { + return platform === 'win32' || looksLikeWindowsPath(pathValue) + ? win32.normalize(win32.resolve(pathValue)).toLowerCase() + : posix.normalize(posix.resolve(pathValue)) +} + export function areWorktreePathsEqual( leftPath: string, rightPath: string, platform = process.platform ): boolean { if (platform === 'win32' || looksLikeWindowsPath(leftPath) || looksLikeWindowsPath(rightPath)) { - return ( - win32.normalize(win32.resolve(leftPath)).toLowerCase() === - win32.normalize(win32.resolve(rightPath)).toLowerCase() - ) + return canonicalWorktreePath(leftPath, 'win32') === canonicalWorktreePath(rightPath, 'win32') } - return posix.normalize(posix.resolve(leftPath)) === posix.normalize(posix.resolve(rightPath)) + return canonicalWorktreePath(leftPath, platform) === canonicalWorktreePath(rightPath, platform) } function looksLikeWindowsPath(pathValue: string): boolean { diff --git a/src/main/git/worktree-removal.ts b/src/main/git/worktree-removal.ts index 443da1eed3f..afe1bf2a9c1 100644 --- a/src/main/git/worktree-removal.ts +++ b/src/main/git/worktree-removal.ts @@ -22,7 +22,9 @@ import { } from './worktree-operation-options' import { areWorktreePathsEqual } from './worktree-path-comparison' import { assertWorktreeCleanForRemoval } from './worktree-removal-preflight' +import { withRepoRefMaintenancePaused } from './local-repo-ref-maintenance' import { bumpWorktreeScanGeneration, listWorktrees } from './worktree-scan-cache' +import { invalidateSparseCheckoutState } from './worktree-sparse-checkout-cache' /** * Remove a worktree. @@ -35,11 +37,17 @@ export async function removeWorktree( options: RemoveWorktreeOptions = {} ): Promise { try { - return await runWithGitReadCacheInvalidation(() => - performRemoveWorktree(repoPath, worktreePath, force, options) + // Removal deletes branches, and a ref deletion needs the packed-refs lock a + // running idle pack holds while it rewrites. Waits that window out; the + // prune phase that follows it is concurrency-safe and is left to finish. + return await withRepoRefMaintenancePaused('worktree-remove', () => + runWithGitReadCacheInvalidation(() => + performRemoveWorktree(repoPath, worktreePath, force, options) + ) ) } finally { invalidateWslLinkedWorktreeGitRouting(worktreePath) + invalidateSparseCheckoutState(repoPath, worktreePath) bumpWorktreeScanGeneration(repoPath) } } diff --git a/src/main/git/worktree-scan-cache-annotation-reuse.test.ts b/src/main/git/worktree-scan-cache-annotation-reuse.test.ts new file mode 100644 index 00000000000..0fabe5ba049 --- /dev/null +++ b/src/main/git/worktree-scan-cache-annotation-reuse.test.ts @@ -0,0 +1,93 @@ +// The annotated listing is the graph listing plus a sparse probe: callers that read only +// `worktree.path` must skip the probe, without costing a second `git worktree list`. +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { GitWorktreeInfo } from '../../shared/worktree/types' + +const { detectSparseCheckoutMock, readWorktreeListMock, readTranslatedWorktreeGraphMock } = + vi.hoisted(() => ({ + detectSparseCheckoutMock: vi.fn(), + readWorktreeListMock: vi.fn(), + readTranslatedWorktreeGraphMock: vi.fn() + })) + +vi.mock('./worktree-sparse-state', () => ({ + detectSparseCheckout: detectSparseCheckoutMock, + resolveGitCommonDir: vi.fn() +})) +vi.mock('./worktree-list-reader', () => ({ + readCheckedOutBranchRef: vi.fn(), + readRepoCommonDirFromGit: vi.fn(), + readRepoLocation: vi.fn(), + readTranslatedWorktreeGraph: readTranslatedWorktreeGraphMock, + readWorktreeHeadOid: vi.fn(), + readWorktreeList: readWorktreeListMock +})) + +import { _resetWorktreeScanCacheForTests, listWorktreeGraph, listWorktrees } from './worktree' +import { __resetSparseCheckoutStateCacheForTests } from './worktree-sparse-checkout-cache' + +const REPO = '\\\\wsl.localhost\\Ubuntu\\home\\me\\repo' +const ROW: GitWorktreeInfo = { + path: 'C:\\wt\\x', + head: 'a'.repeat(40), + branch: 'refs/heads/feature', + isBare: false, + isMainWorktree: false +} + +describe('graph and annotated worktree scans', () => { + beforeEach(() => { + detectSparseCheckoutMock.mockReset() + detectSparseCheckoutMock.mockResolvedValue(true) + readWorktreeListMock.mockReset() + readWorktreeListMock.mockResolvedValue([ROW]) + readTranslatedWorktreeGraphMock.mockReset() + readTranslatedWorktreeGraphMock.mockResolvedValue([ROW]) + _resetWorktreeScanCacheForTests() + __resetSparseCheckoutStateCacheForTests() + }) + + it('does not probe sparse state for a graph scan', async () => { + const rows = await listWorktreeGraph(REPO, { wslDistro: 'Ubuntu' }) + + expect(rows[0]?.path).toBe('C:\\wt\\x') + expect(rows[0]?.isSparse).toBeUndefined() + expect(detectSparseCheckoutMock).not.toHaveBeenCalled() + }) + + it('still probes sparse state for the annotated scan', async () => { + const rows = await listWorktrees(REPO, { wslDistro: 'Ubuntu' }) + + expect(rows[0]?.isSparse).toBe(true) + expect(detectSparseCheckoutMock).toHaveBeenCalledTimes(1) + }) + + it('reads the git listing once for an overlapping graph and annotated scan', async () => { + const [graphRows, annotatedRows] = await Promise.all([ + listWorktreeGraph(REPO, { wslDistro: 'Ubuntu' }), + listWorktrees(REPO, { wslDistro: 'Ubuntu' }) + ]) + + expect(readTranslatedWorktreeGraphMock).toHaveBeenCalledTimes(1) + expect(graphRows[0]?.isSparse).toBeUndefined() + expect(annotatedRows[0]?.isSparse).toBe(true) + }) + + // Sharing the listing must not make the probe-free caller wait on the probe it opted out of. + it('resolves a graph scan while the annotated scan is still probing', async () => { + let releaseProbe!: () => void + detectSparseCheckoutMock.mockImplementation( + () => + new Promise((resolve) => { + releaseProbe = () => resolve(true) + }) + ) + + const annotatedScan = listWorktrees(REPO, { wslDistro: 'Ubuntu' }) + const graphRows = await listWorktreeGraph(REPO, { wslDistro: 'Ubuntu' }) + + expect(graphRows[0]?.path).toBe('C:\\wt\\x') + releaseProbe() + expect((await annotatedScan)[0]?.isSparse).toBe(true) + }) +}) diff --git a/src/main/git/worktree-scan-cache-sharing.test.ts b/src/main/git/worktree-scan-cache-sharing.test.ts index d64ec2d4852..2a687420cd8 100644 --- a/src/main/git/worktree-scan-cache-sharing.test.ts +++ b/src/main/git/worktree-scan-cache-sharing.test.ts @@ -98,7 +98,9 @@ describe('listWorktrees in-flight sharing', () => { expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(1) }) - it('keeps graph and annotated scans separate despite sharing the same Git listing', async () => { + // The annotated scan is the graph scan plus a sparse probe, so the two share one `git worktree + // list` and only the annotated caller pays the probe. They ran Git twice before. + it('runs one git listing for concurrent graph and annotated scans', async () => { const resolvers: ((value: { stdout: string }) => void)[] = [] gitExecFileAsyncMock.mockImplementation( () => @@ -109,13 +111,34 @@ describe('listWorktrees in-flight sharing', () => { const graphScan = listWorktreeGraph('/repo') const annotatedScan = listWorktrees('/repo') - expect(resolvers).toHaveLength(2) + expect(resolvers).toHaveLength(1) for (const resolve of resolvers) { resolve({ stdout: 'worktree /repo\nHEAD abc123\nbranch refs/heads/main\n' }) } await Promise.all([graphScan, annotatedScan]) - expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(2) + expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(1) + }) + + // Order must not matter: whichever runs first owns the listing and the other joins it. + it('runs one git listing when the annotated scan starts first', async () => { + const resolvers: ((value: { stdout: string }) => void)[] = [] + gitExecFileAsyncMock.mockImplementation( + () => + new Promise((resolve) => { + resolvers.push(resolve) + }) + ) + + const annotatedScan = listWorktrees('/repo') + const graphScan = listWorktreeGraph('/repo') + expect(resolvers).toHaveLength(1) + + for (const resolve of resolvers) { + resolve({ stdout: 'worktree /repo\nHEAD abc123\nbranch refs/heads/main\n' }) + } + await Promise.all([annotatedScan, graphScan]) + expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(1) }) it('keeps graph scans with an AbortSignal isolated from shared callers', async () => { @@ -352,14 +375,15 @@ describe('listWorktrees in-flight sharing', () => { expect(scanResolvers).toHaveLength(1) await moveWorktree('/repo', '/repo-old', '/repo-new') - expect(_getWorktreeScanCacheSizesForTests()).toEqual({ inFlight: 1, generations: 1 }) + // Two entries per annotated scan: its own, plus the graph listing it shares with probe-free callers. + expect(_getWorktreeScanCacheSizesForTests()).toEqual({ inFlight: 2, generations: 1 }) const freshScan = listWorktrees('/repo') - expect(_getWorktreeScanCacheSizesForTests()).toEqual({ inFlight: 2, generations: 1 }) + expect(_getWorktreeScanCacheSizesForTests()).toEqual({ inFlight: 4, generations: 1 }) scanResolvers[1]?.('worktree /repo-new\nHEAD fresh\nbranch refs/heads/main\n') expect((await freshScan)[0]?.path).toBe('/repo-new') - expect(_getWorktreeScanCacheSizesForTests()).toEqual({ inFlight: 1, generations: 1 }) + expect(_getWorktreeScanCacheSizesForTests()).toEqual({ inFlight: 2, generations: 1 }) scanResolvers[0]?.('worktree /repo\nHEAD stale\nbranch refs/heads/main\n') expect((await staleScan)[0]?.path).toBe('/repo') @@ -396,7 +420,7 @@ describe('listWorktrees in-flight sharing', () => { const newestScan = listWorktrees('/repo') expect(listCalls).toBe(3) - expect(_getWorktreeScanCacheSizesForTests()).toEqual({ inFlight: 3, generations: 1 }) + expect(_getWorktreeScanCacheSizesForTests()).toEqual({ inFlight: 6, generations: 1 }) scanResolvers[0]?.() scanResolvers[2]?.() diff --git a/src/main/git/worktree-scan-cache.ts b/src/main/git/worktree-scan-cache.ts index 327aead5f2e..59a7691fe7f 100644 --- a/src/main/git/worktree-scan-cache.ts +++ b/src/main/git/worktree-scan-cache.ts @@ -1,8 +1,8 @@ import type { GitWorktreeInfo } from '../../shared/worktree/types' import { + annotateSparseCheckoutStatus, listWorktreeGraph as listWorktreeGraphUnshared, - listWorktreesStrict as listWorktreesStrictUnshared, - listWorktreesUnshared + listWorktreesStrict as listWorktreesStrictUnshared } from './worktree-listing' import type { GitWorktreeExecOptions } from './worktree-operation-options' import { WORKTREE_LIST_TIMEOUT_MS } from './worktree-operation-options' @@ -90,6 +90,22 @@ function shareWorktreeScan( return scan } +/** + * Sparse annotation layered over the shared graph scan rather than its own `git worktree list`. + * + * Both paths soften a Git failure to `[]`, so they can share one listing; only this one pays the + * per-worktree sparse probe. That lets a caller which reads just `worktree.path` skip the probes + * without costing a second subprocess when it overlaps a badge reader — the two ran Git twice + * before. Strict stays on its own scan because it must be able to reject. + */ +async function runAnnotatedWorktreeScan( + repoPath: string, + options: GitWorktreeExecOptions +): Promise { + const worktrees = await listWorktreeGraph(repoPath, options) + return annotateSparseCheckoutStatus(repoPath, worktrees, options) +} + /** * List all worktrees for a git repo at the given path. Concurrent calls for * the same repo share one scan (unless the caller passes an AbortSignal, @@ -99,7 +115,7 @@ export function listWorktrees( repoPath: string, options: GitWorktreeExecOptions = {} ): Promise { - return shareWorktreeScan(repoPath, options, 'lenient', listWorktreesUnshared) + return shareWorktreeScan(repoPath, options, 'lenient', runAnnotatedWorktreeScan) } /** diff --git a/src/main/git/worktree-sparse-checkout-cache.test.ts b/src/main/git/worktree-sparse-checkout-cache.test.ts new file mode 100644 index 00000000000..7963c0ecee4 --- /dev/null +++ b/src/main/git/worktree-sparse-checkout-cache.test.ts @@ -0,0 +1,356 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { detectSparseCheckoutMock } = vi.hoisted(() => ({ + detectSparseCheckoutMock: vi.fn() +})) + +vi.mock('./worktree-sparse-state', () => ({ + detectSparseCheckout: detectSparseCheckoutMock, + resolveGitCommonDir: vi.fn() +})) + +import { + __getSparseCheckoutStateCacheSizeForTests, + __resetSparseCheckoutStateCacheForTests, + clearSparseCheckoutStateCache, + clearSparseCheckoutStateCacheForRepo, + detectSparseCheckoutCached, + invalidateSparseCheckoutState, + onSparseCheckoutStateChanged +} from './worktree-sparse-checkout-cache' + +const RECONCILE_WINDOW_MS = 5 * 60_000 + +// A real setTimeout tick, not a faked one, to flush the microtask chain a background +// stale-while-revalidate detect runs on without needing vi.useFakeTimers() (which would also +// have to fake Date.now(), the thing these tests drive manually via the Date.now spy below). +async function flushBackgroundRevalidation(): Promise { + await new Promise((resolve) => setTimeout(resolve, 0)) +} + +beforeEach(() => { + detectSparseCheckoutMock.mockReset() + __resetSparseCheckoutStateCacheForTests() +}) + +describe('detectSparseCheckoutCached', () => { + it('caches a detection result across repeated calls for the same repo+path', async () => { + detectSparseCheckoutMock.mockResolvedValue(true) + + expect(await detectSparseCheckoutCached('/repo', '/repo/wt-a')).toBe(true) + expect(await detectSparseCheckoutCached('/repo', '/repo/wt-a')).toBe(true) + + expect(detectSparseCheckoutMock).toHaveBeenCalledTimes(1) + }) + + it('detects each distinct worktree path independently', async () => { + detectSparseCheckoutMock.mockImplementation( + async (worktreePath: string) => worktreePath === '/repo/wt-sparse' + ) + + expect(await detectSparseCheckoutCached('/repo', '/repo/wt-sparse')).toBe(true) + expect(await detectSparseCheckoutCached('/repo', '/repo/wt-full')).toBe(false) + expect(detectSparseCheckoutMock).toHaveBeenCalledTimes(2) + }) + + it('scopes the cache by repo, so the same path under two repos is detected independently', async () => { + detectSparseCheckoutMock.mockResolvedValue(true) + + expect(await detectSparseCheckoutCached('/repo-a', '/shared-mount/wt')).toBe(true) + expect(await detectSparseCheckoutCached('/repo-b', '/shared-mount/wt')).toBe(true) + + expect(detectSparseCheckoutMock).toHaveBeenCalledTimes(2) + }) + + it('treats an equivalent path spelling (trailing slash, redundant segment) as the same cache entry', async () => { + detectSparseCheckoutMock.mockResolvedValue(true) + + expect(await detectSparseCheckoutCached('/repo', '/repo/wt-a')).toBe(true) + expect(await detectSparseCheckoutCached('/repo', '/repo/./wt-a/')).toBe(true) + + expect(detectSparseCheckoutMock).toHaveBeenCalledTimes(1) + }) + + it('caches a false result too, so a worktree that stays non-sparse costs one detect', async () => { + detectSparseCheckoutMock.mockResolvedValue(false) + + expect(await detectSparseCheckoutCached('/repo', '/repo/wt-a')).toBe(false) + expect(await detectSparseCheckoutCached('/repo', '/repo/wt-a')).toBe(false) + + expect(detectSparseCheckoutMock).toHaveBeenCalledTimes(1) + }) + + it('serves the stale value immediately past the reconcile window and corrects it in the background', async () => { + const nowSpy = vi.spyOn(Date, 'now') + try { + nowSpy.mockReturnValue(1_000) + detectSparseCheckoutMock.mockResolvedValueOnce(false) + expect(await detectSparseCheckoutCached('/repo', '/repo/wt-a')).toBe(false) + + // Just under the window: still trusts the cached value, no re-detect. + nowSpy.mockReturnValue(1_000 + RECONCILE_WINDOW_MS - 1) + expect(await detectSparseCheckoutCached('/repo', '/repo/wt-a')).toBe(false) + expect(detectSparseCheckoutMock).toHaveBeenCalledTimes(1) + + // Past the window: both calls return the (stale) cached value, and only one kicks a + // background re-detect. Read both without awaiting in between — the underlying function + // never suspends before returning the stale value, so two calls issued back-to-back in the + // same tick are the only reliable way to observe "both concurrent callers stay stale" without + // racing the background revalidation's own microtask. + nowSpy.mockReturnValue(1_000 + RECONCILE_WINDOW_MS + 1) + detectSparseCheckoutMock.mockResolvedValueOnce(true) + const firstPastWindow = detectSparseCheckoutCached('/repo', '/repo/wt-a') + const secondPastWindow = detectSparseCheckoutCached('/repo', '/repo/wt-a') + expect(await firstPastWindow).toBe(false) + expect(await secondPastWindow).toBe(false) + expect(detectSparseCheckoutMock).toHaveBeenCalledTimes(2) + + await flushBackgroundRevalidation() + + // The corrected value is now served without needing another window to elapse. + expect(await detectSparseCheckoutCached('/repo', '/repo/wt-a')).toBe(true) + expect(detectSparseCheckoutMock).toHaveBeenCalledTimes(2) + } finally { + nowSpy.mockRestore() + } + }) + + it('does not resurrect an entry that was explicitly invalidated while a background revalidation was in flight', async () => { + const nowSpy = vi.spyOn(Date, 'now') + let resolveDetect: (isSparse: boolean) => void = () => {} + try { + nowSpy.mockReturnValue(1_000) + detectSparseCheckoutMock.mockResolvedValueOnce(false) + await detectSparseCheckoutCached('/repo', '/repo/wt-a') + + // Past the window: kicks a background re-detect that we hold open. + nowSpy.mockReturnValue(1_000 + RECONCILE_WINDOW_MS + 1) + detectSparseCheckoutMock.mockImplementationOnce( + () => new Promise((resolve) => (resolveDetect = resolve)) + ) + await detectSparseCheckoutCached('/repo', '/repo/wt-a') + + // The worktree is removed (or the repo cache is cleared) while the detect above is in flight. + invalidateSparseCheckoutState('/repo', '/repo/wt-a') + expect(__getSparseCheckoutStateCacheSizeForTests()).toBe(0) + + // The in-flight detect now resolves; it must not write the entry back. + resolveDetect(true) + await flushBackgroundRevalidation() + expect(__getSparseCheckoutStateCacheSizeForTests()).toBe(0) + } finally { + nowSpy.mockRestore() + } + }) + + it('does not let a stale in-flight revalidation clobber a fresh value written after remove+recreate at the same path', async () => { + const nowSpy = vi.spyOn(Date, 'now') + let resolveStaleDetect: (isSparse: boolean) => void = () => {} + try { + nowSpy.mockReturnValue(1_000) + detectSparseCheckoutMock.mockResolvedValueOnce(false) + await detectSparseCheckoutCached('/repo', '/repo/wt-a') + + // Past the window: kicks a background re-detect that we hold open (simulates a slow probe + // racing a worktree removal + recreation at the same path). + nowSpy.mockReturnValue(1_000 + RECONCILE_WINDOW_MS + 1) + detectSparseCheckoutMock.mockImplementationOnce( + () => new Promise((resolve) => (resolveStaleDetect = resolve)) + ) + await detectSparseCheckoutCached('/repo', '/repo/wt-a') + + // The worktree is removed (invalidate) and a new one is recreated at the exact same path, + // repopulating the key with a fresh, different value via a normal cold read. + invalidateSparseCheckoutState('/repo', '/repo/wt-a') + detectSparseCheckoutMock.mockResolvedValueOnce(true) + expect(await detectSparseCheckoutCached('/repo', '/repo/wt-a')).toBe(true) + + // The stale in-flight detect from before the remove+recreate now resolves with the old + // answer. A presence-only guard would let this overwrite the fresh entry above; the fix + // must compare entry identity and refuse to write back over a value it didn't produce. + resolveStaleDetect(false) + await flushBackgroundRevalidation() + expect(await detectSparseCheckoutCached('/repo', '/repo/wt-a')).toBe(true) + } finally { + nowSpy.mockRestore() + } + }) + + it('notifies the registered change listener only when a background revalidation flips the answer', async () => { + const listener = vi.fn() + onSparseCheckoutStateChanged(listener) + const nowSpy = vi.spyOn(Date, 'now') + try { + nowSpy.mockReturnValue(1_000) + detectSparseCheckoutMock.mockResolvedValueOnce(false) + await detectSparseCheckoutCached('/repo', '/repo/wt-a') + + nowSpy.mockReturnValue(1_000 + RECONCILE_WINDOW_MS + 1) + detectSparseCheckoutMock.mockResolvedValueOnce(false) + await detectSparseCheckoutCached('/repo', '/repo/wt-a') + await flushBackgroundRevalidation() + expect(listener).not.toHaveBeenCalled() + + nowSpy.mockReturnValue(1_000 + 2 * RECONCILE_WINDOW_MS + 2) + detectSparseCheckoutMock.mockResolvedValueOnce(true) + await detectSparseCheckoutCached('/repo', '/repo/wt-a') + await flushBackgroundRevalidation() + expect(listener).toHaveBeenCalledTimes(1) + expect(listener).toHaveBeenCalledWith('/repo', '/repo/wt-a', true) + } finally { + nowSpy.mockRestore() + onSparseCheckoutStateChanged(undefined) + } + }) +}) + +describe('invalidateSparseCheckoutState', () => { + it('drops only the named repo+path, leaving other cached paths untouched', async () => { + detectSparseCheckoutMock.mockResolvedValue(true) + await detectSparseCheckoutCached('/repo', '/repo/wt-a') + await detectSparseCheckoutCached('/repo', '/repo/wt-b') + + invalidateSparseCheckoutState('/repo', '/repo/wt-a') + expect(__getSparseCheckoutStateCacheSizeForTests()).toBe(1) + + detectSparseCheckoutMock.mockClear() + await detectSparseCheckoutCached('/repo', '/repo/wt-a') + await detectSparseCheckoutCached('/repo', '/repo/wt-b') + expect(detectSparseCheckoutMock).toHaveBeenCalledTimes(1) + expect(detectSparseCheckoutMock).toHaveBeenCalledWith('/repo/wt-a', {}) + }) +}) + +describe('clearSparseCheckoutStateCacheForRepo', () => { + it('drops only the named repo`s entries, leaving a sibling repo`s warm cache intact', async () => { + detectSparseCheckoutMock.mockResolvedValue(true) + await detectSparseCheckoutCached('/repo-a', '/repo-a/wt-1') + await detectSparseCheckoutCached('/repo-b', '/repo-b/wt-1') + expect(__getSparseCheckoutStateCacheSizeForTests()).toBe(2) + + clearSparseCheckoutStateCacheForRepo('/repo-a') + expect(__getSparseCheckoutStateCacheSizeForTests()).toBe(1) + + detectSparseCheckoutMock.mockClear() + await detectSparseCheckoutCached('/repo-a', '/repo-a/wt-1') + await detectSparseCheckoutCached('/repo-b', '/repo-b/wt-1') + expect(detectSparseCheckoutMock).toHaveBeenCalledTimes(1) + }) +}) + +describe('clearSparseCheckoutStateCache', () => { + it('drops every cached path across every repo, matching the fallback used when a repo cannot be resolved', async () => { + detectSparseCheckoutMock.mockResolvedValue(true) + await detectSparseCheckoutCached('/repo-a', '/repo-a/wt-1') + await detectSparseCheckoutCached('/repo-b', '/repo-b/wt-1') + expect(__getSparseCheckoutStateCacheSizeForTests()).toBe(2) + + clearSparseCheckoutStateCache() + + expect(__getSparseCheckoutStateCacheSizeForTests()).toBe(0) + }) +}) + +// Regression coverage for the live Windows+WSL sequence: a distro-less listing (filesystem-auth +// root rebuild, worktree ownership checks) racing the real distro-carrying listing for the same +// repo. Before the distro joined the cache key they shared one entry, so whichever ran first +// decided the sparse badge for the whole reconcile window. +describe('detectSparseCheckoutCached with a WSL distro', () => { + // Mirrors the real probe: without the distro the gitdir pointer resolves to a fabricated Win32 + // path, the sparse-checkout stat misses, and the worktree reads as non-sparse. + function detectOnlyWithDistro(distro: string): void { + detectSparseCheckoutMock.mockImplementation( + async (_worktreePath: string, options?: { wslDistro?: string }) => + options?.wslDistro === distro + ) + } + + it('does not serve a distro-carrying read an answer derived without that distro', async () => { + detectOnlyWithDistro('Ubuntu') + + expect(await detectSparseCheckoutCached('C:\\repo', 'C:\\repo\\wt')).toBe(false) + expect( + await detectSparseCheckoutCached('C:\\repo', 'C:\\repo\\wt', { wslDistro: 'Ubuntu' }) + ).toBe(true) + + expect(detectSparseCheckoutMock).toHaveBeenCalledTimes(2) + }) + + it('keeps a distro-carrying answer correct when a distro-less read follows it', async () => { + detectOnlyWithDistro('Ubuntu') + + expect( + await detectSparseCheckoutCached('C:\\repo', 'C:\\repo\\wt', { wslDistro: 'Ubuntu' }) + ).toBe(true) + expect(await detectSparseCheckoutCached('C:\\repo', 'C:\\repo\\wt')).toBe(false) + expect( + await detectSparseCheckoutCached('C:\\repo', 'C:\\repo\\wt', { wslDistro: 'Ubuntu' }) + ).toBe(true) + + expect(detectSparseCheckoutMock).toHaveBeenCalledTimes(2) + }) + + it('treats distro spellings that name the same distro as one entry', async () => { + detectSparseCheckoutMock.mockResolvedValue(true) + + expect( + await detectSparseCheckoutCached('C:\\repo', 'C:\\repo\\wt', { wslDistro: 'Ubuntu' }) + ).toBe(true) + expect( + await detectSparseCheckoutCached('C:\\repo', 'C:\\repo\\wt', { wslDistro: ' ubuntu ' }) + ).toBe(true) + + expect(detectSparseCheckoutMock).toHaveBeenCalledTimes(1) + }) + + it('does not let a distro-less reader past the window revalidate a distro-carrying entry', async () => { + const listener = vi.fn() + onSparseCheckoutStateChanged(listener) + const nowSpy = vi.spyOn(Date, 'now') + try { + detectOnlyWithDistro('Ubuntu') + nowSpy.mockReturnValue(1_000) + await detectSparseCheckoutCached('C:\\repo', 'C:\\repo\\wt', { wslDistro: 'Ubuntu' }) + await detectSparseCheckoutCached('C:\\repo', 'C:\\repo\\wt') + + // Past the window the distro-less caller re-probes its own entry, not the sparse one, so the + // badge cannot blink off and fire the change listener that clears the whole repo's cache. + nowSpy.mockReturnValue(1_000 + RECONCILE_WINDOW_MS + 1) + expect(await detectSparseCheckoutCached('C:\\repo', 'C:\\repo\\wt')).toBe(false) + await flushBackgroundRevalidation() + + expect(listener).not.toHaveBeenCalled() + expect( + await detectSparseCheckoutCached('C:\\repo', 'C:\\repo\\wt', { wslDistro: 'Ubuntu' }) + ).toBe(true) + } finally { + nowSpy.mockRestore() + onSparseCheckoutStateChanged(undefined) + } + }) + + it('drops every distro variant of a path on invalidate, so a removed worktree leaves nothing behind', async () => { + detectSparseCheckoutMock.mockResolvedValue(true) + await detectSparseCheckoutCached('C:\\repo', 'C:\\repo\\wt') + await detectSparseCheckoutCached('C:\\repo', 'C:\\repo\\wt', { wslDistro: 'Ubuntu' }) + await detectSparseCheckoutCached('C:\\repo', 'C:\\repo\\other') + expect(__getSparseCheckoutStateCacheSizeForTests()).toBe(3) + + invalidateSparseCheckoutState('C:\\repo', 'C:\\repo\\wt') + + expect(__getSparseCheckoutStateCacheSizeForTests()).toBe(1) + }) + + it('still caches normally with no distro anywhere, as on macOS/Linux and native Windows', async () => { + detectSparseCheckoutMock.mockResolvedValue(true) + + expect(await detectSparseCheckoutCached('/repo', '/repo/wt-a')).toBe(true) + expect(await detectSparseCheckoutCached('/repo', '/repo/wt-a', {})).toBe(true) + expect(await detectSparseCheckoutCached('/repo', '/repo/wt-a', { wslDistro: undefined })).toBe( + true + ) + + expect(detectSparseCheckoutMock).toHaveBeenCalledTimes(1) + expect(__getSparseCheckoutStateCacheSizeForTests()).toBe(1) + }) +}) diff --git a/src/main/git/worktree-sparse-checkout-cache.ts b/src/main/git/worktree-sparse-checkout-cache.ts new file mode 100644 index 00000000000..3c356dad7c8 --- /dev/null +++ b/src/main/git/worktree-sparse-checkout-cache.ts @@ -0,0 +1,153 @@ +import type { GitRuntimeOptions } from './git-runtime-options' +import { canonicalWorktreePath } from './worktree-path-comparison' +import { detectSparseCheckout } from './worktree-sparse-state' + +// Why: `git worktree list` only emits a `sparse` porcelain line on newer Git (annotateSparseCheckoutStatus +// already skips rows where that's set), but Orca's compatibility baseline is Git 2.25, which predates it — +// so every listing still paid a per-worktree fs.stat + config read on the fallback path, measured at ~9x +// the cost of the `git worktree list` call it decorates on a 1000-worktree repo. Cache the result, scoped +// per repo so churn in one repo can't evict another's warm entries. +// +// Invalidation coverage: +// - Orca-driven remove/move: explicit calls below (worktree-removal.ts, worktree-move.ts). +// - External `git sparse-checkout` toggle while extensions.worktreeConfig is on: it rewrites +// `config.worktree`, which the git-common-dir watcher already classifies as structural and +// routes through notifyWorktreesChanged -> the invalidator this module registers (repo-scoped). +// - External toggle with extensions.worktreeConfig off, or a bare pattern-file edit: unwitnessed +// by the watcher (same blind spot `readRepoWorktreeAdminFingerprint` already documents and +// accepts). Past the reconcile window below, a read still returns instantly from the stale entry +// but also kicks a deduplicated background re-detect; a flip fires the change listener (wired to +// the existing worktrees-changed notification) so the visible staleness window collapses from the +// interval to one refresh cycle instead of blocking the listing that noticed it. That notification +// itself runs the invalidator registered below, so a flip is immediately followed by a full clear +// of the repo's cache (not just the one entry) -- an intentionally forced one-time full re-detect +// on the rare edge that actually flipped, rather than partial state that could quietly diverge. +// - App cold start: the map starts empty, so the first read is always a fresh detect. +const SPARSE_CHECKOUT_CACHE_RECONCILE_INTERVAL_MS = 5 * 60_000 + +// Part of the cache key, not just a probe argument. A distro-less read of a WSL-hosted repo +// resolves the gitdir pointer against a fabricated Win32 path and reports "not sparse"; several +// callers (filesystem-auth root rebuild, worktree ownership checks) list a repo with no options at +// all and would otherwise publish that wrong answer onto the entry the distro-carrying listing +// reads. Keying on it also pins each entry's revalidation to the options that produced it, so the +// background probe can never re-derive a warm entry under weaker options and flip it. Every field +// here must be in the key; widening this type means widening `cacheKey`. +type SparseCheckoutProbeOptions = Pick + +type SparseCheckoutCacheEntry = { + isSparse: boolean + cachedAt: number + revalidating?: Promise +} + +export type SparseCheckoutChangeListener = ( + repoPath: string, + worktreePath: string, + isSparse: boolean +) => void + +const sparseCheckoutStateCache = new Map() +let changeListener: SparseCheckoutChangeListener | undefined + +// Distro last so the repo- and worktree-scoped prefix deletes below still match every variant. +function cacheKey( + repoPath: string, + worktreePath: string, + options: SparseCheckoutProbeOptions +): string { + return `${worktreeKeyPrefix(repoPath, worktreePath)}${options.wslDistro?.trim().toLowerCase() ?? ''}` +} + +function worktreeKeyPrefix(repoPath: string, worktreePath: string): string { + return `${canonicalWorktreePath(repoPath)}\0${canonicalWorktreePath(worktreePath)}\0` +} + +function deleteKeysWithPrefix(prefix: string): void { + for (const key of sparseCheckoutStateCache.keys()) { + if (key.startsWith(prefix)) { + sparseCheckoutStateCache.delete(key) + } + } +} + +/** Wired by the ipc/ layer to the shared worktrees-changed notification; last registration wins. */ +export function onSparseCheckoutStateChanged( + listener: SparseCheckoutChangeListener | undefined +): void { + changeListener = listener +} + +/** Cached wrapper around {@link detectSparseCheckout}; see module doc for invalidation coverage. */ +export async function detectSparseCheckoutCached( + repoPath: string, + worktreePath: string, + options: SparseCheckoutProbeOptions = {} +): Promise { + const key = cacheKey(repoPath, worktreePath, options) + const cached = sparseCheckoutStateCache.get(key) + if (!cached) { + const isSparse = await detectSparseCheckout(worktreePath, options) + sparseCheckoutStateCache.set(key, { isSparse, cachedAt: Date.now() }) + return isSparse + } + if (Date.now() - cached.cachedAt < SPARSE_CHECKOUT_CACHE_RECONCILE_INTERVAL_MS) { + return cached.isSparse + } + // Stale-while-revalidate: serve the still-cached value now and correct it in the background, + // deduplicated so concurrent readers past the window don't each start their own probe. Whichever + // reader wins the dedupe re-probes with the entry's own distro, because that distro is what + // routed it to this key. + cached.revalidating ??= revalidateInBackground(key, repoPath, worktreePath, cached, options) + return cached.isSparse +} + +async function revalidateInBackground( + key: string, + repoPath: string, + worktreePath: string, + startingEntry: SparseCheckoutCacheEntry, + options: SparseCheckoutProbeOptions +): Promise { + try { + const isSparse = await detectSparseCheckout(worktreePath, options) + // Identity guard against a race with an explicit invalidate/clear -- or a remove+recreate at + // the same path that repopulates the key with a fresh cold read -- while this was in flight. + // A `has()`/presence check can't tell "still mine" from "someone else's fresh value" sharing + // the key; comparing the map's current entry object to the one we started from can. + if (sparseCheckoutStateCache.get(key) === startingEntry) { + sparseCheckoutStateCache.set(key, { isSparse, cachedAt: Date.now() }) + } + if (isSparse !== startingEntry.isSparse) { + changeListener?.(repoPath, worktreePath, isSparse) + } + } catch { + // Leave whatever's there in place; the next read past the window retries. + if (sparseCheckoutStateCache.get(key) === startingEntry) { + startingEntry.revalidating = undefined + } + } +} + +/** Drop one worktree's cached state; call when Orca itself removes or moves a worktree path. */ +export function invalidateSparseCheckoutState(repoPath: string, worktreePath: string): void { + deleteKeysWithPrefix(worktreeKeyPrefix(repoPath, worktreePath)) +} + +/** Clear one repo's cached entries; wired to the shared worktree-change invalidator registry in ipc/. */ +export function clearSparseCheckoutStateCacheForRepo(repoPath: string): void { + deleteKeysWithPrefix(`${canonicalWorktreePath(repoPath)}\0`) +} + +/** Clear every cached entry; fallback for a change notification whose repo can't be resolved to a path. */ +export function clearSparseCheckoutStateCache(): void { + sparseCheckoutStateCache.clear() +} + +export function __resetSparseCheckoutStateCacheForTests(): void { + sparseCheckoutStateCache.clear() + changeListener = undefined +} + +export function __getSparseCheckoutStateCacheSizeForTests(): number { + return sparseCheckoutStateCache.size +} diff --git a/src/main/git/worktree-sparse-checkout.test.ts b/src/main/git/worktree-sparse-checkout.test.ts index dba4dc5b59e..19d503bd537 100644 --- a/src/main/git/worktree-sparse-checkout.test.ts +++ b/src/main/git/worktree-sparse-checkout.test.ts @@ -3,7 +3,7 @@ import { mkdtemp, mkdir, realpath, rm, stat, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import * as path from 'node:path' import { afterEach, describe, expect, it } from 'vitest' -import { listWorktrees, parseCoreSparseCheckoutFlag } from './worktree' +import { listWorktrees, parseCoreSparseCheckoutFlag, removeWorktree } from './worktree' const tempRoots: string[] = [] @@ -110,6 +110,32 @@ describe('sparse-checkout detection', () => { ) }) +describe('sparse-checkout cache invalidation across the worktree lifecycle', () => { + it.skipIf(process.platform === 'win32')( + 'does not leak a stale sparse badge onto a worktree created at a removed worktree`s path', + async () => { + const repoPath = await createRepoWithTwoDirs() + const linkedPath = path.join(path.dirname(repoPath), 'linked') + + git(repoPath, ['worktree', 'add', '-b', 'sparse-branch', linkedPath]) + git(linkedPath, ['sparse-checkout', 'set', 'keep']) + + const beforeRemoval = await listWorktrees(repoPath) + const sparseRow = beforeRemoval.find((worktree) => worktree.branch.endsWith('sparse-branch')) + expect(sparseRow?.isSparse).toBe(true) + + // Removing through Orca's own removeWorktree (not a raw `git worktree remove`) exercises the + // cache-invalidation hook this listing now relies on instead of a fresh stat every time. + await removeWorktree(repoPath, linkedPath, true) + git(repoPath, ['worktree', 'add', '-b', 'full-branch', linkedPath]) + + const afterRecreate = await listWorktrees(repoPath) + const fullRow = afterRecreate.find((worktree) => worktree.branch.endsWith('full-branch')) + expect(fullRow?.isSparse).toBeFalsy() + } + ) +}) + describe('parseCoreSparseCheckoutFlag', () => { it('reads an enabled flag from the [core] section', () => { expect(parseCoreSparseCheckoutFlag('[core]\n\tsparseCheckout = true\n')).toBe(true) diff --git a/src/main/git/worktree-sparse-state-host-paths.test.ts b/src/main/git/worktree-sparse-state-host-paths.test.ts new file mode 100644 index 00000000000..afa7e1babf7 --- /dev/null +++ b/src/main/git/worktree-sparse-state-host-paths.test.ts @@ -0,0 +1,57 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { readFileMock, statMock } = vi.hoisted(() => ({ + readFileMock: vi.fn(), + statMock: vi.fn() +})) + +vi.mock('node:fs/promises', () => ({ readFile: readFileMock, stat: statMock })) + +import { detectSparseCheckout } from './worktree-sparse-state' + +const slashed = (value: unknown): string => String(value).replaceAll('\\', '/') +const missing = () => Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) + +// The layout that needs the caller's distro: `git worktree list` reports a drvfs worktree, which +// translates to a drive letter, so the base path no longer names the distro that wrote the gitdir +// pointer — and that pointer is not itself drvfs, so there is nothing to derive a drive from. +const HOST_WORKTREE = 'C:/wt/x' +const GUEST_GIT_DIR = '/home/me/repo/.git/worktrees/x' +const HOST_GIT_DIR = '//wsl.localhost/Ubuntu/home/me/repo/.git/worktrees/x' + +describe('detectSparseCheckout on a drvfs-spelled WSL worktree', () => { + beforeEach(() => { + readFileMock.mockReset() + statMock.mockReset() + readFileMock.mockImplementation(async (target: string) => { + const value = slashed(target) + if (value === `${HOST_WORKTREE}/.git`) { + return `gitdir: ${GUEST_GIT_DIR}\n` + } + // No `commondir`, so the gitdir is its own common dir and the config read stays in one + // namespace — the pointer resolve above is the only thing under test. + if (value === `${HOST_GIT_DIR}/config`) { + return '[core]\n\tsparseCheckout = true\n' + } + throw missing() + }) + statMock.mockImplementation(async (target: string) => + slashed(target) === `${HOST_GIT_DIR}/info/sparse-checkout` + ? { isFile: () => true, size: 12 } + : Promise.reject(missing()) + ) + }) + + it('reads the pattern file through the caller-named distro', async () => { + const platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('win32') + + try { + await expect(detectSparseCheckout('/mnt/c/wt/x', { wslDistro: 'Ubuntu' })).resolves.toBe(true) + expect(statMock.mock.calls.map(([target]) => slashed(target))).toContain( + `${HOST_GIT_DIR}/info/sparse-checkout` + ) + } finally { + platformSpy.mockRestore() + } + }) +}) diff --git a/src/main/git/worktree-sparse-state.ts b/src/main/git/worktree-sparse-state.ts index f8353d43f6a..38a085d9a5f 100644 --- a/src/main/git/worktree-sparse-state.ts +++ b/src/main/git/worktree-sparse-state.ts @@ -1,12 +1,18 @@ import { readFile, stat } from 'node:fs/promises' import { isAbsolute, join, resolve } from 'node:path' +import type { GitRuntimeOptions } from './git-runtime-options' import { resolveGitDir } from './status' -export async function detectSparseCheckout(worktreePath: string): Promise { +export async function detectSparseCheckout( + worktreePath: string, + // Why: git in a WSL distro reports the worktree, and writes its gitdir pointer, in the guest + // namespace; without the distro this stats a path Win32 fabricates and reads "not sparse". + options: Pick = {} +): Promise { // Why: fs.stat the per-worktree gitdir's sparse-checkout pattern file instead of a per-poll `git sparse-checkout list` subprocess that regressed responsiveness (PR #1290); // this is the cheap fast-path gate before the enabled check below. try { - const gitDir = await resolveGitDir(worktreePath) + const gitDir = await resolveGitDir(worktreePath, options) const stats = await stat(join(gitDir, 'info', 'sparse-checkout')) if (!stats.isFile() || stats.size === 0) { return false diff --git a/src/main/git/worktree-symlink-detection.ts b/src/main/git/worktree-symlink-detection.ts index a8042598e05..8def9845b3f 100644 --- a/src/main/git/worktree-symlink-detection.ts +++ b/src/main/git/worktree-symlink-detection.ts @@ -1,5 +1,6 @@ import { lstat } from 'node:fs/promises' import { resolve } from 'node:path' +import { resolveWorktreeHostPath } from '../../shared/git-metadata-path' // Why this is a leaf module rather than part of ipc/worktree-symlinks: status // and review-creation need only the read-only "is this a symlink" question, and @@ -32,10 +33,19 @@ export function getSafeRelativePath(rawPath: string): SafeRelativePathResult { return { safe: true, rel } } +export type WorktreeSymlinkDetectionOptions = { + /** Distro that spelled `worktreePath`, for a Windows host reopening a guest path. */ + wslDistro?: string +} + export async function findExistingWorktreeSymlinkPaths( worktreePath: string, - paths: readonly string[] + paths: readonly string[], + options: WorktreeSymlinkDetectionOptions = {} ): Promise { + // Why: git in a WSL distro reports the worktree in the guest namespace, but this lstat runs in + // the Windows main process, where that spelling names nothing. + const hostWorktreePath = resolveWorktreeHostPath(worktreePath, options) ?? worktreePath const symlinkPaths: string[] = [] for (const rawPath of paths) { const safePath = getSafeRelativePath(rawPath) @@ -43,7 +53,7 @@ export async function findExistingWorktreeSymlinkPaths( continue } try { - if ((await lstat(resolve(worktreePath, safePath.rel))).isSymbolicLink()) { + if ((await lstat(resolve(hostWorktreePath, safePath.rel))).isSymbolicLink()) { symlinkPaths.push(safePath.rel) } } catch { diff --git a/src/main/git/worktree-test-harness.ts b/src/main/git/worktree-test-harness.ts index 9317f11b967..a72dacc9ec6 100644 --- a/src/main/git/worktree-test-harness.ts +++ b/src/main/git/worktree-test-harness.ts @@ -1,11 +1,13 @@ import { afterEach, beforeEach, vi } from 'vitest' import { clearGitCapabilityStateForTests } from './git-capability-state' +import { __resetSparseCheckoutStateCacheForTests } from './worktree-sparse-checkout-cache' /** Root hooks every worktree suite shares: pristine capability cache, no ambient add-timeout override. */ export function registerWorktreeSuiteHooks(): void { beforeEach(() => { clearGitCapabilityStateForTests() + __resetSparseCheckoutStateCacheForTests() // Why: addWorktree reads the override at call time, so a developer's ambient value must not leak in. // `undefined` deletes the key, matching production's unset case rather than an empty string. vi.stubEnv('ORCA_WORKTREE_ADD_TIMEOUT_MS', undefined) diff --git a/src/main/headless-automation-dispatcher-source-boundary.test.ts b/src/main/headless-automation-dispatcher-source-boundary.test.ts index d8cc23c28a3..5f7a10fedec 100644 --- a/src/main/headless-automation-dispatcher-source-boundary.test.ts +++ b/src/main/headless-automation-dispatcher-source-boundary.test.ts @@ -2,7 +2,7 @@ import { readFileSync } from 'node:fs' import { join } from 'node:path' import { describe, expect, it } from 'vitest' -const source = readFileSync(join(__dirname, 'index.ts'), 'utf8') +const source = readFileSync(join(__dirname, 'startup', 'main-process-automations.ts'), 'utf8') function sourceBetween(startPattern: string, endPattern: string): string { const start = source.indexOf(startPattern) diff --git a/src/main/host/deferred-secret-protection-report.test.ts b/src/main/host/deferred-secret-protection-report.test.ts index ce0a301e433..5c6336bec3d 100644 --- a/src/main/host/deferred-secret-protection-report.test.ts +++ b/src/main/host/deferred-secret-protection-report.test.ts @@ -80,6 +80,34 @@ describe('scheduleSecretProtectionGapReport', () => { deferUntilFirstWindow: true }) + it('can skip the blocking probe for development profiles', () => { + scheduleSecretProtectionGapReport({ + dataFile, + log: (m) => void logged.push(m), + deferUntilFirstWindow: true, + skipInDevelopment: true + }) + createWindow().reveal() + drain() + vi.advanceTimersByTime(20_000) + expect(probes).toBe(0) + expect(logged).toEqual([]) + }) + + it('honors forced reports for development profiles', () => { + scheduleSecretProtectionGapReport({ + dataFile, + log: (m) => void logged.push(m), + deferUntilFirstWindow: true, + skipInDevelopment: true, + force: true + }) + createWindow().reveal() + drain() + expect(probes).toBe(1) + expect(logged).toEqual(['[secrets] The OS keyring is unavailable.']) + }) + /** Runs an already-queued setImmediate; the 1ms is slack, not a delay under test. */ const drain = (): void => void vi.advanceTimersByTime(1) diff --git a/src/main/host/deferred-secret-protection-report.ts b/src/main/host/deferred-secret-protection-report.ts index 4b12ad7e563..8f5be1fb047 100644 --- a/src/main/host/deferred-secret-protection-report.ts +++ b/src/main/host/deferred-secret-protection-report.ts @@ -27,6 +27,7 @@ const REPORT_FALLBACK_MS = 15_000 export function scheduleSecretProtectionGapReport({ deferUntilFirstWindow, + skipInDevelopment = false, ...options }: { dataFile: string @@ -40,7 +41,13 @@ export function scheduleSecretProtectionGapReport({ * the timing serve already had, and the safer of the two. */ deferUntilFirstWindow: boolean + /** Development profiles are disposable/isolated and must not trigger OS keychain UI. */ + skipInDevelopment?: boolean }): void { + if (skipInDevelopment && !options.force) { + return + } + if (!deferUntilFirstWindow) { reportSecretProtectionGap(options) return diff --git a/src/main/index.ts b/src/main/index.ts index 5a4964bd729..522e59b908f 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,3831 +1,115 @@ -/* eslint-disable max-lines -- main-process entry point; owns app lifecycle, service wiring, window creation, and hook/daemon startup with no cleaner split seam. */ -import { existsSync, statSync } from 'node:fs' -import { randomUUID } from 'node:crypto' -import { isAbsolute, join } from 'node:path' -import os from 'node:os' -import { - app, - BrowserWindow, - clipboard, - dialog, - ipcMain, - nativeTheme, - powerMonitor, - type Tray, - session -} from 'electron' -import { applyMacPressAndHoldDefaultAtStartup } from './macos-press-and-hold-default' -import { initTccPromptNotice, stopTccPromptNotice } from './macos-tcc-prompt-notice' -import { electronApp, is } from '@electron-toolkit/utils' -import { - Store, - initDataPath, - getCanonicalUserDataPath, - migrateMobilePairingDataToCanonicalUserDataPath -} from './persistence' -import { setAppEnvironment } from '../shared/app-environment' -import { ElectronAppEnvironment } from './host/electron-app-environment' -import { setPtyHostBindings } from './ipc/pty-host-bindings' -import { electronRuntimeDesktopSurface } from './host/electron-runtime-desktop-surface' -import { setRuntimeDesktopSurface } from './runtime/runtime-desktop-surface' -import { electronRuntimeBrowserCommandsFactory } from './host/electron-browser-commands' -import { setRuntimeBrowserCommandsFactory } from './runtime/runtime-browser-commands-factory' -import { electronHttpClient } from './host/electron-http-client' -import { setMainHttpClient } from './network/http-client' -import { electronSpeechServiceFactories } from './host/electron-speech-services' -import { setSpeechServiceFactories } from './speech/speech-runtime-service' -import { setWorktreeWatcherRemoval } from './ipc/worktree-watcher-removal' -import { setSecretStore } from '../shared/secret-store' -import { ElectronSecretStore } from './host/electron-secret-store' -import { scheduleSecretProtectionGapReport } from './host/deferred-secret-protection-report' -import { initSessionParseCachePersistence } from './ai-vault/session-parse-cache-persistence' -import { ensureActiveOrcaProfile, initOrcaProfilePaths } from './orca-profiles/profile-index-store' -import { getOrcaCloudAuthConfig } from './orca-profiles/profile-cloud-auth-config' -import { getProfileUserDataPath } from './orca-profiles/profile-storage-paths' -import { applyAppIcon } from './app-icon' -import { relaunchApp } from './app-relaunch' -import { StatsCollector, initStatsPath } from './stats/collector' -import { initSshHostKeyStoreFile } from './ssh/ssh-host-key-store' -import { AgentSessionTransitionRecorder } from './stats/agent-session-transition-recorder' -import { ClaudeUsageStore, initClaudeUsagePath } from './claude-usage/store' -import { CodexUsageStore, initCodexUsagePath } from './codex-usage/store' -import { OpenCodeUsageStore, initOpenCodeUsagePath } from './opencode-usage/store' -import { - killAllPty, - clearProviderPtyState, - getPtyIdForPaneKey, - registerPaneKeyTeardownListener, - getLocalPtyProvider, - getSshPtyProvider, - registerHeadlessPtyRuntime, - type CodexHomeLaunchContext -} from './ipc/pty' -import { - initDaemonPtyProvider, - disconnectDaemon, - getDaemonProvider, - listLiveDaemonPtyIds, - shutdownDaemon -} from './daemon/daemon-init' -import { - type CodexPaneHomeRoute, - getCodexPaneAccount, - hasAnyRecordedLegacyWslCodexPane, - hasRecordedManagedHostCodexPane, - isCodexPaneHomeRouteProvenAwayFromSharedHome, - reconcileCodexPaneAccountsWithLivePtys -} from './codex/codex-pane-account-registry' -import { closeAllWatchers, desktopWorktreeWatcherRemoval } from './ipc/filesystem-watcher' -import { disposeWorktreeBaseDirectoryWatchers } from './ipc/worktree-base-directory-watcher' -import { stopFolderRepoGitUpgradeWatch } from './ipc/folder-repo-git-upgrade' -import { registerCoreHandlers } from './ipc/register-core-handlers/register-core-handlers' -import { initObservability, shutdownObservability } from './observability' -import { registerMobileHandlers } from './ipc/mobile' -import { initTelemetry, shutdownTelemetry, trackAppOpenedOnce, track } from './telemetry/client' -import { classifyError } from './telemetry/classify-error' -import { recordManagedHookInstallFailure } from './agent-hooks/install-telemetry' -import { - indexPersistedPaneKeyPtyIds, - isLocalExecutionHost, - resolveAgentWorkspaceExecutionHostId, - sweepRestoredSubagentsWithoutLiveAgent -} from './agent-hooks/restored-subagent-liveness-sweep' -import { - installManagedAgentHooks, - isAgentStatusHooksEnabled, - removeManagedAgentHooksAsync, - resolveStartupManagedHookAction, - shouldInstallStartupManagedAgentHook, - shouldContinueManagedHookStartup -} from './agent-hooks/managed-agent-hook-controls' -import { initCohortClassifier } from './telemetry/cohort-classifier' -import { initOnboardingCohortClassifier } from './telemetry/onboarding-cohort-classifier' -import { resolveConsent } from './telemetry/consent' -import { triggerStartupNotificationRegistration } from './ipc/startup-notification-registration' -import { OrcaRuntimeService, type RuntimeWorktreeLifecycleEvent } from './runtime/orca-runtime' -import { ArtifactCloudService } from './artifacts/artifact-cloud-service' -import { SkillCloudService } from './skills/skill-cloud-service' -import { recoverPendingSkillTransactions } from './skills/skill-transaction-startup-recovery' -import { isArtifactSharingEnabled } from '../shared/artifact-sharing-gate' -import { loadAgentSessionClaimSigner } from './runtime/agent-session-claim-identity' -import { - fingerprintOrchestrationPeer, - type OrchestrationEnvironmentTransport -} from './runtime/orchestration/environment-transport' -import { callRuntimeEnvironment } from './ipc/runtime-environment-transport-routing' -import { resolveEnvironment } from '../shared/runtime-environment-store' -import { getPreferredPairingOffer } from '../shared/runtime-environments' -import { OrcaRuntimeRpcServer } from './runtime/runtime-rpc' -import { - recordRuntimeRpcStartFailure, - showRuntimeRpcStartupFailureDialog -} from './runtime/runtime-rpc-startup-failure' -import { resolveAdvertisedPairingEndpoint } from './runtime/pairing-endpoint' -import { ServeReadinessPublisher } from './server/serve-readiness' -import { reserveServeStdoutForReadiness } from './server/serve-stdout-boundary' -import { DesktopRelayService } from './runtime/relay/desktop-relay-service' -import type { RelayBrokerStatus } from './runtime/relay/relay-session-broker' -import { awaitRuntimeFileWatcherUnsubscribes } from './runtime/orca-runtime-files' -import { clearRuntimeMetadataIfOwned } from './runtime/runtime-metadata' -import { scheduleAllPendingHistoryTreeRemovals } from './terminal-history-deletion' -import { ensureMainI18n, setMainPluginLanguagePacks, setMainUiLanguage } from './i18n/main-i18n' -import { - getNextDefaultOnAppearanceSettingValue, - registerAppMenu, - rebuildAppMenu -} from './menu/register-app-menu' -import { createGpuAccelerationAboutPanelOptions } from './menu/gpu-acceleration-about-panel' -import { - checkForRemoteServerUpdate, - checkForUpdatesFromMenu, - downloadRemoteServerUpdate, - getRemoteServerUpdaterSnapshot, - installRemoteServerUpdate, - isQuittingForUpdate, - resolveUpdateInstallMode -} from './updater' -import { configureRemoteServerUpdater } from './runtime/remote-server-updater' -import type { UpdateCheckOptions } from '../shared/update-status-types' -import { recordUpdaterLifecycle } from './updater-lifecycle-diagnostics' -import { - installServeSupervisorDisconnectQuit, - notifyServeSupervisorReady -} from './serve-update-handoff' -import { - configureElectronNetworkCompatibility, - configureDevUserDataPath, - configureOrcaUserDataPathEnv, - disableUnsupportedChromiumFeatures, - optOutOfHiddenPageWakeUpThrottling, - enableMainProcessGpuFeatures, - installDevParentDisconnectQuit, - installDevParentSignalQuit, - installDevParentWatchdog, - isDevParentShutdownRequested, - patchPackagedProcessPath, - shouldInstallManagedHooks -} from './startup/configure-process' -import { - installUncaughtPipeErrorGuard, - installUnhandledRejectionLogging -} from './startup/main-process-error-guards' -import { enableRendererHeapHeadroom } from './startup/renderer-heap-headroom' -import { argvRequestsServeMode, normalizeServeModeArgv } from './startup/serve-mode-argv' -import { ensureVirtualDisplayForHeadlessServe } from './startup/ensure-virtual-display' -import { - clearGpuFallbackMarker, - readActiveGpuFallbackMarker, - writeGpuFallbackMarker, - type GpuFallbackMarker, - type GpuFallbackEnvironment, - type WindowsGpuFallbackEnvironment -} from './startup/gpu-fallback-marker' -import { applyGpuFallbackCommandLineSwitches } from './startup/gpu-fallback-switches' -import { - DEFAULT_GPU_CRASH_FALLBACK_THRESHOLD, - DEFAULT_GPU_CRASH_FALLBACK_WINDOW_MS, - GpuCrashFallbackTracker, - isGpuFallbackCrashCandidate -} from './crash-reporting/gpu-crash-fallback-decision' -import { promptForGpuFallbackRestart } from './crash-reporting/gpu-fallback-restart-prompt' -import { engageGpuFallbackAfterCrashBurst } from './crash-reporting/gpu-fallback-engagement' -import { GpuCrashDiagnosticsRecorder } from './crash-reporting/gpu-crash-diagnostics' -import { - handleGpuFallbackRecoveredLaunch, - promptForGpuFallbackRecoveredLaunch -} from './crash-reporting/gpu-fallback-recovered-launch' -import { - shouldSuppressDevEducation, - suppressDevEducationForStore -} from './startup/dev-education-suppression' -import { maybeRedirectAppImageCliLaunch } from './startup/appimage-cli-redirect' -import { maybeRedirectPackagedCliEntryLaunch } from './startup/packaged-cli-entry-redirect' -import { startFirstWindowStartupServices } from './startup/first-window-startup-services' -import { recoverLegacyWorkerTerminalsForRendererStartup } from './startup/legacy-worker-renderer-recovery' -import { createWslCliReconciliationStartupBarrier } from './startup/wsl-cli-reconciliation-startup-barrier' -import { getDevInstanceIdentity, shouldApplyPreReadyAppName } from './startup/dev-instance-identity' -import { hydrateShellPath, mergePathSegments } from './startup/hydrate-shell-path' -import { createWindowsShellPathHydration } from './startup/windows-shell-path-hydration' -import { - startWindowsDesktopBeforeShellPathReady, - type WindowsDesktopStartupServices -} from './startup/windows-desktop-shell-path-startup' -import { - acquireSingleInstanceLock, - logSingleInstanceLockBypass, - logSingleInstanceLockFailure, - shouldActivateDesktopForSecondInstance, - shouldBypassSingleInstanceLock, - shouldSkipSingleInstanceLock, - SINGLE_INSTANCE_ALREADY_RUNNING_EXIT_CODE -} from './startup/single-instance-lock' -import { startEventLoopStallProbe } from './startup/event-loop-stall-probe' -import { startMainThreadChurnProbe } from './diagnostics/main-thread-churn-probe' -import { settledDiffCache } from './git/source-control/git-read-cache-invalidation' +import { app, type BrowserWindow } from 'electron' import { parseSkillShareId } from '../shared/skill-share-link' -import { SkillShareDeepLinkState } from './startup/skill-share-deep-link-state' -import { - isStartupDiagnosticsEnabled, - logStartupDiagnostic, - logStartupMilestone -} from './startup/startup-diagnostics' -import { ensureWindowsUserDataAclGrant } from './startup/windows-user-data-acl' -import { probeWindowsInstallDirAcl } from './startup/windows-install-dir-acl-probe' -import { - describeInstallDirAclPoison, - startWindowsInstallDirAclRepairIfPoisoned -} from './startup/windows-install-dir-acl-recovery' -import { presentRendererRecoveryPrompt } from './window/renderer-recovery-prompt' -import { neutralizeLegacyTerminalShimDir } from './pty/legacy-terminal-shim-dir' -import { shouldQuitWhenAllWindowsClosed } from './startup/window-all-closed-quit-policy' -import { registerServeSignalHandlers } from './startup/serve-signal-handlers' -import { - createServeDesktopActivationGate, - settleServeDesktopActivation as settleServeDesktopActivationGate -} from './startup/serve-desktop-activation' -import { RateLimitService } from './rate-limits/service' -import { readMiniMaxSessionCookie } from './minimax/minimax-cookie-store' -import { getInitialClaudeRateLimitTarget } from './rate-limits/claude-rate-limit-target' -import { getInitialCodexRateLimitTarget } from './rate-limits/codex-rate-limit-target' -import { getKimiRuntimeTarget, resolveKimiHome } from './kimi/kimi-runtime-home' -import { createAccountRuntimeTargetSettingsSync } from './rate-limits/account-runtime-target-sync' -import { - attachMainWindowServices, - ensureAutoUpdaterConfigured -} from './window/attach-main-window-services' -import { createMainWindow, loadMainWindow } from './window/createMainWindow' -import { shutdownPairedRuntimeBrowserClientHosts } from './browser/paired-runtime-browser-client-host-runtime' -import { - getDashboardPopoutWindow, - zoomDashboardPopoutIfFocused -} from './window/dashboard-popout-window' -import { - createSystemTray, - destroySystemTray, - setMacMenuBarIconVisible, - setTrayAttention, - type SystemTrayOptions -} from './tray/system-tray' import { createMacAppActivationHandler } from './window/macos-app-activation' -import { focusExistingMainWindow, safelyRevealWindow } from './window/focus-existing-window' -import { applyBackgroundActivationPolicy } from './window/foreground-activation-policy' -import { notifyMainWindowBecameVisible } from './window/main-window-visibility' -import { CodexAccountService } from './codex-accounts/service' -import { CodexRuntimeHomeService } from './codex-accounts/runtime-home-service' -import { markCodexProjectTrusted } from './agent-trust-presets' import { - normalizeCodexRuntimeSelection, - type CodexAccountSelectionTarget -} from './codex-accounts/runtime-selection' -import { normalizeClaudeRuntimeSelection } from './claude-accounts/runtime-selection' -import { codexHookService, setSystemCodexHomeHookSweepSuppressed } from './codex/hook-service' -import { reconcileRetainedCodexHookHomes } from './codex/retained-codex-hook-state' -import { - ensureRealHomeCodexHookState, - isRealHomeCodexHookLaneUsable -} from './codex/codex-real-home-hook-install' -import { setCodexTrustGrantTelemetry } from './codex/codex-trust-grant-telemetry' -import { startCodexSessionBackfillInBackground } from './codex/codex-session-backfill' -import { startCodexSessionIndexHealInBackground } from './codex/codex-session-index-heal' -import { - startCodexStateDbBackfillRecoveryInBackground, - stopCodexStateDbBackfillRecoveries -} from './codex/codex-state-db-backfill-recovery' -import { createCodexSessionMigrationScheduler } from './codex/codex-session-migration-scheduler' -import { prepareCodexAiVaultSessionResume } from './codex/codex-ai-vault-session-resume' -import { prepareLegacySharedCodexSessionResume } from './codex/codex-legacy-session-resume' -import { ManagedCodexHomeTemporarilyUnavailableError } from './codex-accounts/host-codex-managed-home-ownership' -import { resolveHostCodexSessionSourceHome } from './codex/codex-session-source-home' -import type { CodexSessionResumePreparation } from './codex/codex-session-resume-home' -import { prepareCodexSessionResume } from './codex/codex-session-resume-preparation' -import { getOrcaManagedCodexHomePath, getSystemCodexHomePath } from './codex/codex-home-paths' -import { normalizeRuntimePathForComparison } from '../shared/cross-platform-path' -import type { AgentProviderSessionMetadata } from '../shared/agent-session-resume' -import { getDefaultWslDistro } from './wsl' -import { collectWorktreeTrashSweepRoots, sweepStaleWorktreeTrash } from './worktree-trash' -import { ClaudeAccountService } from './claude-accounts/service' -import { ClaudeRuntimeAuthService } from './claude-accounts/runtime-auth-service' -import { - attachClaudeLivePtyPersistence, - onLiveClaudePtysDrained, - seedLiveClaudePtysFromPersistence -} from './claude-accounts/live-pty-gate' -import { StarNagService } from './star-nag/service' -import { agentHookServer, type AgentHookProviderSessionIdentity } from './agent-hooks/server' -import { createHookProviderSessionInvalidator } from './agent-hooks/hook-provider-session-invalidation' -import { createHookStatusSessionTabsInvalidator } from './agent-hooks/hook-status-session-tabs-invalidation' -import { wslHookRelayManager } from './agent-hooks/wsl-hook-relay-manager' -import { maybeAutoRenameBranchOnFirstWork } from './agent-hooks/first-work-branch-rename' -import { rememberBranchRenameFailureOutput } from './agent-hooks/branch-rename-failure-output' -import { renameWorktreeFolderOnFirstWork } from './agent-hooks/first-work-folder-rename' -import { moveWorktree } from './git/worktree' -import { - configureWindowsHostGitEnvironmentReadiness, - setDefaultWslDistroOverride -} from './git/runner' -import { getRepoIdFromWorktreeId } from '../shared/worktree/id' -import { parseWorkspaceKey } from '../shared/workspace-scope' -import { setMigrationUnsupportedPtyListener } from './agent-hooks/migration-unsupported-pty-state' -import { AgentBrowserBridge } from './browser/agent-browser-bridge' -import { configureBrowserClientPageAutomationRuntime } from './browser/browser-client-page-automation-runtime' -import { BrowserClientPageCommandError } from './browser/browser-client-page-command-failure' -import { EmulatorBridge } from './emulator/emulator-bridge' -import { browserCertificateTrustController, browserManager } from './browser/browser-manager' -import { RpcDispatcher } from './runtime/rpc/dispatcher' -import { OffscreenBrowserBackend } from './browser/offscreen-browser-backend' -import { browserSessionRegistry } from './browser/browser-session-registry' -import { - applyBrowserSessionProxies, - setBrowserNetworkProxySettingsResolver -} from './browser/browser-session-proxy' -import { initializeBrowserSessionsForApp } from './browser/browser-session-startup' -import { - installDocPreviewProtocolHandler, - registerDocPreviewSchemePrivileges -} from './browser/doc-preview-protocol' -import { registerDocPreviewGrantHandlers } from './ipc/doc-preview-grant-ipc' -import { initializeBrowserClientHostId } from './browser/browser-client-host-id' -import { setUnreadDockBadgeCount } from './dock/unread-badge' -import { AutomationService } from './automations/service' -import { createHeadlessAutomationOutputSnapshotBuffer } from './automations/headless-dispatch' -import { buildHeadlessAutomationWorktreeCreateArgs } from './automations/headless-workspace-create' -import { createRuntimeAutomationRunTerminalObserver } from './automations/runtime-terminal-run-observer' -import { AgentAwakeService } from './agent-awake-service' -import { normalizeComputerAwakeMode } from '../shared/computer-awake-mode' -import { registerSystemResumeBroadcast } from './system-resume-broadcast' -import { settleTeardownWithinDeadline, settleWithinMs } from './quit-teardown-deadline' -import { stopStructuredAgentSessionRuntime } from './runtime/structured-agent-session-runtime' -import { quitTeardownStartGate } from './quit-teardown-start-gate' -import { beginSshShutdown } from './ipc/ssh-shutdown-drain' -import { PluginService } from './plugins/plugin-service' -import { PluginKillListService } from './plugins/plugin-kill-list-service' -import { getPluginsDataDir } from './plugins/plugin-discovery' -import { PluginMarketplaceService } from './plugins/plugin-marketplace-service' -import { PluginMarketplaceInstaller } from './plugins/plugin-marketplace-installer' -import { PluginBundledBootstrapCoordinator } from './plugins/plugin-bundled-bootstrap-coordinator' -import { resolveBundledPluginRoot } from './plugins/plugin-bundled-bootstrap' -import { resolvePluginHostEntryPath } from './plugins/plugin-host-process' -import { applyPluginConsent, applyPluginEnablement } from './plugins/plugin-enablement' -import { setPluginServiceForRpc } from './runtime/rpc/methods/plugins' -import { - normalizePluginConsents, - normalizePluginIdList -} from '../shared/plugins/plugin-consent-state' -import { - recordCoalescedCrashBreadcrumb, - recordCrashBreadcrumb -} from './crash-reporting/crash-breadcrumb-store' -import { recordDurableCrashBreadcrumb } from './crash-reporting/durable-crash-breadcrumb' -import { installMainThreadHangWatchdog } from './hang-watchdog/main-thread-hang-watchdog' -import { - consumeHangDetectionMarker, - hangDetectionMarkerPath -} from './hang-watchdog/hang-detection-marker' -import { getMainProcessLifecycleIdentity } from './crash-reporting/main-process-lifecycle-identity' -import { CrashReportStore } from './crash-reporting/crash-report-store' -import { - shouldRecoverRendererAfterProcessGone, - type ExpectedTeardownScope -} from './crash-reporting/process-gone-classification' -import { recordProcessGoneCrash as recordProcessGoneCrashEvent } from './crash-reporting/process-gone-recorder' -import { startCrashpadCapture } from './crash-reporting/crashpad-capture' -import { startPreGoneProcessMetricsSampling } from './crash-reporting/process-gone-diagnostics' -import { resolveExpectedTeardownScope } from './crash-reporting/expected-teardown-state' -import { - advanceSyntheticTitleSpinnerEntries, - getSyntheticTitleSpinnerPaneKeyToStop, - type SyntheticTitleSpinnerEntry -} from './synthetic-title-spinner' -import { shouldSendSyntheticTitleFrame } from './synthetic-title-visibility' -import { shouldCopySyntheticTitleFrameToPtyData } from './synthetic-title-frame-routing' -import { - getSyntheticAgentTitleProfile, - shouldDriveSyntheticAgentTitleFromHook, - type SyntheticAgentTitleProfile -} from '../shared/synthetic-agent-title' -import type { AgentStatusState } from '../shared/agent-status-types' -import { resolveTuiAgentPermissionMode } from '../shared/tui-agent-permissions' -import { isAskUserQuestionTool } from '../shared/agent-question-answered-intent' -import type { TerminalSideEffectBatch } from '../shared/terminal-side-effect-facts' -import { - HEADLESS_RUNTIME_WINDOW_ID, - type RuntimeDesktopWindowStatus -} from '../shared/runtime-types' -import { LocalPtyProvider } from './providers/local-pty-provider' -import { KeybindingService } from './keybindings/keybinding-service' -import { - applyElectronProxySettings, - setDefaultProxySessionResolver -} from './network/proxy-settings' -import { handleElectronProxyLogin } from './network/electron-proxy-credentials' -import { installElectronProxyRequestGuard } from './network/electron-proxy-request-guard' -import { preserveAgentAuthBeforeRestart } from './agent-auth-restart-preservation' -import { CliInstaller } from './cli/cli-installer' -import { installLinuxBareOrcaDispatcher } from './cli/linux-bare-orca-dispatcher' -import { reconcileManagedWslCliRegistrations } from './cli/wsl-cli-registration-reconciliation' + focusExistingWindow as focusExistingWindowAction, + setMainWindowOpener +} from './startup/main-window-actions' +import { openMainWindow as openMainWindowController } from './startup/main-window-controller' +import { mainProcessState as state } from './startup/main-process-state' +import { runMainProcessPreflight } from './startup/main-process-preflight' +import { registerMainProcessIpcHandlers } from './startup/main-process-ipc-bootstrap' +import { initializeMainProcessReady } from './startup/main-process-ready' +import { installMainProcessQuitHandlers } from './startup/main-process-quit' +import { shouldActivateDesktopForSecondInstance } from './startup/single-instance-lock' +import { resolveOpenedMarkdownDocuments } from './startup/os-opened-markdown-files' -let mainWindow: BrowserWindow | null = null -/** Whether a manual app.quit() (Cmd+Q) is in progress; lets the close handler skip the running-process confirmation and go straight to close. */ -let isQuitting = false -let store: Store | null = null -let stats: StatsCollector | null = null -let claudeUsage: ClaudeUsageStore | null = null -let codexUsage: CodexUsageStore | null = null -let openCodeUsage: OpenCodeUsageStore | null = null -let codexAccounts: CodexAccountService | null = null -let codexRuntimeHome: CodexRuntimeHomeService | null = null -let codexSessionMigration: ReturnType | null = null -let claudeAccounts: ClaudeAccountService | null = null -let claudeRuntimeAuth: ClaudeRuntimeAuthService | null = null -let runtime: OrcaRuntimeService | null = null -let rateLimits: RateLimitService | null = null -let runtimeRpc: OrcaRuntimeRpcServer | null = null -const serveReadinessPublisher = new ServeReadinessPublisher() -let desktopRelayService: DesktopRelayService | null = null -let desktopRelayStatus: RelayBrokerStatus = 'offline' -let pendingUnpairedDeviceAuthFailure = false -// Why: gates whether headless serve installs the offscreen browser backend (and advertises browser pane support). -let headlessBrowserDisplayAvailable = false - -let starNag: StarNagService | null = null -let agentAwakeService: AgentAwakeService | null = null -let crashReports: CrashReportStore | null = null -let unsubscribeAgentAwakeStatusChanges: (() => void) | null = null -let unsubscribeSystemResumeBroadcast: (() => void) | null = null -let watcherShutdownPromise: Promise | null = null -let watcherShutdownDone = false -let automations: AutomationService | null = null -let pluginService: PluginService | null = null -let pluginKillListService: PluginKillListService | null = null -let pluginMarketplaceService: PluginMarketplaceService | null = null -let pluginMarketplaceInstaller: PluginMarketplaceInstaller | null = null -let keybindings: KeybindingService | null = null - -function emitPluginWorktreeLifecycle(event: RuntimeWorktreeLifecycleEvent): void { - pluginService?.emitEvent( - event.kind === 'created' ? 'worktree.created' : 'worktree.removed', - event.kind === 'created' - ? { worktreeId: event.worktreeId, path: event.path, branch: event.branch } - : { worktreeId: event.worktreeId, path: event.path } - ) -} -// Why: a reload intent must not leak to a later load; the recovery reload re-fires did-finish-load, so its flag spares live PTYs from the orphan sweep (#5787). -const expectedRendererReload = createWebContentsTimedFlag() -const recoveryReloadInFlight = createWebContentsTimedFlag() -// Why: a tray "Settings…" click can precede the renderer's ui:openSettings listener; it pulls this one-shot on mount. -const pendingOpenSettings = createWebContentsTimedFlag() -const skillShareDeepLinks = new SkillShareDeepLinkState() -let firstWindowStartupServicesReady: Promise = Promise.resolve() -let managedWslCliReconciliationReady: Promise = Promise.resolve() -let managedWslCliStartupBarrierReady: Promise = Promise.resolve() -// Why: the serve barrier fails open, so this state tells headless clients a WSL PTY launch may still race an un-migrated registration ('settled' = off-Windows no-op). -let managedWslCliReconciliationStatus: 'pending' | 'settled' | 'failed' = 'settled' -const gpuCrashFallbackTracker = new GpuCrashFallbackTracker({ - windowMs: DEFAULT_GPU_CRASH_FALLBACK_WINDOW_MS, - threshold: DEFAULT_GPU_CRASH_FALLBACK_THRESHOLD -}) -let activeGpuFallbackMarker: GpuFallbackMarker | null = null -let gpuFallbackActiveThisLaunch = false -let gpuFeatureStatus: Electron.GPUFeatureStatus | null = null -const gpuCrashDiagnostics = - process.platform === 'win32' - ? new GpuCrashDiagnosticsRecorder({ - provider: { - getGPUInfo: (infoType) => app.getGPUInfo(infoType), - getGPUFeatureStatus: () => app.getGPUFeatureStatus() - }, - recordBreadcrumb: (data) => recordDurableCrashBreadcrumb('gpu_crash_hardware', data) - }) - : null -let localPtyStartupReady: Promise = Promise.resolve() -let localPtyProviderStartupReady: Promise = Promise.resolve() -const AGENT_STATE_CRASH_BREADCRUMB_MIN_INTERVAL_MS = 30_000 - -function handleCodexHomePtySpawned(args: { - id: string - codexHomePath: string | null - reattached?: boolean - reattachedHomeRoute?: CodexPaneHomeRoute | null - launchEnv?: NodeJS.ProcessEnv - startedAt?: Date - startedSequence?: number -}): void { - // Why: only shared or ambiguous retained shells can create rollout logs that still need publication. - if (args.reattached && args.startedSequence !== undefined) { - const paneAccount = getCodexPaneAccount(args.id) - const homeRoute = - args.reattachedHomeRoute !== undefined - ? (args.reattachedHomeRoute ?? undefined) - : paneAccount?.homeRoute - if (codexSessionMigration && isCodexPaneHomeRouteProvenAwayFromSharedHome(homeRoute)) { - codexSessionMigration.ignoreLaunch(args.id, args.startedSequence) - return - } - } - const fullScanRequired = - codexRuntimeHome?.beginHostSystemDefaultSessionMigrationLaunch(args.codexHomePath, { - reattached: args.reattached, - launchEnv: args.launchEnv - }) ?? null - if (fullScanRequired !== null) { - codexSessionMigration?.beginLaunch( - args.id, - args.reattached === true || fullScanRequired, - args.startedAt, - args.startedSequence - ) - } +function openMainWindow(options: { revealOnDidFinishLoad?: boolean } = {}): BrowserWindow { + return openMainWindowController(options) } -function handlePtyExit(id: string, exitSequence: number): void { - codexSessionMigration?.finishLaunch(id, exitSequence) -} -// Why: on Windows a CLI launch that lost ELECTRON_RUN_AS_NODE would boot the GUI and exit silently; redirect to node mode before the lock gate below. -// Both redirects run before the serve-argv rewrite so they still match on the launch argv verbatim. -// It is load-bearing for the AppImage one: rewriting first replaces the `serve` positional, so its -// command-name lookup finds a port number and strands the launch in an in-process serve. The -// packaged-CLI one matches on the entry path instead, so order cannot affect it either way. -const packagedCliEntryRedirect = maybeRedirectPackagedCliEntryLaunch({ - isPackaged: app.isPackaged, - resourcesPath: process.resourcesPath, - execPath: process.execPath -}) -if (packagedCliEntryRedirect.redirected) { - app.exit(packagedCliEntryRedirect.status) -} -const appImageCliRedirect = maybeRedirectAppImageCliLaunch({ - isPackaged: app.isPackaged, - resourcesPath: process.resourcesPath, - execPath: process.execPath -}) -if (appImageCliRedirect.redirected) { - app.exit(appImageCliRedirect.status) -} -// Why: extracted AppRun / binary launches can land CLI-form `serve` args on the -// Electron process without the CLI rewrite that injects `--serve` (#12677). -// Guarded so a normal GUI launch keeps its original argv array identity. -if (argvRequestsServeMode(process.argv)) { - process.argv = normalizeServeModeArgv(process.argv) -} -const isServeMode = process.argv.includes('--serve') - -function updateGpuAccelerationAboutPanel(): void { - app.setAboutPanelOptions( - createGpuAccelerationAboutPanelOptions({ - appName: app.name, - appVersion: app.getVersion(), - platform: process.platform, - gpuFallbackActive: gpuFallbackActiveThisLaunch, - gpuFeatureStatus - }) - ) -} - -app.on('gpu-info-update', () => { - gpuFeatureStatus = app.getGPUFeatureStatus() - gpuCrashDiagnostics?.warm() - if (app.isReady()) { - updateGpuAccelerationAboutPanel() - } -}) -if (isServeMode) { - reserveServeStdoutForReadiness() -} -const desktopActivationGate = createServeDesktopActivationGate({ - initialState: isServeMode ? 'initializing' : 'ready', - activateWindow: () => { - // Why: an updater replacement must not resurrect the old app bundle. - if (!isQuittingForUpdate()) { - focusExistingWindow() - } - }, - onBlocked: (reason) => console.error(`[serve] Desktop activation blocked: ${reason}`) -}) - -// Kill switch for the first-work on-disk folder rename; the renderer reconciles the id change (migrateWorktreeIdentity) so it isn't mistaken for a deletion. -const ENABLE_FIRST_WORK_FOLDER_RENAME = false - -// Why: inject the index.ts store/runtime singletons so the rename orchestrator stays module-state-free and unit-testable. -function maybeAutoRenameBranchOnFirstWorkFromHook(event: { - paneKey: string - tabId: string | undefined - worktreeId: string | undefined - payload: { state: string; prompt?: string; lastAssistantMessage?: string } - isReplay: boolean | undefined -}): void { - const currentStore = store - const currentRuntime = runtime - if (!currentStore || !currentRuntime) { - return - } - void maybeAutoRenameBranchOnFirstWork( - { - paneKey: event.paneKey, - tabId: event.tabId, - worktreeId: event.worktreeId, - state: event.payload.state, - prompt: event.payload.prompt, - assistantMessage: event.payload.lastAssistantMessage, - isReplay: event.isReplay - }, - { - getSettings: () => currentStore.getSettings(), - getRepo: (repoId) => currentStore.getRepo(repoId), - getAgentEnvResolvers: () => currentRuntime.getCommitMessageAgentEnvironmentResolvers(), - getCurrentDisplayName: (worktreeId) => { - const scope = parseWorkspaceKey(worktreeId) - if (scope?.type === 'folder') { - return currentStore.getFolderWorkspace(scope.folderWorkspaceId)?.name - } - return currentStore.getWorktreeMeta(worktreeId)?.displayName - }, - getFolderWorkspacePath: (worktreeId) => { - const scope = parseWorkspaceKey(worktreeId) - return scope?.type === 'folder' - ? currentStore.getFolderWorkspace(scope.folderWorkspaceId)?.folderPath - : undefined - }, - isPendingFirstAgentMessageRename: (worktreeId) => { - const scope = parseWorkspaceKey(worktreeId) - if (scope?.type === 'folder') { - return ( - currentStore.getFolderWorkspace(scope.folderWorkspaceId) - ?.pendingFirstAgentMessageRename === true - ) - } - return currentStore.getWorktreeMeta(worktreeId)?.pendingFirstAgentMessageRename === true - }, - canRenameOrcaCreatedBranch: (worktreeId) => { - const meta = currentStore.getWorktreeMeta(worktreeId) - // Why: a user branch could coincidentally match a creature name; only Orca-stamped worktrees are safe to auto-rename. - return !!meta?.orcaCreationSource && meta.preserveBranchOnDelete !== true - }, - setDisplayName: (worktreeId, displayName) => { - rememberBranchRenameFailureOutput(worktreeId, null) - const scope = parseWorkspaceKey(worktreeId) - if (scope?.type === 'folder') { - currentStore.updateFolderWorkspace(scope.folderWorkspaceId, { - name: displayName, - pendingFirstAgentMessageRename: false, - firstAgentMessageRenameError: null - }) - currentRuntime.notifyFolderWorkspaceChanged() - return - } - currentStore.setWorktreeMeta(worktreeId, { - displayName, - // The first-agent title is an intentional user-facing label; keep it stable after the - // generated branch is renamed and across subsequent catalog refreshes. - displayNameIsPinned: true, - pendingFirstAgentMessageRename: false, - // Success clears the failure badge (redundant with the explicit setRenameError(null)). - firstAgentMessageRenameError: null - }) - }, - renameWorktreeFolder: ENABLE_FIRST_WORK_FOLDER_RENAME - ? (worktreeId, newLeaf) => - renameWorktreeFolderOnFirstWork(worktreeId, newLeaf, { - getRepo: (repoId) => currentStore.getRepo(repoId), - getSettings: () => currentStore.getSettings(), - migrateWorktreeIdentity: (oldId, newId) => - currentStore.migrateWorktreeIdentity(oldId, newId), - notifyWorktreeRenamed: (repoId, oldId, newId) => - currentRuntime.notifyWorktreeFolderRenamed(repoId, oldId, newId), - pathExists: async (candidate) => existsSync(candidate), - moveWorktree - }) - : undefined, - setRenameError: (worktreeId, error, failureOutput) => { - // Refresh the full-output capture before the dedupe below — a repeat error string is still a fresh run. - rememberBranchRenameFailureOutput(worktreeId, error === null ? null : failureOutput) - // Skip the write + push when unchanged — most settled worktrees never had an error to clear. - const scope = parseWorkspaceKey(worktreeId) - if (scope?.type === 'folder') { - const current = currentStore.getFolderWorkspace( - scope.folderWorkspaceId - )?.firstAgentMessageRenameError - if ((current ?? null) === (error ?? null)) { - return - } - currentStore.updateFolderWorkspace(scope.folderWorkspaceId, { - firstAgentMessageRenameError: error - }) - currentRuntime.notifyFolderWorkspaceChanged() - return - } - const current = currentStore.getWorktreeMeta(worktreeId)?.firstAgentMessageRenameError - if ((current ?? null) === (error ?? null)) { - return - } - currentStore.setWorktreeMeta(worktreeId, { firstAgentMessageRenameError: error }) - // Why: the hook only knows the worktreeId, so derive the repoId notifyBranchRenamed expects. - currentRuntime.notifyBranchRenamed(getRepoIdFromWorktreeId(worktreeId)) - }, - resolveWorktreeIdForTab: (tabId) => currentStore.getWorktreeIdForTab(tabId), - onRenamed: (repoIdOrWorktreeId) => { - if (parseWorkspaceKey(repoIdOrWorktreeId)?.type === 'folder') { - currentRuntime.notifyFolderWorkspaceChanged() - return - } - currentRuntime.notifyBranchRenamed(repoIdOrWorktreeId) - } - } - ) -} - -const devInstanceIdentity = getDevInstanceIdentity(is.dev) -const devAgentHookEndpointNamespace = devInstanceIdentity.isDev - ? devInstanceIdentity.appUserModelId - : undefined - -installUncaughtPipeErrorGuard() -// Why (issue #9441): without this, one rejected background promise during startup restore kills main silently (exit 1, no crash report). -installUnhandledRejectionLogging() -// Why: expose the app version via process.env so main and the forked daemon can set TERM_PROGRAM_VERSION without importing electron. -process.env.ORCA_APP_VERSION = app.getVersion() -configureRemoteServerUpdater({ - getSnapshot: getRemoteServerUpdaterSnapshot, - check: checkForRemoteServerUpdate, - download: downloadRemoteServerUpdate, - install: installRemoteServerUpdate -}) -patchPackagedProcessPath() -// Why: the sync seed above covers early IPC (homebrew/nix); the async login-shell probe below (packaged only) then adds the user's rc PATH. -if (app.isPackaged && process.platform !== 'win32') { - void hydrateShellPath().then((result) => { - if (result.ok) { - mergePathSegments(result.segments) - return - } - // Why: on failure the seeded fallbacks stay in front. For an nvm user that is - // now their `default` version rather than the newest install, so it is usually - // survivable — but it is still not what their shell would have resolved. Name - // the reason so it shows up in a log bundle instead of as a missing CLI. - console.warn( - `[shell-path] login-shell probe failed (${result.failureReason}); using seeded PATH` - ) - }) -} -configureDevUserDataPath(is.dev) -configureOrcaUserDataPathEnv() -// Why these four lines are one step (#16761): the two above decide where userData lives, and -// everything below may resolve a path. Installing the accessor any later leaves a window where an -// early resolve either throws — which is what killed `orca serve` — or, worse, memoizes the -// pre-override directory and silently writes user state to the wrong place for the whole session. -// Safe this early: ElectronAppEnvironment holds no state and calls `app` lazily per accessor, so it -// changes no timing, and initDataPath only joins strings. -setAppEnvironment(new ElectronAppEnvironment()) -// Why captured now: after the dev/E2E override above, and before app.setName('Orca') (whenReady) -// changes how userData resolves on a case-sensitive filesystem. See persistence.ts:20-28. -initDataPath() - -// Why: just past createMainWindow's 10s ready-to-show fallback, so a window revealed that way still gets its tray icon. -const TRAY_CREATE_FALLBACK_MS = 12_000 - -const startupDiagnosticsEnabled = isStartupDiagnosticsEnabled() -if (startupDiagnosticsEnabled) { - logStartupDiagnostic('before-single-instance-lock', { - version: app.getVersion(), - packaged: app.isPackaged, - platform: process.platform, - osRelease: os.release(), - userData: app.getPath('userData'), - e2eUserData: Boolean(process.env.ORCA_E2E_USER_DATA_DIR) - }) - startEventLoopStallProbe() -} -// Self-gated on ORCA_MAIN_THREAD_DIAGNOSTICS; runs the whole session to catch steady-state churn (issue #7576). -// Why the diff-cache counters ride along: a stamp the filesystem reports unstably makes the cache -// look exactly like a cold start, and only the hit/miss/unprovable split tells the two apart. -startMainThreadChurnProbe({ extraStats: () => ({ diffCache: settledDiffCache.stats() }) }) +setMainWindowOpener(openMainWindow) function focusExistingWindow(): void { - focusExistingMainWindow({ - app, - getWindow: () => mainWindow, - openWindow: openMainWindow, - warn: console.warn - }) + focusExistingWindowAction() } function requestDesktopActivation(argv: readonly string[] = []): void { - skillShareDeepLinks.capture(argv, (shareId) => { - mainWindow?.webContents.send('ui:openSkillShare', shareId) + state.skillShareDeepLinks.capture(argv, (shareId) => { + state.mainWindow?.webContents.send('ui:openSkillShare', shareId) }) + state.osOpenedMarkdownFiles.capture(argv, publishOsOpenedMarkdownFiles) // Why: a duplicate `orca serve` must not drag a headless server into opening a desktop window (#11935). if (!shouldActivateDesktopForSecondInstance(argv)) { return } - desktopActivationGate.requestActivation() + state.desktopActivationGate?.requestActivation() } -app.on('open-url', (event, url) => { - if (!parseSkillShareId(url)) { +/** + * Hands buffered OS-opened markdown paths to a renderer that has proven it is listening. + * + * Until that proof arrives the paths stay buffered, because `webContents.send` to a renderer + * with no listener attached is dropped silently and the queue would be gone. + */ +function publishOsOpenedMarkdownFiles(): void { + const targetWindow = state.mainWindow + if (!state.markdownFileOpenListenerReady || !targetWindow || targetWindow.isDestroyed()) { return } - event.preventDefault() - requestDesktopActivation([url]) -}) - -skillShareDeepLinks.capture(process.argv) + // Why consumed before the await: a renderer pull racing this resolve must not take the same + // batch again. The restore() calls hand it back if delivery turns out to be impossible. + const filePaths = state.osOpenedMarkdownFiles.consume() + if (filePaths.length === 0) { + return + } + void resolveOpenedMarkdownDocuments(filePaths) + .then((documents) => { + if (targetWindow.isDestroyed() || targetWindow.webContents.isDestroyed()) { + state.osOpenedMarkdownFiles.restore(filePaths) + return + } + if (documents.length > 0) { + targetWindow.webContents.send('ui:openMarkdownFiles', documents) + } + }) + .catch((error) => { + state.osOpenedMarkdownFiles.restore(filePaths) + console.warn('[os-open] Failed to resolve OS-opened markdown files:', error) + }) +} const handleMacAppActivation = createMacAppActivationHandler({ - getWindow: () => mainWindow, + getWindow: () => state.mainWindow, requestActivation: requestDesktopActivation }) -function getDesktopWindowStatus(): RuntimeDesktopWindowStatus { - const state = desktopActivationGate.getState() - return state === 'ready' ? 'openable' : state -} - -function settleServeDesktopActivation(): void { - settleServeDesktopActivationGate(desktopActivationGate, { - hasPersistentPtyProvider: !(getLocalPtyProvider() instanceof LocalPtyProvider) - }) -} - -// Why: webContents-scoped auto-expiring flag so an intent can't leak to a later renderer load; `consume` clears on match for one-shot signals. -function createWebContentsTimedFlag(defaultDurationMs = 10_000): { - mark: (webContentsId: number, durationMs?: number) => void - clear: (webContentsId?: number) => void - matches: (webContentsId: number, options?: { consume?: boolean }) => boolean -} { - let state: { webContentsId: number; until: number } | null = null - return { - mark(webContentsId, durationMs = defaultDurationMs) { - state = { webContentsId, until: Date.now() + durationMs } - }, - clear(webContentsId) { - if (webContentsId === undefined || state?.webContentsId === webContentsId) { - state = null - } - }, - matches(webContentsId, options) { - if (!state || Date.now() > state.until) { - state = null - return false - } - if (state.webContentsId !== webContentsId) { - return false - } - if (options?.consume) { - state = null - } - return true - } - } -} - -function markExpectedRendererReload(webContentsId: number, durationMs = 10_000): void { - expectedRendererReload.mark(webContentsId, durationMs) -} - -function clearExpectedRendererReload(webContentsId?: number): void { - expectedRendererReload.clear(webContentsId) -} - -function getExpectedTeardownScope( - webContentsId?: number, - includeSystemSessionEnd = true -): ExpectedTeardownScope { - return resolveExpectedTeardownScope({ - isQuitting, - isQuittingForUpdate: isQuittingForUpdate(), - isExpectedRendererReload: - webContentsId !== undefined && expectedRendererReload.matches(webContentsId), - includeSystemSessionEnd - }) -} - -function markRecoveryReloadInFlight(webContentsId: number, durationMs = 10_000): void { - recoveryReloadInFlight.mark(webContentsId, durationMs) -} - -function isRecoveryReloadInFlight(webContentsId: number): boolean { - // Why: consume on read — the recovery reload fires exactly one did-finish-load, so a later genuine reload still sweeps orphaned PTYs. - return recoveryReloadInFlight.matches(webContentsId, { consume: true }) -} - -function recordAgentStateCrashBreadcrumb(agentType: string, state: string): void { - // Why: hook pings arrive many times/sec; coalesce so identical state pings don't fill all 30 breadcrumbs, leaving room for renderer errors. - recordCoalescedCrashBreadcrumb({ - name: 'agent_state_changed', - data: { agentType, state }, - coalesceKey: `agent:${agentType}:${state}`, - minIntervalMs: AGENT_STATE_CRASH_BREADCRUMB_MIN_INTERVAL_MS - }) -} - -// Why: acquire AFTER configureDevUserDataPath — Electron derives lock identity from `userData`, so dev/packaged lock in separate namespaces. -// Why skip in dev: parallel `pnpm dev` from multiple worktrees would make the second exit silently; packaged keeps the lock (corruption PR #1326 / #1312). -const bypassSingleInstanceLock = shouldBypassSingleInstanceLock({ - isDev: is.dev, - isServeMode +const preflightReady = runMainProcessPreflight({ + focusExistingWindow, + requestDesktopActivation }) -const skipSingleInstanceLock = shouldSkipSingleInstanceLock({ - isDev: is.dev, - isServeMode -}) -if (bypassSingleInstanceLock) { - // Why: diagnostic escape hatch for macOS builds where Electron reports a false lock loss before any app logs exist. - logSingleInstanceLockBypass() -} -const hasSingleInstanceLock = skipSingleInstanceLock - ? true - : bypassSingleInstanceLock - ? true - : acquireSingleInstanceLock(app, requestDesktopActivation) -if (startupDiagnosticsEnabled) { - logStartupDiagnostic('single-instance-lock-result', { - acquired: hasSingleInstanceLock, - bypassed: bypassSingleInstanceLock, - skippedForDev: skipSingleInstanceLock - }) -} -if (!hasSingleInstanceLock) { - // Why: a false-negative lock loss otherwise looks like a silent crash on packaged macOS; `open --stderr` can capture this line. - logSingleInstanceLockFailure() - // Why: a graceful quit is deferred pre-ready, so this launch would still walk into Linux display init and SIGSEGV (#11935). - app.exit(SINGLE_INSTANCE_ALREADY_RUNNING_EXIT_CODE) -} // Why: when another process holds the lock we've already exited; skip file-writing side effects so this transient process never touches userData. -if (hasSingleInstanceLock) { - // Why first in this block: the accessor throws until installed and everything below may read a - // credential. The constructor does not touch `safeStorage` — it resolves lazily per call — so - // installing here changes no timing, in particular not the pre-ready Keychain service-name - // resolution. The app-environment port and the userData capture install earlier still, next to - // the path decision they depend on. - setSecretStore(new ElectronSecretStore()) - // Why at process level, not per-window: pty.ts registers against injected surfaces so - // it can load without electron, and an Electron main process always has ipcMain — - // whether a window exists is irrelevant. Installing this in attachMainWindowServices - // meant `orca serve` registered its PTY handlers against no-ops before any window - // attached, so a paired desktop owner never received them. - setPtyHostBindings({ ipc: ipcMain, power: powerMonitor }) - // Why also at process level: the runtime's notification, window-lookup and - // tab-create-reply channel are desktop-only. A Node host installs none and the - // runtime routes notifications to paired clients instead. - setRuntimeDesktopSurface(electronRuntimeDesktopSurface) - // Why here: constructing RuntimeBrowserCommands is what pulls the Chromium browser - // cluster into the graph. The desktop installs it; a Node host installs none and every - // browser RPC rejects, which capability filtering already tells clients about. - setRuntimeBrowserCommandsFactory(electronRuntimeBrowserCommandsFactory) - // Why here: proxy-settings only needed electron for `session.defaultSession`. The - // desktop supplies it; a Node host has no Chromium proxy config to consult, so the - // environment variables are the whole answer there. - setDefaultProxySessionResolver(() => session.defaultSession) - // Why here: integrations use Chromium's network stack on the desktop. A Node host - // falls back to the platform default, which is a real behavioural difference (proxy - // read from the environment, Node's user agent) rather than a transparent swap. - setMainHttpClient(electronHttpClient) - // Why here: constructing the speech services is what pulls Electron's streaming net - // request in. A host without them rejects speech calls rather than pretending. - setSpeechServiceFactories(electronSpeechServiceFactories) - setWorktreeWatcherRemoval(desktopWorktreeWatcherRemoval) - // Why: couple to dev-parent only for electron-vite desktop runs; `orca serve`'s parent (CLI shim/background shell) isn't the intended server lifetime. - const shouldCoupleToDevParent = is.dev && !isServeMode - installDevParentDisconnectQuit(shouldCoupleToDevParent) - installDevParentWatchdog(shouldCoupleToDevParent) - installDevParentSignalQuit(shouldCoupleToDevParent) - // Why not at module scope with the other lifetime couplings (#16761): this resolves the handoff - // path, so it throws until setAppEnvironment() above installs the accessor — which killed every - // `orca serve` process before it could listen. After initDataPath() specifically, so the - // path-equality check against the CLI's env var uses the dir captured before app.setName(). - // Safe to defer, and must stay synchronous: no 'disconnect' can be delivered until this module - // finishes evaluating, so moving this behind an await would open a real orphan window. - installServeSupervisorDisconnectQuit(isServeMode) - // Why here: initDataPath above gives the canonical userData path for the record file; the write - // itself lands for the next launch (see macos-press-and-hold-default.ts). - applyMacPressAndHoldDefaultAtStartup(getCanonicalUserDataPath()) - // Why: use the canonical userData path — late app.getPath('userData') can resolve differently across restarts, defeating persistence. - initSessionParseCachePersistence({ - filePath: join(getCanonicalUserDataPath(), 'ai-vault', 'session-parse-cache.json'), - appVersion: app.getVersion() - }) - initOrcaProfilePaths() - // Why: same timing as initDataPath — capture userData before app.setName changes it. See persistence.ts:20-28. - initStatsPath() - initClaudeUsagePath() - initCodexUsagePath() - initOpenCodeUsagePath() - // Why: Electron resolves the macOS safeStorage Keychain service name - // (" Safe Storage") before `ready`, so the setName in whenReady is - // too late to move it — dev otherwise lands on the package.json name. Dev-only - // so a packaged build keeps deriving the key from its own CFBundleName. - // Safe here: dev always pins userData via app.setPath (configure-process.ts), - // so setName cannot shift the paths captured just above. - if (shouldApplyPreReadyAppName(devInstanceIdentity)) { - app.setName(devInstanceIdentity.appName) - } - // Why: Electron freezes the privileged scheme table at ready, so the doc-preview - // scheme must be declared here or its webview loses fetch/secure-origin privileges. - registerDocPreviewSchemePrivileges() - // Why: must precede app.whenReady() so Crashpad is installed before the - // first renderer spawns; a CHECK before this point is still exit-code-only. - startCrashpadCapture() - crashReports = CrashReportStore.fromUserData() - recordCrashBreadcrumb('app_started', { - packaged: app.isPackaged, - platform: process.platform, - ...getMainProcessLifecycleIdentity() - }) - disableUnsupportedChromiumFeatures() - // Why: unconditional — a GPU-fallback launch skips enableMainProcessGpuFeatures() below. - optOutOfHiddenPageWakeUpThrottling() - configureElectronNetworkCompatibility() - enableRendererHeapHeadroom() - maybeApplyGpuFallbackForThisLaunch() - if (!gpuFallbackActiveThisLaunch) { - enableMainProcessGpuFeatures() - } - // Why: headless serve's offscreen BrowserWindows need an X display (Xvfb) on Linux; the result gates whether the offscreen backend is installed. - headlessBrowserDisplayAvailable = ensureVirtualDisplayForHeadlessServe({ isServeMode }) -} - -ipcMain.handle('app:awaitFirstWindowStartupServices', async () => { - await Promise.all([firstWindowStartupServicesReady, managedWslCliStartupBarrierReady]) -}) - -ipcMain.handle('app:prepareTerminalStartupRestoration', async () => { - await Promise.all([firstWindowStartupServicesReady, managedWslCliStartupBarrierReady]) - await runtime?.prepareStructuredAgentSessionStartupRestoration() -}) - -ipcMain.handle('app:recoverLegacyWorkerTerminalsForRendererStartup', () => - recoverLegacyWorkerTerminalsForRendererStartup({ - firstWindowStartupServicesReady, - managedWslCliStartupBarrierReady, - localPtyProviderStartupReady, - reconcile: async () => { - await runtime?.refreshRestoredOrchestrationAuthority() - return runtime?.reconcileLegacyWorkerTerminals({ materializeRenderer: true }) - }, - onDeferredRecoveryError: (error) => { - console.warn('[orchestration] legacy worker provider-ready recovery failed', error) - } - }) -) - -// Why: the renderer pulls this once its ui:openSettings listener attaches, so a Settings request queued before mount isn't lost. -ipcMain.handle('ui:consumePendingOpenSettings', (event) => - pendingOpenSettings.matches(event.sender.id, { consume: true }) -) - -ipcMain.handle('ui:consumePendingSkillShare', () => { - return skillShareDeepLinks.consume() -}) - -ipcMain.handle( - 'app:startupDiagnostic', - (_event, event: string, details?: Record) => { - if (!startupDiagnosticsEnabled || !event.startsWith('renderer-')) { +if (preflightReady) { + app.on('open-url', (event, url) => { + if (!parseSkillShareId(url)) { return } - logStartupMilestone(event, details && typeof details === 'object' ? details : {}) - } -) - -/** A PTY that dies while Orca is down never runs the teardown that clears pane - * state, so hydrate can rebuild a Claude subagent roster that no later hook can - * retire — pinning the pane 'working' and locking its agent out of hibernation - * for good. Once provider and hook hydration settle, targeted PTY liveness can - * retire only rows whose local owner is proven gone. */ -async function reapRestoredSubagentsWithoutLiveAgent(): Promise { - const currentStore = store - if (!currentStore) { - return - } - const provider = getDaemonProvider() - if (!provider) { - return - } - const persistedPtyIdByPaneKey = indexPersistedPaneKeyPtyIds( - currentStore.getWorkspaceSession().terminalLayoutsByTabId ?? {} - ) - await sweepRestoredSubagentsWithoutLiveAgent({ - probeLiveLocalPty: (ptyId) => provider.probePtyLiveness(ptyId), - isLocalExecutionHost: (worktreeId) => - isLocalExecutionHost( - resolveAgentWorkspaceExecutionHostId(worktreeId, { - getRepo: (repoId) => currentStore.getRepo(repoId), - getWorktreeMeta: (resolvedWorktreeId) => currentStore.getWorktreeMeta(resolvedWorktreeId), - getFolderWorkspace: (folderWorkspaceId) => - currentStore.getFolderWorkspace(folderWorkspaceId), - getProjectGroups: () => currentStore.getProjectGroups() - }) - ), - getBoundPtyIdForPaneKey: getPtyIdForPaneKey, - getPersistedPtyIdForPaneKey: (paneKey) => persistedPtyIdByPaneKey.get(paneKey), - reap: (isLocalHost, isLocalPaneAgentLive, isLocalPaneLivenessEvidenceCurrent) => - agentHookServer.reapRestoredClaudeSubagentsWithoutLiveAgent( - isLocalHost, - isLocalPaneAgentLive, - isLocalPaneLivenessEvidenceCurrent - ) + event.preventDefault() + requestDesktopActivation([url]) }) -} - -function startTerminalRuntimeStartupServices(): WindowsDesktopStartupServices { - logStartupMilestone('first-window-startup-services-start') - const startupServices = startFirstWindowStartupServices({ - // Why: both desktop and headless serve must adopt the same persistent provider before creating terminals or a renderer. - startDaemonPtyProvider: async (signal) => { - logStartupMilestone('startup-service-start', { service: 'daemon-pty-provider' }) - // Why: only GUI-spawned macOS daemons watch for login-session death; a headless - // serve daemon must survive its spawning session ending (SSH disconnect). - await initDaemonPtyProvider(signal, { - macosLoginSessionWatch: process.platform === 'darwin' && !isServeMode - }) - // Why: a retained shell keeps its launch-time Codex home even when the current routing lane changes. - const hasRetainedManagedHostPane = hasRecordedManagedHostCodexPane() - if (codexRuntimeHome && (hasRetainedManagedHostPane || hasAnyRecordedLegacyWslCodexPane())) { - const livePtyIds = await listLiveDaemonPtyIds() - if (livePtyIds) { - reconcileCodexPaneAccountsWithLivePtys(livePtyIds) - const settings = store?.getSettings() - // Why (#16441): each retained home can run a codex app-server grant - // session. Awaiting them here delayed the first window by N sessions; - // a retained shell cannot invoke Codex before this provider serves. - if (hasRetainedManagedHostPane) { - void reconcileRetainedCodexHookHomes({ - hookService: codexHookService, - hooksEnabled: - isAgentStatusHooksEnabled(settings) && - settings?.disabledTuiAgents.includes('codex') !== true, - runtimeHomePaths: codexRuntimeHome.getRetainedHostCodexHookHomePaths(livePtyIds) - }).catch((error: unknown) => { - console.warn('[codex-hook-service] retained Codex home reconcile failed:', error) - }) - } - } - } - // Why: retained shells can invoke Codex immediately after the startup gate. - codexRuntimeHome?.reconcileLegacySharedHomeForRetainedPanes() - logStartupMilestone('startup-service-done', { service: 'daemon-pty-provider' }) - }, - // Why: PTY spawn env reads ORCA_AGENT_HOOK_* from live server state, so the renderer awaits this before restored terminals reconnect. - startAgentHookServer: async () => { - if (!isAgentStatusHooksEnabled(store?.getSettings())) { - return - } - logStartupMilestone('startup-service-start', { service: 'agent-hook-server' }) - // Why (#11217): the hook listener fails open on every request error, so an IDS resetting - // loopback POSTs mid-body stops agent status for every runtime with no symptom but staleness. - // Log + telemetry (the daemon_start_failed pattern) so it is diagnosable without a packet capture. - agentHookServer.setTransportInterferenceListener((report) => { - track('agent_hook_transport_blocked', { count: report.count }) - }) - await agentHookServer.start({ - env: app.isPackaged ? 'production' : 'development', - // Why: hooks source this endpoint file at invocation time so old PTY env reaches the current process after restart; dev namespaces it (worktrees share `orca-dev`). - userDataPath: app.getPath('userData'), - endpointNamespace: devAgentHookEndpointNamespace - }) - logStartupMilestone('startup-service-done', { service: 'agent-hook-server' }) - }, - onDaemonError: (error) => { - // Why: daemon failure silently falls back to non-persistent local PTYs; log + telemetry so a fleet-wide outage is observable (was invisible in v1.4.129-rc.1). - const reason = error instanceof Error ? error.message : String(error) - console.error( - `[daemon] STARTUP FAILED — falling back to local PTYs; terminals will not persist across quit. Reason: ${reason}` - ) - track('daemon_start_failed', classifyError(error)) - }, - onAgentHookServerError: (error) => { - // Why: hook callbacks are sidebar enrichment only; Orca must still boot if the loopback receiver fails. - console.error('[agent-hooks] Failed to start local hook server:', error) - } - }) - void startupServices.firstWindowReady.then(() => { - logStartupMilestone('first-window-startup-services-ready') - }) - void startupServices.localPtyReady.then(() => { - logStartupMilestone('local-pty-startup-ready') - void reapRestoredSubagentsWithoutLiveAgent().catch((error) => { - console.warn('[agent-hooks] restored-subagent liveness probe failed:', error) - }) - }) - return startupServices -} - -function bindTerminalRuntimeStartupServices( - services: Promise -): void { - firstWindowStartupServicesReady = services.then((value) => value.firstWindowReady) - localPtyStartupReady = services.then((value) => value.localPtyReady) - localPtyProviderStartupReady = services.then((value) => value.localPtyProviderReady) -} - -async function prepareCodexRuntimeHomeForLaunch( - target?: CodexAccountSelectionTarget, - launchEnv?: NodeJS.ProcessEnv, - launchContext?: CodexHomeLaunchContext -): Promise { - if ( - target?.runtime !== 'wsl' && - launchContext?.launchAgent === 'codex' && - launchContext.workspacePath - ) { - try { - // Why: renderer quick-launch cannot await trust IPC before its PTY mounts; launch prep runs before every recognized Codex spawn. - await markCodexProjectTrusted(launchContext.workspacePath) - } catch (error) { - console.warn('[codex-project-trust] failed to pre-mark launch workspace:', error) - } - } - const ensureRealHomeHooksIfSelected = async (): Promise => { - if ( - target?.runtime === 'wsl' || - !codexRuntimeHome!.isHostSystemDefaultRealHomeSelected(launchEnv) - ) { - return false - } - // Why (flag ON, system default): the hook entry must exist — appended last - // and trusted by codex's own app-server grant — in the real ~/.codex before - // the pane spawns. An incapable grant flips the lane gate so the launch - // below falls back to the managed home instead of a status-blind pane. - await ensureRealHomeCodexHookState({ - hooksEnabled: isAgentStatusHooksEnabled(store?.getSettings()), - userDataPath: app.getPath('userData') - }) - return true - } - let realHomeHooksPrepared = await ensureRealHomeHooksIfSelected() - // Why: a ManagedCodexHomeTemporarilyUnavailableError must escape uncaught — - // the fallbacks below all key off `null`, which means "system default", so - // swallowing the refusal would launch the wrong account (#STA-4422). - let runtimeHomePath = await codexRuntimeHome!.prepareForCodexLaunchAsync(target, launchEnv, { - unavailableManagedHomePath: launchContext?.unavailableManagedHomePath - }) - if (runtimeHomePath === null && !realHomeHooksPrepared) { - // Why: launch prep can reject an untrusted managed home and clear its - // selection. Establish hook capability for that newly selected lane, then - // re-resolve if the capability gate rejects it. - realHomeHooksPrepared = await ensureRealHomeHooksIfSelected() - if (realHomeHooksPrepared) { - runtimeHomePath = await codexRuntimeHome!.prepareForCodexLaunchAsync(target, launchEnv, { - unavailableManagedHomePath: launchContext?.unavailableManagedHomePath - }) - } - } - if (runtimeHomePath === null && target?.runtime !== 'wsl') { - // Why: Codex runs on the user's real ~/.codex; the managed-home hook - // install below would target a home Codex never reads on this lane. - return null - } - const hookTarget = - target?.runtime === 'wsl' - ? { - runtime: 'wsl' as const, - wslDistro: target.wslDistro?.trim() || getDefaultWslDistro() - } - : target - const hooksEnabled = isAgentStatusHooksEnabled(store?.getSettings()) - try { - // Why: honor the persisted off switch so post-startup launches can't reinstall removed hooks. - const status = await codexHookService.prepareRuntimeHomeForLaunch( - runtimeHomePath, - hookTarget, - hooksEnabled - ) - if (status.state === 'error') { - console.warn( - `[codex-hook-service] failed to ${ - hooksEnabled ? 'refresh' : 'refresh user' - } runtime hooks before launch`, - status.detail - ) - } - } catch (error) { - // Why: hook install is best-effort launch prep; a malformed hooks file must not block Codex from starting. - console.warn( - `[codex-hook-service] failed to ${ - hooksEnabled ? 'refresh' : 'refresh user' - } runtime hooks before launch`, - error - ) - } - return runtimeHomePath -} - -async function prepareCodexSessionResumeForLaunch(args: { - providerSession: AgentProviderSessionMetadata - target: CodexAccountSelectionTarget - launchEnv?: NodeJS.ProcessEnv - workspacePath?: string -}): Promise { - if (args.target.runtime === 'wsl' || !codexRuntimeHome || !store) { - return null - } - const systemHomePath = getSystemCodexHomePath() - // Why: codexSessionSourceHome is import-only; treating it as CODEX_HOME would mutate history sources and bypass account auth. - const trustedHomes = [ - systemHomePath, - ...codexRuntimeHome.getHostCodexHomePathsForSessionDiscovery() - ] - const settingsStore = store - // Why: resolved eagerly, once, before any ranking or provenance match. The - // marker read used to be deferred into the ranking thunk so a - // provenance-present resume never paid for it, but that optimisation let an - // unreadable selected home reach the PTY as "no selection": the provenance - // branch simply omits the account from `trustedHomes` and another account's - // readable alias wins. A throw here refuses the whole resume instead - // (#STA-4422). - const selectedAccountCodexHome = - codexRuntimeHome.resolveSelectedHostAccountCodexHomePathForResume() - // Why: a `fresh` outcome must skip migration, trust and hook repair entirely — there is - // no verified origin home to prepare, so the PTY layer drops the resume argv (#10793). - const preparation = await prepareCodexSessionResume({ - sessionId: args.providerSession.id, - transcriptPath: args.providerSession.transcriptPath, - trustedCodexHomes: trustedHomes, - // Why: the legacy id rescan's winning home becomes this pane's CODEX_HOME, i.e. its account; - // rank it by the current selection so settings insertion order can never decide the account. - getSelectedAccountCodexHome: () => selectedAccountCodexHome, - systemCodexHomePath: systemHomePath, - // Why: the mirror winning is what triggers the migration into ~/.codex below, so it must - // outrank the path-sorted account homes or a system-default selection resumes as an account. - sharedRuntimeCodexHomePath: getOrcaManagedCodexHomePath(), - resolveVerifiedResumeHome: async (sessionSource) => { - let migrated = { useRealCodexHome: false } - try { - migrated = await prepareLegacySharedCodexSessionResume( - { - agent: 'codex', - executionHostId: 'local', - filePath: sessionSource.transcriptPath, - codexHome: sessionSource.homePath - }, - { - isHostSystemDefaultRealHome: () => codexRuntimeHome!.isHostSystemDefaultRealHome(), - systemCodexHomePath: systemHomePath - } - ) - } catch (error) { - // Why: this launch path pins CODEX_HOME to the account that OWNS the - // rollout and deliberately refuses to repin onto whichever account is - // selected now (#10793), so it does not wire - // getSelectedHostAccountCodexHomePath and this branch cannot fire today. - // It stays as a contract guard: the blanket catch below must never - // silently swallow a typed refusal if that ever changes. - if (error instanceof ManagedCodexHomeTemporarilyUnavailableError) { - throw error - } - // Why: migration is a compatibility repair; its failure must not prevent the PTY from resuming from its trusted origin home. - console.warn( - '[codex-session-resume] Legacy rollout migration failed; using origin home:', - error - ) - } - const resumeHome = migrated.useRealCodexHome ? systemHomePath : sessionSource.homePath - - if (args.workspacePath) { - try { - await markCodexProjectTrusted(args.workspacePath) - } catch (error) { - console.warn('[codex-project-trust] failed to pre-mark resumed workspace:', error) - } - } - const isSystemHome = - normalizeRuntimePathForComparison(resumeHome) === - normalizeRuntimePathForComparison(systemHomePath) - const hooksEnabled = isAgentStatusHooksEnabled(settingsStore.getSettings()) - try { - if (isSystemHome) { - await ensureRealHomeCodexHookState({ - hooksEnabled, - userDataPath: app.getPath('userData') - }) - } else if (hooksEnabled) { - await codexHookService.installForLaunchPrep(resumeHome) - } else { - await codexHookService.refreshRuntimeUserHooksForLaunchPrep(resumeHome) - } - } catch (error) { - // Why: hook repair is best-effort; session provenance must still win over the currently selected home. - console.warn('[codex-hook-service] failed to prepare automatic resume home:', error) - } - return resumeHome - } - }) - return preparation.outcome === 'resume' - ? { - ...preparation, - reconcileSharedRuntimeAuth: - normalizeRuntimePathForComparison(preparation.codexHomePath) === - normalizeRuntimePathForComparison(getOrcaManagedCodexHomePath()) - } - : preparation -} - -// Why: restore the window the close handler may have hidden to tray, or reopen it (dock-reactivation style) if fully torn down. -function showMainWindowFromTray(): void { - if (mainWindow && !mainWindow.isDestroyed()) { - safelyRevealWindow(mainWindow) - return - } - if (!isQuittingForUpdate()) { - openMainWindow() - } -} - -function openSettingsFromSystemMenu(): void { - showMainWindowFromTray() - const targetWindow = mainWindow && !mainWindow.isDestroyed() ? mainWindow : null - if (!targetWindow) { - return - } - recordCrashBreadcrumb('settings_opened') - - // Why: no signal proves the renderer listener is attached — push, and also leave a one-shot intent the unmounted renderer pulls at mount. - targetWindow.webContents.send('ui:openSettings') - // Why: untimed — any TTL can be outrun by a slow cold start; id-scoping + consume-on-read still prevent leaking to a later renderer. - pendingOpenSettings.mark(targetWindow.webContents.id, Number.POSITIVE_INFINITY) -} - -function quitFromSystemTray(): void { - if (mainWindow && !mainWindow.isDestroyed()) { - // Why: a hidden session may veto shutdown with a save/discard prompt, so make the window visible. - showMainWindowFromTray() - } - // Why: set the quit latch before app.quit() so the 'close' handler tears down instead of re-hiding to tray. - isQuitting = true - app.quit() -} - -// Why: menu/tray are clickable before anything else configures the updater. -function runUserInitiatedUpdateCheck(options?: UpdateCheckOptions): void { - ensureAutoUpdaterConfigured() - checkForUpdatesFromMenu(options) -} - -function getSystemTrayOptions(): SystemTrayOptions | null { - if (!store) { - return null - } - return { - appIcon: store.getSettings().appIcon, - isDevInstance: devInstanceIdentity.isDev, - devInstanceLabel: devInstanceIdentity.devLabel, - onOpen: showMainWindowFromTray, - onOpenSettings: openSettingsFromSystemMenu, - onCheckForUpdates: () => { - // Why: updater status renders in the main window, so a bare check would complete invisibly. - showMainWindowFromTray() - runUserInitiatedUpdateCheck() - }, - onQuit: quitFromSystemTray - } -} - -function syncMacMenuBarIcon(showMenuBarIcon: boolean): Tray | null { - if (process.platform !== 'darwin' || isServeMode) { - return null - } - const options = getSystemTrayOptions() - return options ? setMacMenuBarIconVisible(showMenuBarIcon, options) : null -} - -function openMainWindow(options: { revealOnDidFinishLoad?: boolean } = {}): BrowserWindow { - logStartupMilestone('open-main-window-start') - if (!store) { - throw new Error('Store must be initialized before opening the main window') - } - if (!runtime) { - throw new Error('Runtime must be initialized before opening the main window') - } - if (!stats) { - throw new Error('Stats must be initialized before opening the main window') - } - if (!claudeUsage) { - throw new Error('Claude usage store must be initialized before opening the main window') - } - if (!codexUsage) { - throw new Error('Codex usage store must be initialized before opening the main window') - } - if (!openCodeUsage) { - throw new Error('OpenCode usage store must be initialized before opening the main window') - } - if (!rateLimits) { - throw new Error('Rate limit service must be initialized before opening the main window') - } - if (!automations) { - throw new Error('Automation service must be initialized before opening the main window') - } - if (!codexAccounts) { - throw new Error('Codex account service must be initialized before opening the main window') - } - if (!codexRuntimeHome) { - throw new Error('Codex runtime home service must be initialized before opening the main window') - } - if (!claudeAccounts) { - throw new Error('Claude account service must be initialized before opening the main window') - } - if (!claudeRuntimeAuth) { - throw new Error( - 'Claude runtime auth service must be initialized before opening the main window' - ) - } - if (!keybindings) { - throw new Error('Keybinding service must be initialized before opening the main window') - } - - // Why: Chromium's BrowserWindow ctor resets userData to a Protected DACL, breaking writes; re-grant ACEs (marker-gated to avoid a ~60s startup stall). - if (process.platform === 'win32') { - logStartupMilestone('acl-grant-start') - ensureWindowsUserDataAclGrant(app.getPath('userData'), { - onDone: (result) => { - logStartupMilestone('acl-grant-done', { mode: result.mode }) - if (result.mode === 'failed') { - console.warn('[win32-acl] userData ACL grant failed:', result.reason) - } - } - }) - // Why here: read-only, and the install DACL is the one thing a 0x80000003 - // child death cannot tell us about itself. See electron/electron#51761. - probeWindowsInstallDirAcl({ - isServeMode, - onDone: (data) => - startWindowsInstallDirAclRepairIfPoisoned(data, { - isServeMode, - userDataPath: app.getPath('userData'), - appVersion: app.getVersion() - }) - }) - } - - const window = createMainWindow(store, { - getIsQuitting: () => isQuitting, - onQuitAborted: () => { - isQuitting = false - clearExpectedRendererReload() - }, - onRendererProcessGone: (details, webContentsId) => { - recordProcessGoneCrash( - 'renderer', - 'renderer', - details.reason, - details.exitCode ?? null, - { - processType: 'renderer' - }, - webContentsId - ) - }, - shouldRecoverRenderer: (details, webContentsId) => - shouldRecoverRendererAfterProcessGone({ - reason: details.reason, - expectedTeardown: getExpectedTeardownScope(webContentsId, false) - }), - onRendererRecoveryExhausted: ({ details, recentRecoveryCount }) => { - recordDurableCrashBreadcrumb('renderer_recovery_circuit_breaker_open', { - reason: details.reason, - exitCode: details.exitCode ?? null, - recentRecoveryCount - }) - void showRendererRecoveryPrompt(recentRecoveryCount) - }, - deferLoad: true, - ...(options.revealOnDidFinishLoad === true ? { revealOnDidFinishLoad: true } : {}), - title: devInstanceIdentity.name, - getKeybindings: () => keybindings?.getOverrides(), - onBeforeReload: ({ ignoreCache, webContentsId }) => { - if (mainWindow?.webContents.id === webContentsId) { - markExpectedRendererReload(webContentsId) - } - recordCrashBreadcrumb('manual_reload_requested', { ignoreCache }) - }, - // Why: the recovery reload re-fires did-finish-load; flag it so the local-PTY orphan sweep skips that reload (#5787). - onBeforeRecoveryReload: (webContentsId) => { - markRecoveryReloadInFlight(webContentsId) - recordDurableCrashBreadcrumb('renderer_recovery_reload') - } - }) - recordCrashBreadcrumb('main_window_created') - logStartupMilestone('window-created') - // Why: Windows Tray construction can block synchronously on Shell_NotifyIcon, so both platforms defer creation to after first paint. - let trayCreated = false - const createSystemTrayDeferred = (): void => { - if (trayCreated || window.isDestroyed() || isQuitting || !store) { + // Why: macOS delivers "Open With" as open-file, often before `ready`, and only to a handler + // that claims the event. Non-markdown paths stay unclaimed so the OS default handler wins. + app.on('open-file', (event, filePath) => { + if (!state.osOpenedMarkdownFiles.captureFilePaths([filePath], publishOsOpenedMarkdownFiles)) { return } - trayCreated = true - if (process.platform === 'darwin') { - // Why: route through syncMacMenuBarIcon so startup and the live toggle share one serve-mode/visibility policy. - if (syncMacMenuBarIcon(store.getSettings().showMenuBarIcon !== false)) { - logStartupMilestone('tray-created') - } - return - } - const options = getSystemTrayOptions() - if (options && createSystemTray(options)) { - logStartupMilestone('tray-created') - } - } - window.once('ready-to-show', () => { - logStartupMilestone('ready-to-show') - setImmediate(createSystemTrayDeferred) - }) - window.once('show', () => { - logStartupMilestone('window-shown') - void presentGpuFallbackRecoveredLaunchPrompt(window) - }) - const trayCreateFallback = setTimeout(createSystemTrayDeferred, TRAY_CREATE_FALLBACK_MS) - trayCreateFallback.unref?.() - - // Why: telemetry-plan.md anchors default-on app_opened to the first main-window load; this path fires only once consent is already enabled. - const rendererWebContentsId = window.webContents.id - const onFirstWindowLoad = (): void => { - clearExpectedRendererReload(rendererWebContentsId) - recordCrashBreadcrumb('main_window_loaded') - logStartupMilestone('did-finish-load') - if (!store) { - return - } - const consent = resolveConsent(store.getSettings()) - if (consent.effective !== 'enabled') { - return - } - trackAppOpenedOnce() - } - window.webContents.on('did-finish-load', onFirstWindowLoad) - - registerCoreHandlers( - store, - runtime, - stats, - claudeUsage, - codexUsage, - openCodeUsage, - codexAccounts, - claudeAccounts, - rateLimits, - rendererWebContentsId, - automations, - { - prepareForCodexLaunch: prepareCodexRuntimeHomeForLaunch, - prepareForClaudeLaunch: (target) => claudeRuntimeAuth!.prepareForClaudeLaunch(target) - }, - agentAwakeService ?? undefined, - crashReports ?? undefined, - keybindings, - { - getAdditionalAiVaultCodexHomePaths: () => - codexRuntimeHome ? codexRuntimeHome.getHostCodexHomePathsForSessionDiscovery() : [], - prepareAiVaultSessionResume: (args) => - prepareCodexAiVaultSessionResume(args, { - runtimeHome: codexRuntimeHome, - systemCodexHomePath: resolveHostCodexSessionSourceHome(store!.getSettings()) - }), - onBeforeRelaunch: async () => { - isQuitting = true - desktopRelayService?.fenceAndCloseNow() - await preserveAgentAuthBeforeRestart({ codexRuntimeHome, claudeRuntimeAuth, store }) - }, - onOrcaProfileAuthMutation: () => desktopRelayService?.authMutated(), - onBeforeOrcaProfileSignOut: () => desktopRelayService?.fenceAndCloseNow() - }, - pluginService ?? undefined, - pluginMarketplaceService && pluginMarketplaceInstaller - ? { marketplace: pluginMarketplaceService, installer: pluginMarketplaceInstaller } - : undefined - ) - automations.setWebContents(window.webContents) - automations.start() - attachMainWindowServices( - window, - store, - runtime, - prepareCodexRuntimeHomeForLaunch, - (target) => claudeRuntimeAuth!.prepareForClaudeLaunch(target), - { - prepareCodexSessionResume: prepareCodexSessionResumeForLaunch, - awaitLocalPtyStartup: () => localPtyStartupReady, - awaitLocalPtyProviderStartup: () => localPtyProviderStartupReady, - onBeforeRendererReload: ({ ignoreCache, webContentsId }) => { - if (window.webContents.id === webContentsId) { - markExpectedRendererReload(webContentsId) - } - recordCrashBreadcrumb('renderer_reload_requested', { ignoreCache }) - }, - // Why: let the PTY layer skip its orphan sweep on the recovery reload that re-fires did-finish-load, so live local sessions survive (#5787). - isRecoveryReloadInFlight, - onCodexHomePtySpawned: handleCodexHomePtySpawned, - onPtyExit: handlePtyExit, - onBeforeUpdateQuit: () => - preserveAgentAuthBeforeRestart({ codexRuntimeHome, claudeRuntimeAuth, store }), - updateInstallMode: resolveUpdateInstallMode(isServeMode), - onWorktreeLifecycle: emitPluginWorktreeLifecycle - } - ) - // Why: attach the durable renderer pull now, but launch the diagnostic process after first paint. - initTccPromptNotice(window, { deferWatchUntilReadyToShow: true }) - rateLimits.attach(window) - // Why: quota probes spawn CLIs and hit network, so don't fetch immediately and compete with first paint; show/focus listeners refresh later. - rateLimits.start({ fetchImmediately: false }) - window.on('closed', () => { - if (mainWindow === window) { - mainWindow = null - } - clearExpectedRendererReload(rendererWebContentsId) - automations?.setWebContents(null) - // Why: detach the hook listener on close so the server never fires into destroyed webContents before reopen, and replay runs only on deliberate recreations. - agentHookServer.setListener(null) - agentHookServer.setPaneStatusClearListener(null) - setMigrationUnsupportedPtyListener(null) - // Why: stop the spinner timer here — it would fire into destroyed webContents, and per-pane teardown may never run for restored-but-untorn panes. - stopAllSyntheticTitleSpinners() - }) - mainWindow = window - window.on('show', resumeSyntheticTitleSpinnerTimer) - window.on('restore', resumeSyntheticTitleSpinnerTimer) - window.on('hide', stopSyntheticTitleSpinnerTimer) - window.on('minimize', stopSyntheticTitleSpinnerTimer) - // Why: visibility-gated pollers (SSH port scanner) park while hidden and resume on this signal; re-wired per window since dock re-activation recreates it. - window.on('show', notifyMainWindowBecameVisible) - window.on('restore', notifyMainWindowBecameVisible) - // Why: user is back on show/restore, so clear the tray attention dot set while hidden (see notifications.ts). - window.on('show', () => setTrayAttention(false)) - window.on('restore', () => setTrayAttention(false)) - agentHookServer.setListener( - ({ - paneKey, - tabId, - worktreeId, - connectionId, - payload, - receivedAt, - stateStartedAt, - launchToken, - providerSession, - providerSessionOnly, - promptInteractionKey, - restoredUnconfirmed, - observation, - isReplay - }) => { - if (mainWindow?.isDestroyed()) { - return - } - if (providerSessionOnly) { - // Why: session_start just refreshes durable resume identity while Pi is idle; forward it without titles, telemetry, or status UI. - mainWindow?.webContents.send('agentStatus:set', { - ...payload, - paneKey, - ...(launchToken ? { launchToken } : {}), - tabId, - worktreeId, - connectionId, - receivedAt, - stateStartedAt, - ...(providerSession ? { providerSession } : {}), - ...(observation ? { observation } : {}), - providerSessionOnly: true - }) - return - } - if (!restoredUnconfirmed) { - maybeAutoRenameBranchOnFirstWorkFromHook({ paneKey, tabId, worktreeId, payload, isReplay }) - } - const orchestration = runtime?.getAgentStatusOrchestrationContextForPaneKey(paneKey) - const terminalHandle = runtime?.getAgentStatusTerminalHandleForPaneKey(paneKey) - const suppressSyntheticCodexAutoApprovalTitle = - payload.agentType === 'codex' && - (payload.state === 'waiting' || payload.state === 'blocked') - ? shouldSuppressCodexAutoApprovalSyntheticTitleFromHook({ - agentType: payload.agentType, - state: payload.state, - launchConfig: runtime?.getAgentStatusLaunchConfigForPaneKey(paneKey, { launchToken }) - }) - : false - const statusEvent = { - ...payload, - paneKey, - ...(launchToken ? { launchToken } : {}), - ...(terminalHandle ? { terminalHandle } : {}), - tabId, - worktreeId, - connectionId, - receivedAt, - stateStartedAt, - ...(providerSession ? { providerSession } : {}), - ...(promptInteractionKey ? { promptInteractionKey } : {}), - ...(restoredUnconfirmed ? { restoredUnconfirmed: true } : {}), - ...(observation ? { observation } : {}), - ...(orchestration ? { orchestration } : {}) - } - mainWindow?.webContents.send('agentStatus:set', statusEvent) - if (!suppressSyntheticCodexAutoApprovalTitle || isAskUserQuestionTool(payload.toolName)) { - getDashboardPopoutWindow()?.webContents.send('agentStatus:set', statusEvent) - } - recordAgentStateCrashBreadcrumb(payload.agentType ?? 'unknown', payload.state) - // Why: native OSC titles miss some idle/permission frames, so inject hook-derived ones to keep the renderer title tracker in sync. - const profile = getSyntheticAgentTitleProfile(payload.agentType) - if ( - profile && - shouldDriveSyntheticAgentTitleFromHook(payload.agentType, payload.state) && - !suppressSyntheticCodexAutoApprovalTitle - ) { - driveSyntheticTitleFromHook(paneKey, payload.state, profile) - } - } - ) - agentHookServer.setPaneStatusClearListener((clear) => { - if (mainWindow?.isDestroyed()) { - return - } - mainWindow?.webContents.send('agentStatus:clear', clear) - getDashboardPopoutWindow()?.webContents.send('agentStatus:clear', clear) - }) - setMigrationUnsupportedPtyListener((event) => { - if (mainWindow?.isDestroyed()) { - return - } - if (event.type === 'set') { - mainWindow?.webContents.send('agentStatus:migrationUnsupported', event.entry) - } else { - mainWindow?.webContents.send('agentStatus:migrationUnsupportedClear', { - ptyId: event.ptyId - }) + event.preventDefault() + // Why gated on isReady: pre-ready the cold-start window is already on its way, and + // activating the gate here would try to open one before Electron can. + if (app.isReady()) { + requestDesktopActivation() } }) - logStartupMilestone('load-start') - loadMainWindow(window) - return window -} - -function sendOpenFeatureTour(targetWindow?: BrowserWindow | null): void { - const webContents = - targetWindow && !targetWindow.isDestroyed() ? targetWindow.webContents : mainWindow?.webContents - webContents?.send('ui:openFeatureTour') -} - -function sendOpenSetupGuide(targetWindow?: BrowserWindow | null): void { - const webContents = - targetWindow && !targetWindow.isDestroyed() ? targetWindow.webContents : mainWindow?.webContents - webContents?.send('ui:openSetupGuide') -} - -function sendOpenCrashReport(targetWindow?: BrowserWindow | null): void { - const webContents = - targetWindow && !targetWindow.isDestroyed() ? targetWindow.webContents : mainWindow?.webContents - webContents?.send('ui:openCrashReport') -} - -// Why: on renderer crash-loop the breaker stops auto-reloading and the window goes blank, so a main-process dialog is the only retry/quit surface. -async function showRendererRecoveryPrompt(recentRecoveryCount: number): Promise { - await presentRendererRecoveryPrompt({ - recentRecoveryCount, - isQuitting: () => isQuitting, - diagnose: describeInstallDirAclPoison, - showMessageBox: (options) => { - const window = mainWindow && !mainWindow.isDestroyed() ? mainWindow : undefined - return window ? dialog.showMessageBox(window, options) : dialog.showMessageBox(options) - }, - copyToClipboard: (text) => clipboard.writeText(text), - reload: () => { - if (!mainWindow || mainWindow.isDestroyed()) { - return - } - recordDurableCrashBreadcrumb('renderer_recovery_manual_retry') - // Why: leave the breaker open so a re-crash re-raises this prompt instead of resuming the auto-reload loop. - loadMainWindow(mainWindow) - }, - quit: () => { - isQuitting = true - app.quit() - } - }) -} - -function getGpuFallbackEnvironment(): GpuFallbackEnvironment { - return { - appVersion: app.getVersion(), - electronVersion: process.versions.electron ?? '', - platform: process.platform - } -} - -function getWindowsGpuFallbackEnvironment(): WindowsGpuFallbackEnvironment | null { - const environment = getGpuFallbackEnvironment() - if (environment.platform !== 'win32') { - return null - } - return { ...environment, platform: 'win32' } -} - -// Writes both crash-time and post-recovery consent states through one build-scoped path. -function persistGpuFallbackMarker( - userDataPath: string, - info: { engagedAt: number; crashesInWindow: number; userConfirmed: boolean } -): boolean { - const environment = getWindowsGpuFallbackEnvironment() - if (!environment) { - return false - } - try { - writeGpuFallbackMarker(userDataPath, info, environment) - return true - } catch (error) { - console.warn('[gpu-fallback] failed to persist marker:', error) - return false - } -} - -// Read before app.whenReady() so app.disableHardwareAcceleration() takes effect. Windows desktop only. -function maybeApplyGpuFallbackForThisLaunch(): void { - if (isServeMode || process.platform !== 'win32') { - return - } - const marker = readActiveGpuFallbackMarker(app.getPath('userData'), getGpuFallbackEnvironment()) - if (!marker) { - return - } - activeGpuFallbackMarker = marker - app.disableHardwareAcceleration() - const appliedSwitches = applyGpuFallbackCommandLineSwitches(app.commandLine, process.platform) - gpuFallbackActiveThisLaunch = true - // Why: with no GPU child left, child-process-gone can't report a GPU fault, so - // name the applied switches in the trail any later crash report carries. - recordCrashBreadcrumb('gpu_fallback_applied', { - crashesInWindow: marker.crashesInWindow, - switches: appliedSwitches.join(',') - }) -} - -async function presentGpuFallbackRecoveredLaunchPrompt(window: BrowserWindow): Promise { - const marker = activeGpuFallbackMarker - if (!marker || marker.userConfirmed || window.isDestroyed() || isQuitting) { - return - } - // One prompt per process. A failure leaves the on-disk marker unconfirmed so the next launch retries. - activeGpuFallbackMarker = null - const userDataPath = app.getPath('userData') - await handleGpuFallbackRecoveredLaunch({ - isQuitting: () => isQuitting, - prompt: () => promptForGpuFallbackRecoveredLaunch(window), - confirmSafeGraphics: () => { - persistGpuFallbackMarker(userDataPath, { - engagedAt: marker.engagedAt, - crashesInWindow: marker.crashesInWindow, - userConfirmed: true - }) - }, - clearSafeGraphics: () => clearGpuFallbackMarker(userDataPath), - onPromptFailed: (error) => - console.warn('[gpu-fallback] failed to show recovered-launch prompt:', error), - onSafeGraphicsKept: () => - recordDurableCrashBreadcrumb('gpu_fallback_safe_graphics_kept', { - crashesInWindow: marker.crashesInWindow - }), - restartWithHardware: () => { - isQuitting = true - relaunchApp('gpu-fallback', { - mode: 'hardware-retry', - crashesInWindow: marker.crashesInWindow - }) - destroySystemTray() - app.exit(0) - } - }) -} - -// Why: a burst of GPU child crashes means HW acceleration is unusable — persist a build-scoped marker and offer software rendering. -async function handleGpuChildCrash( - reason: string, - exitCode: number | null, - crashedAt: number -): Promise { - // Software rendering already active or shutting down: nothing more to do. - if (gpuFallbackActiveThisLaunch || isQuitting || isServeMode) { - return - } - const result = gpuCrashFallbackTracker.recordGpuCrash(crashedAt) - if (!result.shouldEngageFallback) { - return - } - const fallbackData = { - processReason: reason, - exitCode, - crashesInWindow: result.crashesInWindow - } - const userDataPath = app.getPath('userData') - await engageGpuFallbackAfterCrashBurst( - { reason, exitCode, crashesInWindow: result.crashesInWindow, engagedAt: Date.now() }, - { - isQuitting: () => isQuitting, - onEngaged: (engagement) => - recordCrashBreadcrumb('gpu_fallback_engaged', { - reason: engagement.reason, - exitCode: engagement.exitCode, - crashesInWindow: engagement.crashesInWindow - }), - persistMarker: (engagement) => - persistGpuFallbackMarker(userDataPath, { - engagedAt: engagement.engagedAt, - crashesInWindow: engagement.crashesInWindow, - userConfirmed: false - }), - confirmMarker: (engagement) => { - persistGpuFallbackMarker(userDataPath, { - engagedAt: engagement.engagedAt, - crashesInWindow: engagement.crashesInWindow, - userConfirmed: true - }) - }, - clearMarker: () => clearGpuFallbackMarker(userDataPath), - promptForRestart: () => - promptForGpuFallbackRestart( - mainWindow && !mainWindow.isDestroyed() ? mainWindow : undefined - ), - onPromptFailed: (error) => - console.warn('[gpu-fallback] failed to show restart prompt:', error), - onRestartDeferred: () => - recordDurableCrashBreadcrumb('gpu_fallback_restart_deferred', fallbackData), - restartIntoSafeGraphics: () => { - isQuitting = true - relaunchApp('gpu-fallback', fallbackData) - // Why: app.exit(0) skips before-quit, so destroy the Windows tray manually to avoid a stale icon. - destroySystemTray() - app.exit(0) - } - } - ) -} - -function recordProcessGoneCrash( - source: 'renderer' | 'child', - processType: string, - reason: string, - exitCode: number | null, - details: Record, - webContentsId?: number -): void { - recordProcessGoneCrashEvent(crashReports, { - source, - processType, - reason, - exitCode, - expectedTeardown: getExpectedTeardownScope(webContentsId), - details, - ...(webContentsId !== undefined ? { webContentsId } : {}) - }) -} - -function shutdownWatchersOnce(): Promise { - if (watcherShutdownDone) { - return Promise.resolve() - } - if (!watcherShutdownPromise) { - // Why: @parcel/watcher tears down native async work on unsubscribe; Electron must await it before Node's environment exits. - stopFolderRepoGitUpgradeWatch() - watcherShutdownPromise = Promise.allSettled([ - closeAllWatchers(), - disposeWorktreeBaseDirectoryWatchers() - ]) - .then((results) => { - for (const result of results) { - if (result.status === 'rejected') { - console.error('[filesystem-watcher] shutdown failed:', result.reason) - } - } - }) - .then(() => { - watcherShutdownDone = true - }) - } - return watcherShutdownPromise -} - -// Why: cursor-agent re-emits its own OSC title on every redraw, overwriting a one-shot frame — so re-assert a working frame on an interval. -// 80ms matches Pi's cadence (smooth but under the IPC budget). opencode needs only one frame but reuses this for consistent animated UX. -const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'] -const SPINNER_INTERVAL_MS = 80 - -const syntheticTitleSpinnerByPaneKey = new Map< - string, - SyntheticTitleSpinnerEntry ->() -let syntheticTitleSpinnerTimer: ReturnType | null = null - -type ServeOptions = { - json: boolean - wsPort?: number - pairingAddress: string | null - noPairing: boolean - mobilePairing: boolean - recipeJson: boolean - projectRoot: string | null -} - -function getServeOptions(argv = process.argv): ServeOptions { - const valueAfter = (flag: string): string | null => { - const index = argv.indexOf(flag) - if (index === -1) { - return null - } - const value = argv[index + 1] - return value && !value.startsWith('--') ? value : null - } - const rawPort = valueAfter('--serve-port') - let wsPort: number | undefined - if (rawPort) { - const parsedPort = Number(rawPort) - if (!Number.isInteger(parsedPort) || parsedPort < 0 || parsedPort > 65535) { - throw new Error(`Invalid --serve-port value: ${rawPort}`) - } - wsPort = parsedPort - } - return { - json: argv.includes('--serve-json'), - ...(wsPort !== undefined ? { wsPort } : {}), - pairingAddress: valueAfter('--serve-pairing-address'), - noPairing: argv.includes('--serve-no-pairing'), - mobilePairing: argv.includes('--serve-mobile-pairing'), - recipeJson: argv.includes('--serve-recipe-json'), - projectRoot: valueAfter('--serve-project-root') - } -} - -function getBundledWebClientRoot(): string | undefined { - const appPath = app.getAppPath() - const roots = [ - join(appPath, 'out', 'web'), - // Why: unpacked electron-vite entrypoints set appPath to out/main, next to the web bundle. - join(appPath, '..', 'web') - ] - return roots.find((root) => existsSync(join(root, 'web-index.html'))) -} - -async function renderTerminalPairingQr(pairingUrl: string): Promise { - // Why dynamic: qrcode is only reachable from mobile pairing, so launch should - // not parse it for the majority who never pair a device. - const QRCode = await import('qrcode') - try { - return await QRCode.toString(pairingUrl, { type: 'terminal', small: true }) - } catch { - try { - return await QRCode.toString(pairingUrl, { type: 'utf8' }) - } catch { - return null - } - } -} - -async function printServeReady(options: ServeOptions): Promise { - if (!runtime || !runtimeRpc) { - throw new Error('Runtime server must be initialized before printing serve readiness') - } - if (options.recipeJson) { - if (!options.projectRoot) { - throw new Error('--serve-recipe-json requires --serve-project-root') - } - if (!isAbsolute(options.projectRoot)) { - throw new Error(`--serve-project-root must be absolute: ${options.projectRoot}`) - } - const projectRootStats = statSync(options.projectRoot) - if (!projectRootStats.isDirectory()) { - throw new Error(`--serve-project-root must be a directory: ${options.projectRoot}`) - } - } - const boundEndpoint = runtimeRpc.getWebSocketEndpoint() - const advertised = boundEndpoint - ? resolveAdvertisedPairingEndpoint(boundEndpoint, options.pairingAddress) - : null - const pairing = options.noPairing - ? ({ - available: false, - reason: 'disabled_by_operator', - guidance: 'Restart without --no-pairing to create a client pairing offer.' - } as const) - : runtimeRpc.createPairingOffer({ - address: options.pairingAddress, - name: `${options.mobilePairing ? 'Mobile' : 'CLI'} ${new Date().toLocaleDateString()}`, - scope: options.mobilePairing ? 'mobile' : 'runtime' - }) - const pairingQr = - pairing.available && options.mobilePairing - ? await renderTerminalPairingQr(pairing.pairingUrl) - : null - await serveReadinessPublisher.publish( - { - runtimeId: runtime.getRuntimeId(), - boundEndpoint, - advertisedEndpoint: advertised?.ok ? advertised.endpoint : null, - // Why: the WSL reconciliation barrier fails open, so 'pending' warns a WSL PTY launch may still race a repair. - managedWslCliReconciliation: managedWslCliReconciliationStatus, - pairing: pairing.available - ? { - available: true, - url: pairing.pairingUrl, - endpoint: pairing.endpoint, - deviceId: pairing.deviceId, - webClientUrl: pairing.webClientUrl, - scope: options.mobilePairing ? 'mobile' : 'runtime', - qr: pairingQr - } - : pairing - }, - options.recipeJson - ? { mode: 'recipe-json', projectRoot: options.projectRoot! } - : { mode: options.json ? 'json' : 'human' } - ) - notifyServeSupervisorReady(runtime.getRuntimeId()) -} - -// Why: on PTY teardown drop the spinner entry explicitly, else the shared timer keeps ticking with sendSyntheticTitle no-oping forever. -registerPaneKeyTeardownListener((paneKey) => { - stopSyntheticTitleSpinner(paneKey) -}) - -// Why: the spinner is a stand-in for a live hook status, so it must retire with the row it -// stands in for — otherwise a pane whose status was cleared or dismissed keeps rotating a -// working title long after the agent finished (#13890). Both paths are covered: the -// pane-scoped clear fan-out, and user dismissal, which never routes through it. -agentHookServer.subscribePaneStatusClear((clear) => { - const paneKey = getSyntheticTitleSpinnerPaneKeyToStop(clear) - if (paneKey) { - stopSyntheticTitleSpinner(paneKey) - } -}) -agentHookServer.subscribeStatusDrop(stopSyntheticTitleSpinner) - -function sendSyntheticTitle(ptyId: string, data: string, options: { force?: boolean } = {}): void { - if (!mainWindow || mainWindow.isDestroyed()) { - return - } - // Why: throttle decorative spinner frames (up to 80ms/agent); final/permission frames are forced because they drive BEL. - if ( - !shouldSendSyntheticTitleFrame({ - force: options.force === true, - windowVisible: isSyntheticTitleWindowVisible() - }) - ) { - return - } - // Why: feed the per-PTY tracker directly, never onPtyData — emulator/tails/transcripts/stats must not see fabricated bytes. - runtime?.ingestSyntheticTitleFrame(ptyId, data) - // Why: only the kill-switch-off renderer byte-parses synthetic frames; under main authority the copy mints phantom ACKs (see synthetic-title-frame-routing.ts). - if (shouldCopySyntheticTitleFrameToPtyData(store?.getSettings())) { - mainWindow.webContents.send('pty:data', { id: ptyId, data }) - } -} - -function isSyntheticTitleWindowVisible(): boolean { - return ( - mainWindow !== null && - !mainWindow.isDestroyed() && - mainWindow.isVisible() && - !mainWindow.isMinimized() - ) -} - -function canSendDecorativeSyntheticTitle(): boolean { - return shouldSendSyntheticTitleFrame({ - force: false, - windowVisible: isSyntheticTitleWindowVisible() - }) -} - -function stopSyntheticTitleSpinner(paneKey: string): void { - if (syntheticTitleSpinnerByPaneKey.delete(paneKey)) { - stopSyntheticTitleSpinnerTimerIfIdle() - } -} - -function stopAllSyntheticTitleSpinners(): void { - syntheticTitleSpinnerByPaneKey.clear() - stopSyntheticTitleSpinnerTimer() -} - -function stopSyntheticTitleSpinnerTimer(): void { - if (!syntheticTitleSpinnerTimer) { - return - } - clearInterval(syntheticTitleSpinnerTimer) - syntheticTitleSpinnerTimer = null -} - -function stopSyntheticTitleSpinnerTimerIfIdle(): void { - if (syntheticTitleSpinnerByPaneKey.size === 0) { - stopSyntheticTitleSpinnerTimer() - } -} - -function tickSyntheticTitleSpinners(): void { - if (!canSendDecorativeSyntheticTitle()) { - stopSyntheticTitleSpinnerTimer() - return - } - const ticks = advanceSyntheticTitleSpinnerEntries({ - entries: syntheticTitleSpinnerByPaneKey, - frameCount: SPINNER_FRAMES.length, - getPtyIdForPaneKey - }) - for (const tick of ticks) { - sendSyntheticTitle( - tick.ptyId, - `\x1b]0;${SPINNER_FRAMES[tick.frame]} ${tick.profile.workingLabel}\x07` - ) - } - stopSyntheticTitleSpinnerTimerIfIdle() -} - -function ensureSyntheticTitleSpinnerTimer(): void { - if ( - syntheticTitleSpinnerTimer || - syntheticTitleSpinnerByPaneKey.size === 0 || - !canSendDecorativeSyntheticTitle() - ) { - return - } - // Why: one shared timer for all spinners — per-pane intervals multiplied idle wakeups when several agents were working. - syntheticTitleSpinnerTimer = setInterval(tickSyntheticTitleSpinners, SPINNER_INTERVAL_MS) -} - -function resumeSyntheticTitleSpinnerTimer(): void { - ensureSyntheticTitleSpinnerTimer() -} - -function driveSyntheticTitleFromHook( - paneKey: string, - state: AgentStatusState, - profile: SyntheticAgentTitleProfile -): void { - const ptyId = getPtyIdForPaneKey(paneKey) - if (!ptyId) { - return - } - if (state === 'working') { - // Why: emit the first frame immediately so the spinner is visible now, not up to 80ms later at the next interval tick. - const existing = syntheticTitleSpinnerByPaneKey.get(paneKey) - const frame = existing ? existing.frame : 0 - sendSyntheticTitle(ptyId, `\x1b]0;${SPINNER_FRAMES[frame]} ${profile.workingLabel}\x07`) - if (existing) { - // Why: refresh the profile so a mid-pane agent-type change lands on the right idle/permission labels at terminal state. - existing.profile = profile - return - } - syntheticTitleSpinnerByPaneKey.set(paneKey, { frame, profile }) - ensureSyntheticTitleSpinnerTimer() - return - } - // Why: stop the spinner first so the next tick can't race the state back to "working", then inject the terminal frame. - // Permission frames add a trailing BEL to light up user-input states; done frames omit it (completion notifications own that attention). - stopSyntheticTitleSpinner(paneKey) - const needsUserInput = state === 'blocked' || state === 'waiting' - const label = needsUserInput ? profile.permissionLabel : profile.idleLabel - sendSyntheticTitle(ptyId, `\x1b]0;${label}\x07${needsUserInput ? '\x07' : ''}`, { - force: true - }) -} - -function shouldSuppressCodexAutoApprovalSyntheticTitleFromHook(args: { - agentType: string | null | undefined - state: AgentStatusState - launchConfig: - | { - agentArgs?: string | null - agentEnv?: Record | null - } - | null - | undefined -}): boolean { - if (args.agentType !== 'codex' || (args.state !== 'waiting' && args.state !== 'blocked')) { - return false - } - if (!args.launchConfig) { - return false - } - return ( - resolveTuiAgentPermissionMode({ - agent: 'codex', - agentArgs: args.launchConfig.agentArgs, - agentEnv: args.launchConfig.agentEnv - }) === 'yolo' - ) -} - -void app.whenReady().then(async () => { - logStartupMilestone('app-ready') - // Why: a headless automated run must not claim a macOS Dock tile or the menu bar. - applyBackgroundActivationPolicy({ warn: console.warn }) - installElectronProxyRequestGuard(session.defaultSession) - app.on('login', (event, webContents, details, authInfo, callback) => { - handleElectronProxyLogin( - event, - webContents, - details, - authInfo, - callback, - session.defaultSession - ) - }) - installMainThreadHangWatchdog({ userDataPath: getCanonicalUserDataPath() }) - const hangDetection = consumeHangDetectionMarker( - hangDetectionMarkerPath(getCanonicalUserDataPath()) - ) - if (hangDetection) { - recordDurableCrashBreadcrumb('main_thread_hang_detected', { - unresponsiveMs: hangDetection.unresponsiveMs, - previousPid: hangDetection.parentPid, - selfRecovered: hangDetection.selfRecovered - }) - } - // Why: install certificate decisions before any webview or headless window issues its 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) - // Why: names the app menu/About panel. Dev already applied this pre-ready (see the - // safeStorage note above); this call stays unconditional so packaged builds keep their - // existing post-ready rename, which lands after the Keychain name is already resolved. - app.setName(devInstanceIdentity.appName) - updateGpuAccelerationAboutPanel() - - // Why: managed WSL launchers live outside the Windows app bundle, so keep their launcher/bridge contract synced across app updates. - managedWslCliReconciliationStatus = 'pending' - managedWslCliReconciliationReady = reconcileManagedWslCliRegistrations({ - isPackaged: app.isPackaged, - userDataPath: getCanonicalUserDataPath(), - appVersion: app.getVersion() - }) - .then((results) => { - for (const result of results) { - if (result.outcome === 'failed') { - console.warn( - `[wsl-cli] ${result.distro} managed registration reconciliation failed: ${result.error}` - ) - } else if (result.outcome === 'repaired') { - console.log(`[wsl-cli] Repaired managed registration in ${result.distro}.`) - } - } - managedWslCliReconciliationStatus = 'settled' - }) - .catch((error) => { - managedWslCliReconciliationStatus = 'failed' - console.warn( - '[wsl-cli] Managed registration reconciliation discovery failed:', - error instanceof Error ? error.message : String(error) - ) - }) - managedWslCliStartupBarrierReady = createWslCliReconciliationStartupBarrier( - managedWslCliReconciliationReady - ) - - const activeOrcaProfile = ensureActiveOrcaProfile() - // Why this early: the first window stamps the hosting id into its renderer's argv, so the durable - // read has to have happened by then or the renderer and the browser-host lease disagree. - initializeBrowserClientHostId(activeOrcaProfile.profileDirectory) - store = new Store({ - dataFile: activeOrcaProfile.dataFile, - storageAuthority: isServeMode ? 'runtime' : 'desktop' - }) - // Why: create pending readiness before the guard can observe the default session. - const initialProxyApplication = applyElectronProxySettings(store.getSettings()) - installElectronProxyRequestGuard(session.defaultSession) - // Why armed here and not at install time: the report remembers what it last said, and - // that state lives beside the profile data file, which does not exist until now. - // Why scheduled and not called: the report probes the OS keyring, which blocks on Linux - // and must not gate the first window (STA-5765). - scheduleSecretProtectionGapReport({ - dataFile: activeOrcaProfile.dataFile, - force: process.env.ORCA_ALWAYS_REPORT_SECRET_PROTECTION === '1', - deferUntilFirstWindow: !isServeMode - }) - // Why here: the host key store is a sidecar of the same profile, and every SSH connect consults - // it. Left unbound it reports nothing trusted, which is safe but silently discards our own - // accept records on every launch. - initSshHostKeyStoreFile(activeOrcaProfile.dataFile) - // Why: must precede PTY handler registration and run in headless serve too, which returns before openMainWindow. - neutralizeLegacyTerminalShimDir(app.getPath('userData')) - const windowsShellPathHydration = createWindowsShellPathHydration() - configureWindowsHostGitEnvironmentReadiness( - process.platform === 'win32' ? windowsShellPathHydration.whenReady : null - ) - if (process.platform === 'win32') { - const settings = store.getSettings() - if (app.isPackaged) { - void windowsShellPathHydration.hydrate( - settings.terminalWindowsShell, - settings.terminalWindowsPowerShellImplementation - ) - } else { - windowsShellPathHydration.configure( - settings.terminalWindowsShell, - settings.terminalWindowsPowerShellImplementation - ) - } - } - wslHookRelayManager.setManagedHookSettingsResolver(() => store?.getSettings() ?? null) - logStartupMilestone('store-loaded') - // Why: apply initial fallback WSL distro from store settings for global git/CLI calls. - setDefaultWslDistroOverride(store.getSettings().terminalWindowsWslDistro ?? null) - store.onSettingsChanged((updates, settings) => { - if ('terminalWindowsWslDistro' in updates) { - // Why: synchronize fallback WSL distro updates to runner. - setDefaultWslDistroOverride(settings.terminalWindowsWslDistro ?? null) - } - if ( - ('terminalWindowsShell' in updates || 'terminalWindowsPowerShellImplementation' in updates) && - process.platform === 'win32' - ) { - if (app.isPackaged) { - void windowsShellPathHydration.hydrate( - settings.terminalWindowsShell, - settings.terminalWindowsPowerShellImplementation - ) - } else { - windowsShellPathHydration.configure( - settings.terminalWindowsShell, - settings.terminalWindowsPowerShellImplementation - ) - } - } - if ('showMenuBarIcon' in updates) { - // Why: Store is the mutation authority for all settings writes, so every macOS toggle updates the native item live. - syncMacMenuBarIcon(settings.showMenuBarIcon !== false) - } - if ('agentStatusHooksEnabled' in updates) { - // Why both directions: the ensure gate only blocks NEW relays, so off must stop the running - // guest process and timers, and on must restart them — otherwise open WSL panes report no - // status until their next spawn. - if (isAgentStatusHooksEnabled(settings)) { - wslHookRelayManager.resumeStoppedRelays() - } else { - wslHookRelayManager.disposeAll({ permanent: false }) - } - } - }) - // Why: run before ClaudeRuntimeAuthService's constructor sync — a surviving daemon Claude CLI holds the single-use refresh token; early refresh rotates it out mid-session. - attachClaudeLivePtyPersistence(store) - // Why: while a live claude defers the managed OAuth refresh, usage shows - // "Waiting for Claude session"; refetch when the last live PTY exits so the - // error clears immediately instead of after the failure backoff. - onLiveClaudePtysDrained(() => { - void rateLimits?.refreshAfterClaudeLivePtysDrained() - }) - const persistedClaudePtyIds = store.getClaudeLivePtySessionIds() - seedLiveClaudePtysFromPersistence(persistedClaudePtyIds) - if (persistedClaudePtyIds.length > 0) { - console.log( - `[claude-live-pty] Seeded ${persistedClaudePtyIds.length} persisted Claude session id(s) into the refresh gate` - ) - } - applyAppIcon(store.getSettings().appIcon) - if (shouldSuppressDevEducation({ isDev: is.dev })) { - suppressDevEducationForStore(store) - } - try { - // Why: Dock/Launchpad launches don't inherit shell proxy env vars, so apply the persisted proxy before any app-owned network fetchers run. - const proxyApplyResult = await initialProxyApplication - if (proxyApplyResult.source === 'invalid-settings') { - // Why (STA-3442): a silent DIRECT fallback made a dead configured proxy undiagnosable. - console.warn('[proxy] persisted proxy settings are invalid; using direct networking') - } - } catch { - console.warn('[proxy] Failed to apply network proxy settings') - } - // Why: the partition installer reads the proxy through this resolver, so register it before sessions materialize. - setBrowserNetworkProxySettingsResolver(() => store!.getSettings()) - // Why: the preview session is protocol-scoped, so the handler must exist before any preview webview attaches. - installDocPreviewProtocolHandler() - registerDocPreviewGrantHandlers() - // Why: browser sessions serve desktop webviews and runtime profile commands, so init at app startup rather than via a renderer IPC path. - initializeBrowserSessionsForApp({ - orcaProfileId: activeOrcaProfile.profile.id, - profileDirectory: activeOrcaProfile.profileDirectory, - // Why: local direct-SSH partitions are scoped to targets, and the orphan - // sweep must see the live target list or it would clear their cookie jars. - listLocalSshTargetIds: () => { - if (!store) { - // Why: an empty list would read as "every SSH jar is an orphan"; throwing skips the sweep. - throw new Error('ssh target store unavailable at partition sweep') - } - return store.getSshTargets().map((target) => target.id) - } - }) - try { - // Why: awaited here so the first guest navigation cannot race the installer's fire-and-forget write. - await applyBrowserSessionProxies(browserSessionRegistry.listProfiles(), store.getSettings()) - } catch { - console.warn('[proxy] Failed to apply network proxy settings to browser sessions') - } - unsubscribeSystemResumeBroadcast = registerSystemResumeBroadcast() - agentAwakeService = new AgentAwakeService() - agentAwakeService.setMode( - normalizeComputerAwakeMode( - store.getSettings().computerAwakeMode, - store.getSettings().keepComputerAwakeWhileAgentsRun - ) - ) - // Why: start from empty — disk-hydrated status rows are UI continuity only; only this runtime's hook events keep the computer awake. - agentAwakeService.setStatuses([]) - const collectChangedProviderSessionWorktrees = createHookProviderSessionInvalidator() - const publishProviderSessionChanges = (identities: AgentHookProviderSessionIdentity[]): void => { - const ownedIdentities = identities.map((identity) => ({ - ...identity, - worktreeId: - identity.worktreeId ?? - runtime?.getTerminalWorktreeIdForPaneKey(identity.paneKey) ?? - undefined - })) - for (const worktreeId of collectChangedProviderSessionWorktrees(ownedIdentities)) { - // Why not `notifyMobileSessionTabsChanged` alone: it re-emits at the unchanged - // `snapshotVersion`, which every client drops on its monotonic gate. - runtime?.touchMobileSessionTabsForWorktree(worktreeId, { immediate: true }) - } - } - const unsubscribeStatusChanges = agentHookServer.subscribeStatusChanges((statuses) => { - agentAwakeService?.setStatuses(statuses) - }) - const unsubscribeProviderSessionChanges = agentHookServer.subscribeProviderSessionChanges( - (sessions) => { - // Healthy session.tabs streams need a push when transcript identity changes. - publishProviderSessionChanges(sessions) - } - ) - // Why: hook rows are the only carrier of live agent state on a headless host, and - // nothing else republishes `session.tabs` when one changes — so a paired client - // would keep the pane's last projection until an unrelated PTY touch came along. - const hookStatusChangedSessionTabs = createHookStatusSessionTabsInvalidator() - const unsubscribeHookStatusSessionTabs = agentHookServer.subscribeEnrichedStatus((enriched) => { - if (hookStatusChangedSessionTabs(enriched)) { - runtime?.touchMobileSessionTabsForPane(enriched.paneKey, enriched.worktreeId ?? null) - } - }) - // Teardown: agent exit, pane close, and the SSH transient-disconnect batch all land - // here. Without it the live state published above becomes a zombie question card. - const unsubscribeHookStatusClear = agentHookServer.subscribePaneStatusClear((clear) => { - const clearedPaneKeys = - 'paneKey' in clear - ? [clear.paneKey] - : hookStatusChangedSessionTabs.forgetConnection(clear.connectionId) - for (const paneKey of clearedPaneKeys) { - hookStatusChangedSessionTabs.forgetPane(paneKey) - runtime?.touchMobileSessionTabsForPane(paneKey) - } - }) - unsubscribeAgentAwakeStatusChanges = () => { - unsubscribeStatusChanges() - unsubscribeProviderSessionChanges() - unsubscribeHookStatusSessionTabs() - unsubscribeHookStatusClear() - } - // Why: telemetry must init before any IPC handler/renderer can call track(); it's a no-op in dev and while TELEMETRY_ENABLED is false, so it's safe early. - initTelemetry(store) - // Why: the breadcrumb alone never leaves the machine — it rides crash reports, and a hang is not - // a crash (the app is force-quit, so no report is ever generated). Without this the incidence - // number the watchdog exists to produce would sit unread on the user's disk. Must run after - // initTelemetry: track() drops silently until the client and store are wired. - if (hangDetection) { - track('main_thread_hang_detected', { - unresponsive_ms: Math.round(hangDetection.unresponsiveMs), - self_recovered: hangDetection.selfRecovered - }) - } - // Why: the trust-grant module is bundled into plain-node CLI entries where - // the telemetry client cannot load, so the tracker is injected here instead - // of imported there. - setCodexTrustGrantTelemetry(({ outcome, hostKind, lane, reason, errorClass, verifyClass }) => { - track('codex_trust_grant', { - outcome, - host_kind: hostKind, - lane, - ...(reason !== undefined ? { fallback_reason: reason } : {}), - ...(errorClass !== undefined ? { error_class: errorClass } : {}), - ...(verifyClass !== undefined ? { verify_class: verifyClass } : {}) + state.skillShareDeepLinks.capture(process.argv) + // Why no publish: nothing is listening this early, so the first renderer pulls these on mount. + state.osOpenedMarkdownFiles.capture(process.argv) + registerMainProcessIpcHandlers() + installMainProcessQuitHandlers() + void app.whenReady().then(async () => { + await initializeMainProcessReady({ + openMainWindow, + handleMacAppActivation }) }) - // Why: the error-tracking lane (telemetry-error-tracking.md) is its own - // composition root — independent of product telemetry — and must - // initialize before any IPC handler / runtime span is created so the - // tracer's active sink is populated at the moment the first span fires. - // Honors DO_NOT_TRACK / ORCA_TELEMETRY_DISABLED / ORCA_DIAGNOSTICS_DISABLED - // / CI internally; those gates do not need to be re-checked here. - initObservability() - recordDurableCrashBreadcrumb('main_process_lifecycle_started', { - packaged: app.isPackaged, - platform: process.platform - }) - const skillTransactionRecovery = recoverPendingSkillTransactions( - join(app.getPath('userData'), 'skill-installs') - ) - void skillTransactionRecovery - .then((report) => { - if (report.scanned || report.failures.length || report.truncated) { - console.info('[skills] startup transaction recovery:', { - scanned: report.scanned, - recovered: report.recovered, - failures: report.failures.map((failure) => failure.code), - truncated: report.truncated - }) - } - }) - .catch((error) => console.warn('[skills] startup transaction recovery failed:', error)) - // Why: cohort-classifier reads repo count synchronously at every emit, so hydrate it here — before any IPC handler or window can trigger track(). - initCohortClassifier(store) - initOnboardingCohortClassifier(store) - stats = new StatsCollector() - // Agent-session stats come from hook status transitions, the same truth the - // sidebar and dashboard read — never from OSC terminal titles, which miss - // hook-only agents and count any spinner TUI as an agent (#10201). - const agentSessionRecorder = new AgentSessionTransitionRecorder(stats) - agentHookServer.subscribeEnrichedStatus((enriched) => { - agentSessionRecorder.onStatus(enriched) - }) - agentHookServer.subscribePaneStatusClear((clear) => { - agentSessionRecorder.onCleared(clear) - }) - claudeUsage = new ClaudeUsageStore(store) - codexUsage = new CodexUsageStore(store) - openCodeUsage = new OpenCodeUsageStore(store) - rateLimits = new RateLimitService() - codexRuntimeHome = new CodexRuntimeHomeService(store) - void startCodexStateDbBackfillRecoveryInBackground(getOrcaManagedCodexHomePath()) - // Why: an incapable trust-grant host must fall back to the managed home for - // every consumer (PTY env, rate limits, commit messages) in one place. - codexRuntimeHome.setRealHomeLaneGate(() => isRealHomeCodexHookLaneUsable()) - // Why: while the real-home lane owns ~/.codex/hooks.json, the legacy - // system-home sweep inside managed installs would delete the entry the - // real-home installer just appended. Flag OFF, hooks off, or an incapable - // trust lane re-arms the sweep so downgrade, opt-out, and rollback converge. - setSystemCodexHomeHookSweepSuppressed( - () => - codexRuntimeHome !== null && - codexRuntimeHome.isHostSystemDefaultRealHome() && - isAgentStatusHooksEnabled(store?.getSettings()) - ) - codexSessionMigration = createCodexSessionMigrationScheduler({ - isEligible: () => codexRuntimeHome?.isHostSystemDefaultSessionMigrationEligible() === true, - isQuitting: () => isQuitting, - resolveSystemCodexHomePathOverride: () => - resolveHostCodexSessionSourceHome(store!.getSettings()), - prepareScheduledRun: (scanDates) => - codexRuntimeHome?.prepareHostSystemDefaultSessionMigrationPass(scanDates), - finishScheduledRun: () => codexRuntimeHome?.finishHostSystemDefaultSessionMigrationPass(), - startBackfill: startCodexSessionBackfillInBackground, - startIndexHeal: startCodexSessionIndexHealInBackground - }) - codexAccounts = new CodexAccountService(store, rateLimits, codexRuntimeHome, { - onHostSystemDefaultSelected: codexSessionMigration.requestRun - }) - // Why: migrate historical shared-home sessions after startup; compatibility - // launches re-arm the non-destructive pass for new rollouts (#4444, #8612, #12480). - codexSessionMigration.scheduleInitialRun() - claudeRuntimeAuth = new ClaudeRuntimeAuthService(store) - claudeAccounts = new ClaudeAccountService(store, rateLimits, claudeRuntimeAuth) - rateLimits.setCodexHomePathResolver((target) => - codexRuntimeHome!.prepareForRateLimitFetch(target) - ) - rateLimits.setCodexFetchTarget(getInitialCodexRateLimitTarget(store.getSettings())) - // Why: Kimi's CLI refreshes its OAuth token in whichever runtime it runs in, so the - // usage fetch must read the WSL-side credentials when that's the configured runtime (#12370). - rateLimits.setKimiHomeResolver(() => resolveKimiHome(getKimiRuntimeTarget(store!.getSettings()))) - rateLimits.setClaudeFetchTarget(getInitialClaudeRateLimitTarget(store.getSettings())) - const syncAccountRuntimeTargets = createAccountRuntimeTargetSettingsSync( - rateLimits, - store.getSettings() - ) - store.onSettingsChanged((updates, settings) => { - // Why: auto is a live policy; retarget only providers whose settings-derived runtime changed. - void syncAccountRuntimeTargets(updates, settings).catch((error) => - console.warn('[rate-limits] Failed to apply account runtime target:', error) - ) - }) - rateLimits.setClaudeAuthPreparationResolver((target) => - claudeRuntimeAuth!.prepareForRateLimitFetch(target) - ) - // Why: live Claude sessions stream usage windows through their statusLine command; feeding them here avoids OAuth usage-endpoint polling (and its 429s). - agentHookServer.setClaudeStatusLineListener((event) => { - rateLimits?.ingestLiveClaudeRateLimits(event) - }) - rateLimits.setOpenCodeGoConfigResolver(() => { - const settings = store!.getSettings() - return { - sessionCookie: settings.opencodeSessionCookie, - workspaceIdOverride: settings.opencodeWorkspaceId - } - }) - rateLimits.setMiniMaxConfigResolver(() => { - const settings = store!.getSettings() - return { - sessionCookie: readMiniMaxSessionCookie() ?? '', - groupId: settings.minimaxGroupId, - models: settings.minimaxUsageModels - } - }) - rateLimits.setGeminiCliOAuthEnabledResolver(() => store!.getSettings().geminiCliOAuthEnabled) - rateLimits.setNetworkProxySettingsResolver(() => store!.getSettings()) - keybindings = new KeybindingService({ - homePath: app.getPath('home'), - getLegacyOverrides: () => store!.getSettings().keybindings, - legacyTabSwitchSeed: { - isPending: () => store!.getSettings().tabSwitchKeybindingSeed === 'pending', - markSeeded: () => { - store!.updateSettings({ tabSwitchKeybindingSeed: 'done' }) - } - } - }) - browserManager.setSettingsResolver(() => ({ keybindings: keybindings?.getOverrides() })) - rateLimits.setInactiveClaudeAccountsResolver(() => { - const settings = store!.getSettings() - const activeIds = new Set( - [ - normalizeClaudeRuntimeSelection(settings).host, - ...Object.values(normalizeClaudeRuntimeSelection(settings).wsl) - ].filter(Boolean) - ) - return settings.claudeManagedAccounts - .filter((account) => !activeIds.has(account.id)) - .map((account) => ({ - id: account.id, - managedAuthPath: account.managedAuthPath, - managedAuthRuntime: account.managedAuthRuntime, - wslDistro: account.wslDistro, - wslLinuxAuthPath: account.wslLinuxAuthPath - })) - }) - rateLimits.setInactiveCodexAccountsResolver(() => { - const settings = store!.getSettings() - const activeIds = new Set( - [ - normalizeCodexRuntimeSelection(settings).host, - ...Object.values(normalizeCodexRuntimeSelection(settings).wsl) - ].filter(Boolean) - ) - return settings.codexManagedAccounts - .filter((account) => !activeIds.has(account.id)) - .map((account) => ({ - id: account.id, - resolveHome: () => { - const resolved = codexRuntimeHome!.resolveCodexManagedAccountHomeForInactiveFetch(account) - return resolved.kind === 'ready' - ? { kind: 'ready' as const, managedHomePath: resolved.homePath } - : { kind: 'skip' as const } - } - })) - }) - const orchestrationEnvironmentTransport: OrchestrationEnvironmentTransport = { - resolve: (selector) => { - const environment = resolveEnvironment(app.getPath('userData'), selector) - const pairing = getPreferredPairingOffer(environment) - return { - environmentId: environment.id, - name: environment.name, - peerFingerprint: fingerprintOrchestrationPeer(pairing.publicKeyB64) - } - }, - call: (selector, method, params, timeoutMs, envelope) => - callRuntimeEnvironment( - app.getPath('userData'), - selector, - method, - params, - timeoutMs, - undefined, - envelope - ) - } - const runtimeService = new OrcaRuntimeService(store, stats, { - agentSessionClaimSigner: loadAgentSessionClaimSigner( - getProfileUserDataPath(), - getProfileUserDataPath() - ), - // Why: resolve the PTY provider lazily — a daemon swap happens later, so an eager reference would freeze the pre-daemon provider (design §4.3). - getLocalProvider: () => getLocalPtyProvider(), - // Why: SSH relay providers register after construction and may reconnect, so destructive cleanup must resolve the current generation. - getSshProvider: (connectionId) => getSshPtyProvider(connectionId), - onPtyStopped: clearProviderPtyState, - onTerminalAgentStatus: (event) => { - agentHookServer.ingestTerminalStatus(event) - }, - // Why: serve can be promoted in place, so wire the listener from startup; runtime enables desktop-only scanners only for a ready renderer. - onTerminalSideEffects: (batch: TerminalSideEffectBatch) => { - if (mainWindow && !mainWindow.isDestroyed()) { - mainWindow.webContents.send('pty:sideEffect', batch) - } - }, - getDesktopWindowStatus: getDesktopWindowStatus, - // Why: worktree.ps pulls hook-reported agent status (same source as the desktop sidebar) at query time so mobile shows the same agents. - getAgentStatusSnapshot: () => - agentHookServer.getStatusSnapshot().filter((entry) => entry.providerSessionOnly !== true), - // Why: the filter above hides resume-identity rows from the live-agent views, but - // those rows carry the provider session mobile native chat addresses transcripts - // by — Pi publishes identity that way and would otherwise be unreachable. - getAgentProviderSessionSnapshot: () => agentHookServer.getStatusSnapshot(), - getAgentProviderSessionRowsForPane: (paneKey) => - agentHookServer.getStatusSnapshotForPane(paneKey), - attestAgentHookCompatibilityAuthority: (candidate) => - agentHookServer.attestCompatibilityAuthority(candidate), - retireAgentHookCompatibilityAuthority: (paneKey) => - agentHookServer.retirePaneAuthority(paneKey), - reconcileAgentStatusForEndedProcess: (paneKeys) => { - agentHookServer.reconcileEndedProcessForPaneKeys(paneKeys) - }, - canRecoverPersistentLocalPtys: () => getDaemonProvider() !== null, - // Why: evaluated per call, not captured — the RPC server that owns the device registry is - // constructed with this runtime and does not exist yet at this point. - getPairedDeviceName: (pairedDeviceId) => - runtimeRpc?.getDeviceRegistry()?.getDevice(pairedDeviceId)?.name ?? null, - // Why: source codex-home here (runs in window AND serve) so aiVault.listSessions includes managed-Codex sessions; registerCoreHandlers is window-only. - getAdditionalAiVaultCodexHomePaths: () => - codexRuntimeHome ? codexRuntimeHome.getHostCodexHomePathsForSessionDiscovery() : [], - prepareAiVaultSessionResume: (args) => - prepareCodexAiVaultSessionResume(args, { - runtimeHome: codexRuntimeHome, - systemCodexHomePath: resolveHostCodexSessionSourceHome(store!.getSettings()) - }), - prepareCodexStructuredLaunch: ({ workspacePath, launchEnv }) => - prepareCodexRuntimeHomeForLaunch(undefined, launchEnv, { - launchAgent: 'codex', - workspacePath - }), - buildAgentHookPtyEnv: () => - isAgentStatusHooksEnabled(store?.getSettings()) ? agentHookServer.buildPtyEnv() : {}, - orchestrationEnvironmentTransport, - skillTransactionRecovery - }) - runtime = runtimeService - runtimeService.prepareLegacyWorkerTerminalRecovery() - // Why before anything can attach: a client host that reattaches to a restarted runtime is only - // handed its pages back if the runtime found them first. - runtimeService.rehydrateClientHostedBrowserPages() - publishProviderSessionChanges(agentHookServer.getProviderSessionIdentities()) - browserManager.setBrowserGuestStateChangedListener((worktreeId) => { - runtimeService.notifyMobileSessionTabsChanged(worktreeId) - }) - automations = new AutomationService(store, { - claudeUsage, - codexUsage, - terminalObserver: createRuntimeAutomationRunTerminalObserver(runtimeService), - onAutomationsChanged: (payload) => runtimeService.notifyAutomationsChanged(payload), - // Why: desktop clients mirror remote-host automations, but only a server process should execute remote_host_service-owned schedules. - allowRemoteHostScheduling: isServeMode, - headlessDispatcher: isServeMode - ? async ({ automation, run, target }) => { - const terminalSnapshotLimit = 2_000 - let terminalHandle: string - let terminalSessionId: string | null = null - let terminalPaneKey: string | null = null - let terminalPtyId: string | null = null - let workspaceId: string - let workspaceDisplayName: string | null = null - - if (automation.workspaceMode === 'new_per_run') { - const created = await runtimeService.createManagedWorktree({ - ...buildHeadlessAutomationWorktreeCreateArgs({ - automation, - run, - repo: target.repo - }) - }) - terminalHandle = created.startupTerminal?.handle ?? '' - terminalSessionId = created.startupTerminal?.tabId ?? null - terminalPaneKey = created.startupTerminal?.paneKey ?? null - terminalPtyId = created.startupTerminal?.ptyId ?? null - workspaceId = created.worktree.id - workspaceDisplayName = created.worktree.displayName ?? null - if (!terminalHandle) { - throw new Error( - created.warning || - 'Automation workspace was created, but no agent terminal started.' - ) - } - } else { - if (!automation.workspaceId) { - throw new Error('The target workspace is no longer available.') - } - const terminal = await runtimeService.launchAgentTerminal( - `id:${automation.workspaceId}`, - { - agent: automation.agentId, - prompt: automation.prompt, - title: run.title - } - ) - terminalHandle = terminal.handle - terminalSessionId = terminal.tabId ?? null - terminalPaneKey = terminal.paneKey ?? null - terminalPtyId = terminal.ptyId ?? null - workspaceId = terminal.worktreeId - const worktree = await runtimeService.showManagedWorktree(`id:${workspaceId}`) - workspaceDisplayName = worktree.displayName ?? null - } - - const completion = (async () => { - const wait = await runtimeService.waitForTerminal(terminalHandle, { - condition: 'tui-idle' - }) - const read = await runtimeService.readTerminal(terminalHandle, { - limit: terminalSnapshotLimit - }) - const snapshotBuffer = createHeadlessAutomationOutputSnapshotBuffer() - snapshotBuffer.append(read.tail.join('\n')) - if (wait.satisfied) { - return { - status: 'completed' as const, - outputSnapshot: snapshotBuffer.snapshot(), - error: null - } - } - return { - status: 'dispatch_failed' as const, - outputSnapshot: snapshotBuffer.snapshot(), - error: wait.blockedReason - ? `Automation agent is blocked: ${wait.blockedReason}.` - : 'Automation agent did not report completion.' - } - })() - - return { - workspaceId, - workspaceDisplayName, - terminalSessionId, - terminalPaneKey, - terminalPtyId, - completion - } - } - : undefined - }) - runtimeService.setAutomationService(automations) - runtimeService.setArtifactService( - new ArtifactCloudService(app.getPath('userData'), () => - isArtifactSharingEnabled(store?.getSettings()) - ) - ) - runtimeService.setSkillCloudService(new SkillCloudService(app.getPath('userData'))) - runtimeService.setAccountServices({ claudeAccounts, codexAccounts, rateLimits }) - runtimeService.setCommitMessageAgentEnvironmentResolvers({ - // Why: Codex hooks/auth live in Orca's managed runtime home even for the default path, so every launch must resolve CODEX_HOME via runtime-home. - prepareForCodexLaunch: prepareCodexRuntimeHomeForLaunch, - prepareForClaudeLaunch: (target) => claudeRuntimeAuth!.prepareForClaudeLaunch(target) - }) - const pluginSystemStartupStartedAt = performance.now() - pluginKillListService = new PluginKillListService({ - pluginsDataDir: getPluginsDataDir(app.getPath('userData')) - }) - await pluginKillListService.initialize() - pluginMarketplaceService = new PluginMarketplaceService({ - pluginsDataDir: getPluginsDataDir(app.getPath('userData')), - getKillListEntry: (pluginKey) => pluginKillListService?.find(pluginKey) ?? null - }) - const requestOfficialMarketplaceSeed = (): void => { - if (store?.getSettings().pluginSystemEnabled !== true) { - return - } - void pluginMarketplaceService?.seedOfficialSource().catch((error) => { - console.warn('[plugins] failed to configure the official marketplace:', error) - }) - } - pluginMarketplaceInstaller = new PluginMarketplaceInstaller({ - marketplace: pluginMarketplaceService, - userDataPath: app.getPath('userData'), - hostVersion: app.getVersion(), - blockedPluginReason: (pluginKey) => pluginKillListService?.reason(pluginKey) ?? null - }) - pluginService = new PluginService({ - userDataPath: app.getPath('userData'), - hostVersion: app.getVersion(), - // Feature flag: with the setting off, discovery returns nothing and no - // plugin code path runs at all. - isPluginSystemEnabled: () => store?.getSettings().pluginSystemEnabled === true, - getDisabledPlugins: () => normalizePluginIdList(store?.getSettings().disabledPlugins), - getPluginConsents: () => normalizePluginConsents(store?.getSettings().pluginConsents), - getDevPluginPaths: () => normalizePluginIdList(store?.getSettings().devPluginPaths), - getKeybindings: () => keybindings?.getOverrides() ?? {}, - getPluginKillListEntry: (pluginKey) => pluginKillListService?.find(pluginKey) ?? null, - hostEntryPath: resolvePluginHostEntryPath(app.getAppPath(), app.isPackaged) - }) - const bundledPluginBootstrap = new PluginBundledBootstrapCoordinator({ - root: resolveBundledPluginRoot({ - isPackaged: app.isPackaged, - resourcesPath: process.resourcesPath, - appPath: app.getAppPath() - }), - userDataPath: app.getPath('userData'), - hostVersion: app.getVersion(), - isEnabled: () => store?.getSettings().pluginSystemEnabled === true, - blockedPluginReason: (pluginKey) => pluginKillListService?.reason(pluginKey) ?? null, - refreshPlugins: () => pluginService?.refresh() ?? Promise.resolve() - }) - const requestBundledPluginBootstrap = (): void => { - void bundledPluginBootstrap - .request() - .then((result) => { - for (const failure of result?.errors ?? []) { - console.warn(`[plugins] failed to publish bundled ${failure.pluginKey}:`, failure.error) - } - }) - .catch((error) => { - console.warn('[plugins] failed to bootstrap bundled plugins:', error) - }) - } - pluginKillListService.onChanged(() => { - void pluginService?.reconcileActivationState().catch((error) => { - console.warn('[plugins] failed to apply plugin safety-list refresh:', error) - }) - }) - store.onSettingsChanged((updates) => { - if (updates.pluginSystemEnabled === true) { - requestBundledPluginBootstrap() - requestOfficialMarketplaceSeed() - } - if (app.isPackaged && updates.pluginSystemEnabled === true) { - void pluginKillListService?.refresh().catch((error) => { - console.warn('[plugins] failed to refresh plugin safety list; using cached state:', error) - }) - } - }) - // Why: headless `orca serve` clients reach plugins through the runtime RPC - // methods, which resolve the service via this module-level setter. Consent - // over RPC uses the same hash-keyed write path as the desktop dialog. - setPluginServiceForRpc(pluginService, { - applyConsent: (request) => - applyPluginConsent({ store: store!, pluginService: pluginService!, ...request }), - applyEnablement: (pluginKey, enabled) => - applyPluginEnablement({ store: store!, pluginService: pluginService!, pluginKey, enabled }) - }) - // Lazy kernel: initialize() only discovers manifests — no worker forks, no - // panel reads. Zero plugin code runs before an explicit trigger. - void pluginService - .initialize() - .then(() => { - logStartupMilestone('plugin-system-initialized', { - durationMs: Number((performance.now() - pluginSystemStartupStartedAt).toFixed(2)), - installedPlugins: pluginService?.getDiscovered().length ?? 0 - }) - }) - .catch((error) => { - console.warn('[plugins] failed to initialize plugin service:', error) - }) - if (app.isPackaged && store?.getSettings().pluginSystemEnabled === true) { - void pluginKillListService.refresh().catch((error) => { - console.warn('[plugins] failed to refresh plugin safety list; using cached state:', error) - }) - } - pluginService.onChanged((event) => { - if ( - event.contentPacksChanged && - setMainPluginLanguagePacks(pluginService?.contentPacks.languagePacks.list() ?? []) - ) { - void setMainUiLanguage(store!.getSettings().uiLanguage).then(() => rebuildAppMenu()) - } - for (const window of BrowserWindow.getAllWindows()) { - if (!window.isDestroyed()) { - window.webContents.send('plugins:changed', event) - } - } - }) - requestBundledPluginBootstrap() - requestOfficialMarketplaceSeed() - // v0 plugin event seams: agent status (hook pipeline tap) + worktree - // lifecycle (runtime tap). Server-side filtered per plugin subscription. - agentHookServer.subscribeEnrichedStatus((enriched) => { - // Why: plugins may automate on `working`; restored rows are historical claims, not fresh activity. - if (enriched.restoredUnconfirmed) { - return - } - pluginService?.emitEvent('agent.status.changed', { - worktreeId: enriched.worktreeId ?? null, - paneKey: enriched.paneKey, - state: enriched.payload.state, - receivedAt: enriched.receivedAt - }) - }) - runtimeService.onWorktreeLifecycle((event) => { - emitPluginWorktreeLifecycle(event) - }) - starNag = new StarNagService(store, stats) - starNag.start() - starNag.registerIpcHandlers() - const agentBrowserBridge = new AgentBrowserBridge(browserManager, { - onTabsChanged: (worktreeId) => runtimeService.notifyMobileSessionTabsChanged(worktreeId) - }) - runtimeService.setAgentBrowserBridge(agentBrowserBridge) - // Why: daemons a crashed or SIGKILL'd previous run left behind answer to nobody; nothing else reclaims them. - void agentBrowserBridge.sweepOrphanedSessions() - const browserClientAutomationDispatcher = new RpcDispatcher({ runtime: runtimeService }) - configureBrowserClientPageAutomationRuntime({ - browserManager, - getAgentBrowserBridge: () => agentBrowserBridge, - executeRpc: async (method, params, signal) => { - const response = await browserClientAutomationDispatcher.dispatch( - { - id: randomUUID(), - authToken: 'local-browser-client-automation', - method, - params - }, - { signal } - ) - if (!response.ok) { - throw new BrowserClientPageCommandError(response.error.code) - } - return response.result - } - }) - - // Emulator bridge (serve-sim). macOS-only feature (gated in CLI/runtime); always ship like agent-browser. - // Why: externally started serve-sim processes must stay independent — only Orca-managed/attached helpers belong to a workspace. - const emulatorBridge = new EmulatorBridge() - runtimeService.setEmulatorBridge(emulatorBridge) - // Why: worktree deletion renames the checkout aside and deletes it in the background, so a quit or - // crash mid-delete can leave the moved directory on disk. - void sweepStaleWorktreeTrash( - collectWorktreeTrashSweepRoots(store.getRepos(), store.getSettings()) - ).catch((error) => { - console.warn('[worktrees] Failed to sweep leftover worktree directories:', error) - }) - nativeTheme.themeSource = store.getSettings().theme ?? 'system' - // Why (#16441): the real-home grant runs a codex app-server session. It stays - // ordered before managed-hook reconciliation — an incapable host must re-arm - // and complete the legacy real-home sweep first — but awaiting it inline - // stalled app init behind that session, so chain instead of blocking. - const startupManagedHookSettings = store.getSettings() - const shouldReconcileStartupManagedHooks = - shouldInstallManagedHooks(is.dev) && - resolveStartupManagedHookAction(startupManagedHookSettings) === 'install' - const realHomeCodexHookState = - shouldReconcileStartupManagedHooks && - shouldInstallStartupManagedAgentHook(startupManagedHookSettings, 'codex') && - codexRuntimeHome.isHostSystemDefaultRealHomeSelected() - ? ensureRealHomeCodexHookState({ - hooksEnabled: true, - userDataPath: app.getPath('userData') - }).catch((error: unknown) => { - console.warn('[codex-real-home-hooks] startup ensure failed:', error) - }) - : Promise.resolve() - // Why skip rather than remove when the off switch is set: the hook files are user-global but this - // decision reads only THIS profile's settings, so removing here deletes the hooks every other Orca - // instance depends on (STA-5679). Skipping already keeps removed hooks from reappearing on launch. - if (shouldReconcileStartupManagedHooks) { - const managedHookStore = store - void realHomeCodexHookState - .then(() => - installManagedAgentHooks(managedHookStore.getSettings(), { - shouldHydrateShellPath: app.isPackaged, - onInstallError: recordManagedHookInstallFailure, - shouldContinue: (agent) => { - const settings = managedHookStore.getSettings() - return shouldContinueManagedHookStartup(isQuitting, settings, agent) - } - }) - ) - .catch((error: unknown) => { - console.warn('[agent-hooks] failed to reconcile managed hooks on startup:', error) - }) - } - // Why: process-gone metrics only see survivors; retain a recent whole-app - // snapshot for comparison in crash reports. - startPreGoneProcessMetricsSampling() - app.on('child-process-gone', (_event, details) => { - recordProcessGoneCrash('child', details.type, details.reason, details.exitCode ?? null, { - name: details.name, - serviceName: details.serviceName, - type: details.type - }) - if ( - isGpuFallbackCrashCandidate({ - platform: process.platform, - processType: details.type, - reason: details.reason - }) - ) { - const crashedAt = performance.now() - void gpuCrashDiagnostics?.record() - void handleGpuChildCrash(details.reason, details.exitCode ?? null, crashedAt) - } - }) - - logStartupMilestone('services-initialized') - await ensureMainI18n() - await setMainUiLanguage(store.getSettings().uiLanguage) - logStartupMilestone('i18n-ready') - - registerAppMenu({ - appMenuLabel: devInstanceIdentity.name, - onCheckForUpdates: (options) => runUserInitiatedUpdateCheck(options), - onBeforeReload: ({ ignoreCache, webContentsId }) => { - if (mainWindow?.webContents.id === webContentsId) { - markExpectedRendererReload(webContentsId) - } - recordCrashBreadcrumb('manual_reload_requested', { ignoreCache }) - }, - onOpenSettings: openSettingsFromSystemMenu, - onOpenSetupGuide: (targetWindow) => { - recordCrashBreadcrumb('setup_guide_opened') - const targetBrowserWindow = targetWindow instanceof BrowserWindow ? targetWindow : null - sendOpenSetupGuide(targetBrowserWindow) - }, - onOpenCrashReport: (targetWindow) => { - recordCrashBreadcrumb('crash_report_opened') - const targetBrowserWindow = targetWindow instanceof BrowserWindow ? targetWindow : null - sendOpenCrashReport(targetBrowserWindow) - }, - onOpenFeatureTour: (targetWindow) => { - recordCrashBreadcrumb('feature_tour_opened') - // Why: use the invoking BrowserWindow so hidden/E2E and multi-window flows route to the right renderer, not global focus. - const targetBrowserWindow = targetWindow instanceof BrowserWindow ? targetWindow : null - sendOpenFeatureTour(targetBrowserWindow) - }, - // Why: menu zoom must act on the window the user is looking at — routing to - // the main window while the dashboard pop-out is focused zooms behind it. - onZoomIn: () => { - if (!zoomDashboardPopoutIfFocused('in')) { - mainWindow?.webContents.send('terminal:zoom', 'in') - } - }, - onZoomOut: () => { - if (!zoomDashboardPopoutIfFocused('out')) { - mainWindow?.webContents.send('terminal:zoom', 'out') - } - }, - onZoomReset: () => { - if (!zoomDashboardPopoutIfFocused('reset')) { - mainWindow?.webContents.send('terminal:zoom', 'reset') - } - }, - onToggleLeftSidebar: () => { - mainWindow?.webContents.send('ui:toggleLeftSidebar') - }, - onToggleRightSidebar: () => { - mainWindow?.webContents.send('ui:toggleRightSidebar') - }, - onToggleAppearance: (key) => { - if (!store) { - return - } - if (key === 'statusBarVisible') { - // Why: status bar visibility lives in persisted UI state (not settings) and the renderer owns the toggle — forward the event, let it flip + store. - mainWindow?.webContents.send('ui:toggleStatusBar') - return - } - const current = store.getSettings() - // Why: these appearance settings are default-on, so a missing persisted value must toggle from visible -> hidden. - const next = getNextDefaultOnAppearanceSettingValue(current[key]) - store.updateSettings({ [key]: next }, { notifyListeners: true }) - rebuildAppMenu() - }, - getAppearanceState: () => { - const settings = store?.getSettings() - const ui = store?.getUI() - return { - showTasksButton: settings?.showTasksButton !== false, - showAutomationsButton: settings?.showAutomationsButton !== false, - showMobileButton: settings?.showMobileButton !== false, - showTitlebarAppName: settings?.showTitlebarAppName !== false, - statusBarVisible: ui?.statusBarVisible !== false - } - }, - getKeybindings: () => keybindings?.getOverrides() - }) - // Why: parallel E2E Electron instances would race the fixed port (EADDRINUSE); port 0 gives each a random OS-assigned port. - const isE2E = Boolean(process.env.ORCA_E2E_USER_DATA_DIR) - const requestedE2EWsPort = process.env.ORCA_E2E_RUNTIME_WS_PORT - const e2eWsPort = requestedE2EWsPort === undefined ? 0 : Number(requestedE2EWsPort) - if (isE2E && (!Number.isInteger(e2eWsPort) || e2eWsPort < 0 || e2eWsPort > 65_535)) { - throw new Error(`Invalid ORCA_E2E_RUNTIME_WS_PORT value: ${requestedE2EWsPort}`) - } - // Why: pin dev to 6769 so `pnpm dev` doesn't race packaged Orca on 6768 and fall back to a random port, breaking deterministic mobile pairing/repro (STA-1511). - const devWsPort = is.dev && !isE2E ? 6769 : undefined - let serveOptions: ServeOptions | null = null - try { - serveOptions = isServeMode ? getServeOptions() : null - } catch (error) { - console.error(error instanceof Error ? error.message : String(error)) - app.exit(1) - return - } - // Why: existing installs may have pairing creds under the late app.getPath('userData'); copy them forward before switching to the canonical path. - migrateMobilePairingDataToCanonicalUserDataPath(app.getPath('userData')) - runtimeRpc = new OrcaRuntimeRpcServer({ - runtime, - // Why: mobile pairing needs the stable pre-setName() path (getCanonicalUserDataPath), not a late app.getPath('userData') that drops paired devices across restarts. - userDataPath: getCanonicalUserDataPath(), - enableWebSocket: true, - // Why: STA-2370 — the desktop app binds the WS listener to loopback until the user pairs a device; - // `orca serve` is an explicit remote opt-in, and E2E keeps the wide bind its harness connects over. - exposeNetworkByDefault: Boolean(serveOptions) || isE2E, - ...(isE2E ? { wsPort: e2eWsPort } : {}), - ...(devWsPort !== undefined ? { wsPort: devWsPort } : {}), - ...(serveOptions?.wsPort !== undefined - ? { - wsPort: serveOptions.wsPort, - // Why: only explicit `orca serve --port` overrides a stale STA-1511 fallback (issue #8535); default/dev stay fallback-first for pairing stability. - preferPinnedWsPort: true - } - : {}), - webClientRoot: getBundledWebClientRoot() - }) - registerMobileHandlers(runtimeRpc, { - getRelayStatus: () => desktopRelayStatus, - consumePendingUnpairedDeviceAuthFailure: (webContentsId) => { - if ( - !mainWindow || - mainWindow.isDestroyed() || - mainWindow.webContents.id !== webContentsId || - !pendingUnpairedDeviceAuthFailure - ) { - return false - } - pendingUnpairedDeviceAuthFailure = false - return true - } - }) - // Why: repeated direct auth failures otherwise look like a client that never connects; point users to re-pairing. - runtimeRpc.setOnUnpairedDeviceAuthFailure(() => { - // Why: runtime startup races renderer mount; retain the one-shot until the listener consumes it. - pendingUnpairedDeviceAuthFailure = true - if (mainWindow && !mainWindow.isDestroyed()) { - mainWindow.webContents.send('mobile:unpairedDeviceAuthFailure') - } - }) - - const shellPathReady = windowsShellPathHydration.whenReady() - let desktopWindow: BrowserWindow | null = null - if (process.platform === 'win32' && app.isPackaged && !serveOptions) { - const desktopStartup = startWindowsDesktopBeforeShellPathReady({ - bindServices: bindTerminalRuntimeStartupServices, - openWindow: () => openMainWindow({ revealOnDidFinishLoad: true }), - shellPathReady, - startServices: startTerminalRuntimeStartupServices - }) - desktopWindow = desktopStartup.window - } else { - await shellPathReady - bindTerminalRuntimeStartupServices(Promise.resolve(startTerminalRuntimeStartupServices())) - } - app.on('activate', handleMacAppActivation) - - if (serveOptions) { - // Why: give managed WSL launchers a brief chance to migrate before headless PTYs go live, without slow repairs withholding all RPC readiness. - logStartupMilestone('wsl-cli-barrier-start') - await managedWslCliStartupBarrierReady - logStartupMilestone('wsl-cli-barrier-resolved', { - reconciliation: managedWslCliReconciliationStatus - }) - // Why: headless PTYs must not start on the fallback provider, then get swept when an activated renderer registers desktop lifecycle handlers. - await localPtyStartupReady - await localPtyProviderStartupReady - await registerHeadlessPtyRuntime( - runtime, - prepareCodexRuntimeHomeForLaunch, - () => store!.getSettings(), - (target) => claudeRuntimeAuth!.prepareForClaudeLaunch(target), - store, - prepareCodexSessionResumeForLaunch, - { - onCodexHomePtySpawned: handleCodexHomePtySpawned, - onPtyExit: handlePtyExit - } - ) - await runtime.refreshRestoredOrchestrationAuthority() - await runtime.reconcileLegacyWorkerTerminals() - // Why: headless servers can't mount panes; use offscreen WebContents, gated on a real display so browser.headless.v1 stays honest. - if (headlessBrowserDisplayAvailable) { - runtime.setOffscreenBrowserBackend( - new OffscreenBrowserBackend(browserManager, { - getAgentBrowserBridge: () => agentBrowserBridge - }) - ) - } - // Why: headless servers have no renderer graph publisher; publish an explicit empty graph so status clients see a ready server. - runtime.syncWindowGraph(HEADLESS_RUNTIME_WINDOW_ID, { tabs: [], leaves: [] }) - await runtimeRpc.start().catch((error) => { - console.error('[runtime] Failed to start headless RPC transport:', error) - throw error - }) - settleServeDesktopActivation() - // Why: every attempt must reach app.quit(); a page beforeunload can veto an earlier signal. - registerServeSignalHandlers(process, () => app.quit()) - // Why: headless serve has no renderer to run the normal cli:install flow; do it here for macOS/Linux only (Windows-excluded: install() only mutates registry PATH, not child terminals). - if (process.platform === 'darwin' || process.platform === 'linux') { - try { - // Why: serve is headless — a fallback osascript admin prompt would hang it; skip elevation since ~/.local/bin needs none. - const cliStatus = await new CliInstaller({ - privilegedRunner: async () => { - throw new Error('serve CLI auto-install must not request administrator privileges') - } - }).install() - console.log( - `[serve] orca CLI install: ${cliStatus.state}${cliStatus.commandPath ? ` (${cliStatus.commandPath})` : ''}` - ) - } catch (error) { - console.warn( - '[serve] orca CLI install skipped:', - error instanceof Error ? error.message : String(error) - ) - } - } - // Why: Linux CLI installs as `orca-ide`, but the Claude Team launcher invokes bare `orca`; drop a ~/.local/bin dispatcher (ahead of /usr/bin) so it resolves. Best-effort. - if (process.platform === 'linux' && app.isPackaged && process.resourcesPath) { - try { - const dispatcher = await installLinuxBareOrcaDispatcher({ - resourcesPath: process.resourcesPath - }) - console.log( - `[serve] bare orca dispatcher ${dispatcher.state}: ${dispatcher.dispatcherPath}` + - `${dispatcher.target ? ` -> ${dispatcher.target}` : ''}` - ) - } catch (error) { - console.warn( - '[serve] bare orca dispatcher install skipped:', - error instanceof Error ? error.message : String(error) - ) - } - } - // Why: headless serve never opens a renderer, so arm scheduled automation dispatch here. - automations.start() - // Why: serve deletes worktrees too, and the history GC that normally drains delete tombstones is - // armed from the main window — without this, a quit mid-removal leaks the tree until a desktop launch. - scheduleAllPendingHistoryTreeRemovals() - await printServeReady(serveOptions) - return - } - - // Why: window and RPC startup run in parallel; registerPtyHandlers gates PTY spawns so RPC binds without racing the daemon provider swap. - const desktopRuntimeRpc = runtimeRpc - if (!desktopRuntimeRpc) { - throw new Error('runtime_rpc_unavailable') - } - const [win, runtimeRpcStartResult] = await Promise.all([ - Promise.resolve(desktopWindow ?? openMainWindow()), - shellPathReady - .then(() => desktopRuntimeRpc.start()) - .then( - () => ({ ok: true as const }), - (error: unknown) => { - recordRuntimeRpcStartFailure(error) - return { ok: false as const, error } - } - ) - ]) - if (!runtimeRpcStartResult.ok) { - void showRuntimeRpcStartupFailureDialog(win, runtimeRpcStartResult.error) - } - - const cloudAuth = getOrcaCloudAuthConfig() - if (cloudAuth.configured) { - try { - const relayService = new DesktopRelayService({ - authConfig: cloudAuth.config, - userDataPath: getProfileUserDataPath(), - appVersion: app.getVersion(), - runtimeRpc, - onStatus: (status) => { - desktopRelayStatus = status - mainWindow?.webContents.send('mobile:relayStatusChanged', status) - } - }) - desktopRelayService = relayService - runtimeRpc.setMobileRelayPairingProvider({ - createPairingRelay: (relayDeviceId) => relayService.createPairingRelay(relayDeviceId), - onDeviceRevokeQueued: (item) => relayService.onDeviceRevokeQueued(item), - onDemandStateChanged: () => relayService.demandStateChanged(), - getEndpoints: (context, params) => relayService.getEndpoints(context, params), - provisionRelay: (context, params) => relayService.provisionRelay(context, params) - }) - relayService.start() - // Why: sleeping past relay-token expiry kills the broker with no retry - // timer; resume is the moment that state becomes recoverable. - powerMonitor.on('resume', () => desktopRelayService?.ensureLive()) - } catch (error) { - console.warn( - '[relay] Desktop relay startup unavailable:', - error instanceof Error ? error.message : String(error) - ) - } - } - - // Why: macOS notification permission dialog must fire after the window is shown, else it's hidden behind the maximized window. - win.once('show', () => { - // Why: store can be null if init failed earlier; bail rather than throw inside an Electron event listener. - if (!store) { - return - } - const onboarding = store.getOnboarding() - if (onboarding.closedAt !== null) { - triggerStartupNotificationRegistration(store) - } - }) -}) - -// Why: app.exit() skips Electron quit events, so keep its log child from surviving forced exits. -process.once('exit', stopTccPromptNotice) - -app.on('before-quit', () => { - if (isQuittingForUpdate()) { - recordUpdaterLifecycle('before_quit_allowed', undefined, { - message: 'before-quit allowed for update install' - }) - } - isQuitting = true - desktopRelayService?.fenceAndCloseNow() - runtimeRpc?.setMobileRelayPairingProvider(null) - unsubscribeAgentAwakeStatusChanges?.() - unsubscribeAgentAwakeStatusChanges = null - agentAwakeService?.dispose() - agentAwakeService = null - // Why: defer PTY cleanup to will-quit so the renderer captures scrollback before PTY-exit events unmount TerminalPane (dropping its capture callbacks). - rateLimits?.stop() -}) - -// Why: will-quit fires twice — first pass preventDefaults and runs teardown; second pass exits. -let daemonDisconnectDone = false -// Why 2s: a config delete is best-effort, not durable state. -const GROK_HOOK_CLEANUP_DEADLINE_MS = 2_000 - -app.on('will-quit', (e) => { - // Why return instead of re-running teardown: the second pass is Electron re-firing after - // our own app.quit(), so every step below already ran and every durable write already - // landed. Re-entering would start a fresh unawaited write that the exit then tears down. - if (daemonDisconnectDone) { - return - } - // Why preventDefault before any work: everything below must be free to await, and a - // synchronous durable write here parks the main thread — uninterruptibly, on a stalled - // network profile mount. The teardown deadline cannot rescue that, because its timer - // lives on the same thread it would need to bound (#9447 covers the wedged-transport - // half; this covers the blocked-syscall half). - if (!quitTeardownStartGate.tryStart(e)) { - return - } - unsubscribeSystemResumeBroadcast?.() - unsubscribeSystemResumeBroadcast = null - // Why: renderer guards can still cancel before this committed phase; `log stream` must survive those vetoes. - stopTccPromptNotice() - const updateQuitInProgress = isQuittingForUpdate() - if (updateQuitInProgress) { - recordUpdaterLifecycle( - 'will_quit_cleanup_started', - { daemonTeardown: 'disconnect' }, - { message: 'will-quit cleanup for update install; daemonTeardown=disconnect' } - ) - } - // Why: before-quit can still be aborted by renderer beforeunload; only remove the Windows tray icon on the committed quit path. - destroySystemTray() - // Why: an agent still working at quit gets no terminating hook, so stats.flushAsync() closes those sessions out synchronously (only the write is deferred) — otherwise their duration is lost. - starNag?.stop() - automations?.stop() - // Why: plugin hosts are forked children; dispose sends shutdown and - // escalates to SIGKILL so they cannot outlive the app. The promise joins - // the teardown barrier below — quitting before it resolves would let - // Electron exit first and orphan the hosts. - setPluginServiceForRpc(null) - pluginKillListService = null - pluginMarketplaceService = null - pluginMarketplaceInstaller = null - const pluginHostShutdown = pluginService?.dispose() ?? Promise.resolve() - const codexBackfillRecoveryShutdown = stopCodexStateDbBackfillRecoveries() - const structuredAgentSessionShutdown = stopStructuredAgentSessionRuntime() - pluginService = null - setUnreadDockBadgeCount(0) - agentHookServer.stop() - // Why Windows only: POSIX hooks short-circuit on ORCA_PANE_KEY, while Windows must register a - // bare script path that cannot express the guard and would otherwise keep spawning after quit. - // Why bounded here: every other teardown member carries its own ceiling, and this one reaches - // $GROK_HOME -- which can be a stalled network mount, where the fs calls never settle and the - // shared 20s deadline becomes the only thing ending the quit. - const grokHookCleanup = - process.platform === 'win32' - ? settleWithinMs( - removeManagedAgentHooksAsync({ agents: ['grok'] }), - GROK_HOOK_CLEANUP_DEADLINE_MS - ).then((settled) => { - if (settled.outcome === 'timed-out') { - console.warn('[agent-hooks] Grok hook cleanup on quit timed out') - return - } - if (settled.outcome === 'failed') { - console.warn('[agent-hooks] Grok hook cleanup on quit failed:', settled.error) - return - } - // Why: removers report failures as statuses, so inspect details even after fulfillment. - for (const status of settled.value.filter((entry) => entry.detail)) { - console.warn(`[agent-hooks] ${status.agent} hook cleanup on quit: ${status.detail}`) - } - }) - : Promise.resolve() - // Why: cancels relay restart/reinstall timers and kills wsl.exe children deterministically, not via stdio-pipe teardown. - wslHookRelayManager.disposeAll() - const statsFlush = stats?.flushAsync() ?? Promise.resolve() - // Why: agent-browser daemon processes would otherwise linger after quit, holding ports and stale session state on disk. - // Why the barrier below: each session's close is its own agent-browser child taking hundreds of ms, - // so an unawaited call reaches app.quit() first and every open tab's daemon survives the quit (#16367). - // Why retire headless page owners first: it closes those helpers without a duplicate close fanout. - const browserShutdown = (async (): Promise => { - await runtime?.getOffscreenBrowserBackend()?.destroyAll?.() - await runtime?.getAgentBrowserBridge()?.destroyAllSessions() - })() - // Why (review P2-4): local SSH browser routes own loopback listeners and, on the - // system-ssh path, `ssh -N -D` children that would otherwise outlive the app. - const localSshRouteShutdown = import('./browser/local-ssh-browser-route') - .then((routes) => routes.closeAllLocalSshBrowserRoutes()) - .catch(() => {}) - browserManager.setBrowserGuestStateChangedListener(null) - const emulatorShutdown = runtime?.getEmulatorBridge()?.destroyAllSessions() ?? Promise.resolve() - // Why immediately before store.flushAsync() with no await in between: beginSshShutdown() marks every - // active SSH lease detached in memory synchronously, and that flush is what persists it. - const sshShutdown = beginSshShutdown() - killAllPty() - const watcherShutdown = shutdownWatchersOnce() - const storeFlush = store?.flushAsync() ?? Promise.resolve() - // Why: usage-cache writes are queued off the main thread, so a quit right after setEnabled or a - // scan completion would drop the final snapshot. Captured before any await; joins the barrier below. - const usageCacheFlush = Promise.all([ - claudeUsage?.flush(), - codexUsage?.flush(), - openCodeUsage?.flush() - ]).then(() => {}) - const browserClientHostShutdown = shutdownPairedRuntimeBrowserClientHosts() - const skillUploadShutdown = runtime?.disposeSkillUploadSessions() ?? Promise.resolve() - - // Why: capture pid/runtimeId synchronously (before any await) so a later teardown path can't null them out mid-chain. - const ownedPid = process.pid - const ownedRuntimeId = runtime?.getRuntimeId() - const rpcStopAndClear = runtimeRpc - ? runtimeRpc - .stop() - .then(() => awaitRuntimeFileWatcherUnsubscribes()) - .then(() => { - if (ownedRuntimeId) { - // Why: must match the path the runtime server wrote metadata to (getCanonicalUserDataPath), not late app.getPath('userData'). - clearRuntimeMetadataIfOwned(getCanonicalUserDataPath(), ownedPid, ownedRuntimeId) - } - }) - .catch((error) => { - console.error('[runtime] Failed to stop local RPC transport:', error) - }) - : Promise.resolve() - // Why: allSettled (not all) keeps fail-open — a daemon-disconnect rejection still quits instead of hanging. - // Why: telemetry flush folds in before app.quit() (bounded 2s); catch defensively so a flush failure can't cancel the quit chain. - // Why: normal quits keep the detached daemon for warm reattach, but a dead dev parent leaves the temp/dev profile ownerless. - const daemonTeardown = isDevParentShutdownRequested() ? shutdownDaemon() : disconnectDaemon() - // Why: a wedged transport (half-open post-sleep socket) can leave one - // member unsettled forever and block app.quit() until Force Quit (#9447). - // Why stats/state join here: their writes are durable but not worth hanging the app for. - // Losing at most the last debounce interval beats a quit that never completes, and the - // temp+rename swap means a write cut short by the deadline leaves the old file intact. - settleTeardownWithinDeadline([ - { name: 'daemon', promise: daemonTeardown }, - { name: 'browser', promise: browserShutdown }, - { name: 'runtime-rpc', promise: rpcStopAndClear }, - { name: 'watchers', promise: watcherShutdown }, - { name: 'emulator', promise: emulatorShutdown }, - { name: 'browser-client-hosts', promise: browserClientHostShutdown }, - { name: 'local-ssh-browser-routes', promise: localSshRouteShutdown }, - { name: 'ssh', promise: sshShutdown }, - { name: 'plugin-hosts', promise: pluginHostShutdown }, - { name: 'skill-uploads', promise: skillUploadShutdown }, - { name: 'grok-hooks', promise: grokHookCleanup }, - { name: 'codex-backfill-recovery', promise: codexBackfillRecoveryShutdown }, - { name: 'structured-agent-session', promise: structuredAgentSessionShutdown }, - { name: 'usage-cache', promise: usageCacheFlush }, - { name: 'stats', promise: statsFlush }, - { name: 'state', promise: storeFlush } - ]) - .then((pendingTeardowns) => { - if (pendingTeardowns.length > 0) { - console.warn('[shutdown] Quit teardown deadline reached', { pendingTeardowns }) - } - }) - .then(() => shutdownTelemetry()) - .then(() => shutdownObservability()) - .catch(() => { - /* swallow — telemetry must never prevent app.quit() */ - }) - .then(() => { - daemonDisconnectDone = true - app.quit() - }) -}) - -app.on('window-all-closed', () => { - // Why: serve mode / disposable offscreen browser windows must not take down runtime RPC — the policy fn keeps the app alive. - // Why: on macOS a quit-in-progress (Cmd+Q) is canceled by the renderer buffer-capture deferral; re-trigger quit so it actually exits. - if ( - shouldQuitWhenAllWindowsClosed({ - platform: process.platform, - isQuitting, - isServeMode - }) - ) { - app.quit() - } -}) +} diff --git a/src/main/ipc/cli-appimage-stale-registration.test.ts b/src/main/ipc/cli-appimage-stale-registration.test.ts new file mode 100644 index 00000000000..927c4e4e1b9 --- /dev/null +++ b/src/main/ipc/cli-appimage-stale-registration.test.ts @@ -0,0 +1,381 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { CliInstallState, CliInstallStatus } from '../../shared/cli-install-types' + +const mocks = vi.hoisted(() => ({ + getStatus: vi.fn(), + handle: vi.fn(), + hydrateShellPath: vi.fn(), + install: vi.fn(), + isAppImageRegistrationOwnedBySibling: vi.fn(), + mergePathSegments: vi.fn(), + remove: vi.fn(), + resolveAppImageCacheKey: vi.fn(), + resolveAppImageRuntimeIdentity: vi.fn() +})) + +vi.mock('electron', () => ({ ipcMain: { handle: mocks.handle } })) + +vi.mock('../cli/cli-installer', () => ({ + CliInstaller: class { + getStatus = mocks.getStatus + install = mocks.install + isAppImageRegistrationOwnedBySibling = mocks.isAppImageRegistrationOwnedBySibling + remove = mocks.remove + } +})) + +vi.mock('../appimage-runtime-identity', () => ({ + resolveAppImageRuntimeIdentity: mocks.resolveAppImageRuntimeIdentity +})) + +vi.mock('../cli/appimage-extracted-root', () => ({ + resolveAppImageCacheKey: mocks.resolveAppImageCacheKey +})) + +vi.mock('../cli/wsl-cli-installer', () => ({ + WslCliInstaller: class { + getStatus = mocks.getStatus + install = mocks.install + remove = mocks.remove + } +})) + +vi.mock('../cli/wsl-cli-registration-registry', () => ({ + recordWslCliRegistrationInstalled: vi.fn(), + recordWslCliRegistrationRemoved: vi.fn() +})) + +vi.mock('../cli/wsl-cli-registration-operation', () => ({ + runSerializedWslCliRegistrationOperation: vi.fn() +})) + +vi.mock('../persistence', () => ({ getCanonicalUserDataPath: vi.fn() })) + +vi.mock('../startup/hydrate-shell-path', () => ({ + hydrateShellPath: mocks.hydrateShellPath, + mergePathSegments: mocks.mergePathSegments +})) + +vi.mock('../wsl', () => ({ getDefaultWslDistro: vi.fn() })) + +import { registerCliHandlers } from './cli' + +const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') + +function status( + state: CliInstallState, + launcherPath = '/cache/current/resources/bin/orca-ide' +): CliInstallStatus { + return { + platform: 'linux', + commandName: 'orca-ide', + commandPath: '/home/me/.local/bin/orca-ide', + pathDirectory: '/home/me/.local/bin', + pathConfigured: true, + launcherPath, + installMethod: 'symlink', + supported: true, + state, + currentTarget: state === 'not_installed' ? null : '/cache/old/resources/bin/orca-ide', + unsupportedReason: null, + detail: null + } +} + +function installStatusHandler(): () => Promise { + registerCliHandlers() + return cliHandler('cli:getInstallStatus') +} + +function cliHandler(channelName: string): () => Promise { + const call = mocks.handle.mock.calls.find(([channel]) => channel === channelName) + expect(call).toBeTruthy() + return call![1] +} + +beforeEach(() => { + mocks.getStatus.mockReset() + mocks.handle.mockReset() + mocks.hydrateShellPath.mockReset().mockResolvedValue({ ok: false }) + mocks.install.mockReset() + mocks.isAppImageRegistrationOwnedBySibling.mockReset().mockReturnValue(false) + mocks.mergePathSegments.mockReset() + mocks.remove.mockReset() + mocks.resolveAppImageCacheKey.mockReset().mockReturnValue('generation-1') + mocks.resolveAppImageRuntimeIdentity.mockReset().mockImplementation(() => + process.platform === 'linux' + ? { + appImagePath: '/opt/Orca.AppImage' + } + : null + ) + Object.defineProperty(process, 'platform', { configurable: true, value: 'linux' }) + vi.stubEnv('APPIMAGE', '/opt/Orca.AppImage') +}) + +afterEach(() => { + vi.unstubAllEnvs() + if (originalPlatform) { + Object.defineProperty(process, 'platform', originalPlatform) + } + vi.restoreAllMocks() +}) + +describe('AppImage CLI registration startup repair', () => { + it('repairs a managed stale registration and returns the installed status', async () => { + const stale = status('stale') + const installed = status('installed') + mocks.getStatus.mockResolvedValue(stale) + mocks.install.mockResolvedValue(installed) + + await expect(installStatusHandler()()).resolves.toBe(installed) + expect(mocks.install).toHaveBeenCalledOnce() + }) + + it('keeps the stale status when automatic repair fails', async () => { + const stale = status('stale') + mocks.getStatus.mockResolvedValue(stale) + mocks.install.mockRejectedValue(new Error('read-only filesystem')) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + await expect(installStatusHandler()()).resolves.toBe(stale) + expect(warn).toHaveBeenCalledWith( + '[cli] Failed to repair stale AppImage registration:', + 'read-only filesystem' + ) + }) + + it('retries a failed automatic repair after the cooldown', async () => { + const stale = status('stale') + const installed = status('installed') + let now = 1_000 + mocks.getStatus.mockResolvedValue(stale) + mocks.install + .mockRejectedValueOnce(new Error('read-only filesystem')) + .mockResolvedValueOnce(installed) + vi.spyOn(Date, 'now').mockImplementation(() => now) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const handler = installStatusHandler() + + await expect(handler()).resolves.toBe(stale) + await expect(handler()).resolves.toBe(stale) + expect(mocks.install).toHaveBeenCalledOnce() + expect(warn).toHaveBeenCalledOnce() + + now += 30_000 + await expect(handler()).resolves.toBe(installed) + expect(mocks.install).toHaveBeenCalledTimes(2) + }) + + it('shares one automatic repair across concurrent status polls', async () => { + const stale = status('stale') + const installed = status('installed') + let finishRepair: (result: CliInstallStatus) => void = () => {} + const repair = new Promise((resolve) => { + finishRepair = resolve + }) + mocks.getStatus.mockResolvedValue(stale) + mocks.install.mockReturnValue(repair) + const handler = installStatusHandler() + + const first = handler() + const second = handler() + await vi.waitFor(() => expect(mocks.install).toHaveBeenCalledOnce()) + finishRepair(installed) + + await expect(Promise.all([first, second])).resolves.toEqual([installed, installed]) + }) + + it('repairs a later AppImage generation after an earlier repair succeeds', async () => { + const firstStale = status('stale', '/cache/launcher/orca-ide') + const firstInstalled = status('installed', '/cache/launcher/orca-ide') + const nextStale = status('stale', '/cache/launcher/orca-ide') + const nextInstalled = status('installed', '/cache/launcher/orca-ide') + mocks.getStatus + .mockResolvedValueOnce(firstStale) + .mockResolvedValueOnce(firstStale) + .mockResolvedValueOnce(nextStale) + .mockResolvedValueOnce(nextStale) + mocks.install.mockResolvedValueOnce(firstInstalled).mockResolvedValueOnce(nextInstalled) + mocks.resolveAppImageCacheKey + .mockReturnValueOnce('generation-1') + .mockReturnValueOnce('generation-1') + .mockReturnValueOnce('generation-2') + .mockReturnValueOnce('generation-2') + const handler = installStatusHandler() + + await expect(handler()).resolves.toBe(firstInstalled) + await expect(handler()).resolves.toBe(nextInstalled) + expect(mocks.install).toHaveBeenCalledTimes(2) + }) + + it('waits for the cooldown before repairing a newer AppImage generation', async () => { + const firstStale = status('stale', '/cache/launcher/orca-ide') + const nextStale = status('stale', '/cache/launcher/orca-ide') + const nextInstalled = status('installed', '/cache/launcher/orca-ide') + let now = 1_000 + mocks.getStatus + .mockResolvedValueOnce(firstStale) + .mockResolvedValueOnce(firstStale) + .mockResolvedValueOnce(nextStale) + .mockResolvedValueOnce(nextStale) + .mockResolvedValueOnce(nextStale) + mocks.install + .mockRejectedValueOnce(new Error('temporary failure')) + .mockResolvedValueOnce(nextInstalled) + mocks.resolveAppImageCacheKey + .mockReturnValueOnce('generation-1') + .mockReturnValueOnce('generation-1') + .mockReturnValueOnce('generation-2') + .mockReturnValueOnce('generation-2') + .mockReturnValueOnce('generation-2') + vi.spyOn(Date, 'now').mockImplementation(() => now) + vi.spyOn(console, 'warn').mockImplementation(() => {}) + const handler = installStatusHandler() + + await expect(handler()).resolves.toBe(firstStale) + await expect(handler()).resolves.toBe(nextStale) + expect(mocks.install).toHaveBeenCalledOnce() + + now += 30_000 + await expect(handler()).resolves.toBe(nextInstalled) + expect(mocks.install).toHaveBeenCalledTimes(2) + }) + + it('keeps the explicit install action retryable after automatic repair fails', async () => { + const stale = status('stale') + const installed = status('installed') + mocks.getStatus.mockResolvedValue(stale) + mocks.install + .mockRejectedValueOnce(new Error('temporary failure')) + .mockResolvedValueOnce(installed) + vi.spyOn(console, 'warn').mockImplementation(() => {}) + registerCliHandlers() + + await expect(cliHandler('cli:getInstallStatus')()).resolves.toBe(stale) + await expect(cliHandler('cli:install')()).resolves.toBe(installed) + expect(mocks.install).toHaveBeenCalledTimes(2) + }) + + it('serializes concurrent explicit installs', async () => { + const installed = status('installed') + let finishFirstInstall: (result: CliInstallStatus) => void = () => {} + const firstInstall = new Promise((resolve) => { + finishFirstInstall = resolve + }) + mocks.install.mockReturnValueOnce(firstInstall).mockResolvedValueOnce(installed) + registerCliHandlers() + const handler = cliHandler('cli:install') + + const first = handler() + const second = handler() + await vi.waitFor(() => expect(mocks.install).toHaveBeenCalledOnce()) + + finishFirstInstall(installed) + await expect(Promise.all([first, second])).resolves.toEqual([installed, installed]) + expect(mocks.install).toHaveBeenCalledTimes(2) + }) + + it('does not undo a queued removal with a stale automatic repair', async () => { + const stale = status('stale') + const notInstalled = status('not_installed') + let finishRemove: (result: CliInstallStatus) => void = () => {} + const removal = new Promise((resolve) => { + finishRemove = resolve + }) + mocks.getStatus.mockResolvedValueOnce(stale).mockResolvedValueOnce(notInstalled) + mocks.remove.mockReturnValue(removal) + registerCliHandlers() + + const remove = cliHandler('cli:remove')() + await vi.waitFor(() => expect(mocks.remove).toHaveBeenCalledOnce()) + const poll = cliHandler('cli:getInstallStatus')() + finishRemove(notInstalled) + + await expect(remove).resolves.toBe(notInstalled) + await expect(poll).resolves.toBe(notInstalled) + expect(mocks.install).not.toHaveBeenCalled() + }) + + it('keys a queued repair cooldown to the generation it rechecks', async () => { + const stale = status('stale') + const notInstalled = status('not_installed') + let finishRemove: (result: CliInstallStatus) => void = () => {} + const removal = new Promise((resolve) => { + finishRemove = resolve + }) + mocks.getStatus.mockResolvedValue(stale) + mocks.remove.mockReturnValue(removal) + mocks.install.mockRejectedValue(new Error('temporary failure')) + mocks.resolveAppImageCacheKey + .mockReturnValueOnce('generation-1') + .mockReturnValue('generation-2') + vi.spyOn(console, 'warn').mockImplementation(() => {}) + registerCliHandlers() + + const remove = cliHandler('cli:remove')() + await vi.waitFor(() => expect(mocks.remove).toHaveBeenCalledOnce()) + const oldGenerationPoll = cliHandler('cli:getInstallStatus')() + finishRemove(notInstalled) + + await expect(remove).resolves.toBe(notInstalled) + await expect(oldGenerationPoll).resolves.toBe(stale) + await expect(cliHandler('cli:getInstallStatus')()).resolves.toBe(stale) + await expect(cliHandler('cli:getInstallStatus')()).resolves.toBe(stale) + expect(mocks.install).toHaveBeenCalledOnce() + }) + + it.each(['conflict', 'not_installed'] as const)( + 'does not mutate a %s registration', + async (state) => { + const current = status(state) + mocks.getStatus.mockResolvedValue(current) + + await expect(installStatusHandler()()).resolves.toBe(current) + expect(mocks.install).not.toHaveBeenCalled() + } + ) + + it('does not mutate a stale non-AppImage registration', async () => { + const stale = status('stale') + mocks.getStatus.mockResolvedValue(stale) + mocks.resolveAppImageRuntimeIdentity.mockReturnValue(null) + + await expect(installStatusHandler()()).resolves.toBe(stale) + expect(mocks.install).not.toHaveBeenCalled() + }) + + it('does not claim a sibling AppImage registration during a status poll', async () => { + const stale = { + ...status('stale'), + currentTarget: '/cache/current/resources/bin/orca-ide' + } + mocks.getStatus.mockResolvedValue(stale) + mocks.isAppImageRegistrationOwnedBySibling.mockReturnValue(true) + + await expect(installStatusHandler()()).resolves.toBe(stale) + expect(mocks.install).not.toHaveBeenCalled() + }) + + it('migrates a legacy AppImage target even when a sibling owns the stable endpoint', async () => { + const stale = status('stale') + const installed = status('installed') + mocks.getStatus.mockResolvedValue(stale) + mocks.install.mockResolvedValue(installed) + mocks.isAppImageRegistrationOwnedBySibling.mockImplementation( + (current: CliInstallStatus) => current.currentTarget === current.launcherPath + ) + + await expect(installStatusHandler()()).resolves.toBe(installed) + expect(mocks.install).toHaveBeenCalledOnce() + }) + + it('does not mutate a stale registration off Linux', async () => { + const stale = status('stale') + mocks.getStatus.mockResolvedValue(stale) + Object.defineProperty(process, 'platform', { configurable: true, value: 'darwin' }) + + await expect(installStatusHandler()()).resolves.toBe(stale) + expect(mocks.install).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/ipc/cli.ts b/src/main/ipc/cli.ts index 659c69d9c02..050a00f05cf 100644 --- a/src/main/ipc/cli.ts +++ b/src/main/ipc/cli.ts @@ -1,6 +1,8 @@ import { ipcMain } from 'electron' import type { CliInstallStatus } from '../../shared/cli-install-types' +import { resolveAppImageCacheKey } from '../cli/appimage-extracted-root' import { CliInstaller } from '../cli/cli-installer' +import { runKeyedSerializedOperation } from '../cli/keyed-promise-queue' import { recordWslCliRegistrationInstalled, recordWslCliRegistrationRemoved @@ -10,6 +12,31 @@ import { runSerializedWslCliRegistrationOperation } from '../cli/wsl-cli-registr import { getCanonicalUserDataPath } from '../persistence' import { hydrateShellPath, mergePathSegments } from '../startup/hydrate-shell-path' import { getDefaultWslDistro } from '../wsl' +import { resolveAppImageRuntimeIdentity } from '../appimage-runtime-identity' + +const APPIMAGE_REPAIR_RETRY_MS = 30_000 +const localCliRegistrationQueues = new Map>() + +function runLocalCliRegistrationOperation(operation: () => Promise): Promise { + return runKeyedSerializedOperation(localCliRegistrationQueues, 'local', operation) +} + +function resolveStaleAppImageRepairKey(status: CliInstallStatus): string | null { + if (status.state !== 'stale') { + return null + } + const runtimeIdentity = resolveAppImageRuntimeIdentity() + if (!runtimeIdentity) { + return null + } + const cacheKey = resolveAppImageCacheKey(runtimeIdentity.appImagePath) + if (!cacheKey) { + return null + } + return [status.commandPath, status.launcherPath, runtimeIdentity.appImagePath, cacheKey].join( + '\0' + ) +} function normalizeWslCliDistro(args?: { distro?: string | null }): string | undefined { return args?.distro?.trim() || undefined @@ -57,19 +84,57 @@ async function hydrateLocalShellPathForCli(force = false): Promise { } export function registerCliHandlers(): void { + let staleAppImageRepairAttempt: { + promise: Promise + retryAfter: number + } | null = null + ipcMain.handle('cli:getInstallStatus', async (): Promise => { await hydrateLocalShellPathForCli() - return new CliInstaller().getStatus() + const installer = new CliInstaller() + const status = await installer.getStatus() + // Why: an AppImage update replaces the outer file while the managed symlink still targets the prior extracted payload. + const repairKey = resolveStaleAppImageRepairKey(status) + if (!repairKey || installer.isAppImageRegistrationOwnedBySibling(status)) { + return status + } + + if (!staleAppImageRepairAttempt || Date.now() >= staleAppImageRepairAttempt.retryAfter) { + const promise = runLocalCliRegistrationOperation(async () => { + const currentInstaller = new CliInstaller() + const currentStatus = await currentInstaller.getStatus() + return resolveStaleAppImageRepairKey(currentStatus) === repairKey && + !currentInstaller.isAppImageRegistrationOwnedBySibling(currentStatus) + ? currentInstaller.install() + : currentStatus + }).catch((error) => { + console.warn( + '[cli] Failed to repair stale AppImage registration:', + error instanceof Error ? error.message : String(error) + ) + return null + }) + staleAppImageRepairAttempt = { promise, retryAfter: Number.POSITIVE_INFINITY } + void promise.then((result) => { + if (staleAppImageRepairAttempt?.promise !== promise) { + return + } + staleAppImageRepairAttempt = result + ? null + : { ...staleAppImageRepairAttempt, retryAfter: Date.now() + APPIMAGE_REPAIR_RETRY_MS } + }) + } + return (await staleAppImageRepairAttempt.promise) ?? status }) ipcMain.handle('cli:install', async (): Promise => { await hydrateLocalShellPathForCli(true) - return new CliInstaller().install() + return runLocalCliRegistrationOperation(() => new CliInstaller().install()) }) ipcMain.handle('cli:remove', async (): Promise => { await hydrateLocalShellPathForCli() - return new CliInstaller().remove() + return runLocalCliRegistrationOperation(() => new CliInstaller().remove()) }) ipcMain.handle( diff --git a/src/main/ipc/created-worktree-root-prune.test.ts b/src/main/ipc/created-worktree-root-prune.test.ts index 9114e34a07c..30808d7d79e 100644 --- a/src/main/ipc/created-worktree-root-prune.test.ts +++ b/src/main/ipc/created-worktree-root-prune.test.ts @@ -2,7 +2,7 @@ import type * as NodeFsPromises from 'node:fs/promises' import { resolve } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type * as RepoWorktrees from '../repo-worktrees' -import { listRepoWorktrees } from '../repo-worktrees' +import { listRepoWorktreeGraph } from '../repo-worktrees' import type { Store } from '../persistence' import type { Repo } from '../../shared/repo-types' import { @@ -22,7 +22,7 @@ vi.mock('node:fs/promises', async () => { vi.mock('../repo-worktrees', async () => { const actual = await vi.importActual('../repo-worktrees') - return { ...actual, listRepoWorktrees: vi.fn() } + return { ...actual, listRepoWorktreeGraph: vi.fn() } }) const repo: Repo = { @@ -56,10 +56,10 @@ describe('recovered worktree root pruning', () => { beforeEach(() => { invalidateAuthorizedRootsCache() __resetCreatedWorktreeRootsForTests() - vi.mocked(listRepoWorktrees).mockReset() + vi.mocked(listRepoWorktreeGraph).mockReset() // The #16520 outage itself: `listWorktrees` softens every Git failure to `[]`, so the rebuild // reports success with the recovered row missing and the probe is the only remaining evidence. - vi.mocked(listRepoWorktrees).mockResolvedValue([]) + vi.mocked(listRepoWorktreeGraph).mockResolvedValue([]) statMock.mockReset() statMock.mockResolvedValue({}) }) diff --git a/src/main/ipc/filesystem-auth.test.ts b/src/main/ipc/filesystem-auth.test.ts index 41c2c44a634..fa82b7a652c 100644 --- a/src/main/ipc/filesystem-auth.test.ts +++ b/src/main/ipc/filesystem-auth.test.ts @@ -5,7 +5,7 @@ import { join, resolve } from 'node:path' import { beforeEach, describe, expect, it, vi } from 'vitest' import type { Store } from '../persistence' import type * as RepoWorktrees from '../repo-worktrees' -import { listRepoWorktrees } from '../repo-worktrees' +import { listRepoWorktreeGraph } from '../repo-worktrees' import type { FolderWorkspace } from '../../shared/folder-workspace-types' import type { ProjectGroup } from '../../shared/project-group-types' import type { Repo } from '../../shared/repo-types' @@ -29,7 +29,7 @@ vi.mock('../repo-worktrees', async () => { const actual = await vi.importActual('../repo-worktrees') return { ...actual, - listRepoWorktrees: vi.fn() + listRepoWorktreeGraph: vi.fn() } }) @@ -98,7 +98,7 @@ describe('filesystem auth worktree roots', () => { beforeEach(() => { invalidateAuthorizedRootsCache() __resetCreatedWorktreeRootsForTests() - vi.mocked(listRepoWorktrees).mockReset() + vi.mocked(listRepoWorktreeGraph).mockReset() }) it('rebuilds the authorized roots cache for large worktree lists', async () => { @@ -112,7 +112,7 @@ describe('filesystem auth worktree roots', () => { isMainWorktree: false }) ) - vi.mocked(listRepoWorktrees).mockResolvedValue(worktrees) + vi.mocked(listRepoWorktreeGraph).mockResolvedValue(worktrees) const store = makeStore() await rebuildAuthorizedRootsCache(store) @@ -121,7 +121,7 @@ describe('filesystem auth worktree roots', () => { await expect(resolveRegisteredWorktreePath(lastWorktreePath, store)).resolves.toBe( resolve(lastWorktreePath) ) - expect(listRepoWorktrees).toHaveBeenCalledTimes(1) + expect(listRepoWorktreeGraph).toHaveBeenCalledTimes(1) }) it("keeps a repo's roots when its listing fails mid-rebuild", async () => { @@ -129,7 +129,7 @@ describe('filesystem auth worktree roots', () => { // a worktree a create just recovered without a listing (#16520). const store = makeStore() registerCreatedWorktreeRoot(store, repo.id, '/linked/recovered') - vi.mocked(listRepoWorktrees).mockRejectedValue(new Error('git worktree list failed.')) + vi.mocked(listRepoWorktreeGraph).mockRejectedValue(new Error('git worktree list failed.')) await rebuildAuthorizedRootsCache(store) @@ -146,7 +146,7 @@ describe('filesystem auth worktree roots', () => { await mkdir(recovered) const store = makeStore() registerCreatedWorktreeRoot(store, repo.id, recovered) - vi.mocked(listRepoWorktrees).mockResolvedValue([]) + vi.mocked(listRepoWorktreeGraph).mockResolvedValue([]) await rebuildAuthorizedRootsCache(store) @@ -160,7 +160,7 @@ describe('filesystem auth worktree roots', () => { await mkdir(recovered) const store = makeStore() // Register mid-listing: the rebuild's own result was computed before this worktree existed. - vi.mocked(listRepoWorktrees).mockImplementation(async () => { + vi.mocked(listRepoWorktreeGraph).mockImplementation(async () => { registerCreatedWorktreeRoot(store, repo.id, recovered) return [] }) @@ -174,7 +174,7 @@ describe('filesystem auth worktree roots', () => { it('retires a recovered root once the listing can see it again', async () => { const store = makeStore() registerCreatedWorktreeRoot(store, repo.id, '/linked/feature') - vi.mocked(listRepoWorktrees).mockResolvedValue([ + vi.mocked(listRepoWorktreeGraph).mockResolvedValue([ { path: '/linked/feature', head: '', @@ -189,7 +189,7 @@ describe('filesystem auth worktree roots', () => { await expect(resolveRegisteredWorktreePath('/linked/feature', store)).resolves.toBe( resolve('/linked/feature') ) - vi.mocked(listRepoWorktrees).mockResolvedValue([]) + vi.mocked(listRepoWorktreeGraph).mockResolvedValue([]) await rebuildAuthorizedRootsCache(store) await expect(resolveRegisteredWorktreePath('/linked/feature', store)).rejects.toThrow( @@ -205,7 +205,7 @@ describe('filesystem auth worktree roots', () => { })) let active = 0 let maxActive = 0 - vi.mocked(listRepoWorktrees).mockImplementation(async () => { + vi.mocked(listRepoWorktreeGraph).mockImplementation(async () => { active += 1 maxActive = Math.max(maxActive, active) await new Promise((resolve) => setTimeout(resolve, 1)) @@ -215,7 +215,7 @@ describe('filesystem auth worktree roots', () => { await rebuildAuthorizedRootsCache(makeStore(repos)) - expect(listRepoWorktrees).toHaveBeenCalledTimes(repos.length) + expect(listRepoWorktreeGraph).toHaveBeenCalledTimes(repos.length) expect(maxActive).toBeLessThanOrEqual(8) }) }) @@ -392,7 +392,7 @@ describe('filesystem-auth path containment', () => { vi.resetModules() vi.doMock('../repo-worktrees', () => ({ isRepoRoot: vi.fn(), - listRepoWorktrees: vi.fn() + listRepoWorktreeGraph: vi.fn() })) vi.doMock('path', async () => { const path = await vi.importActual('node:path') diff --git a/src/main/ipc/filesystem-conflict-operation-routing.test.ts b/src/main/ipc/filesystem-conflict-operation-routing.test.ts new file mode 100644 index 00000000000..e0db3f23ad8 --- /dev/null +++ b/src/main/ipc/filesystem-conflict-operation-routing.test.ts @@ -0,0 +1,85 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + handlers, + store, + REPO_PATH, + WORKTREE_FEATURE_PATH, + detectConflictOperationMock, + resetFilesystemIpcMocks +} from './filesystem-test-harness' + +const getLocalGitOptionsForRegisteredWorktreeMock = vi.hoisted(() => vi.fn()) + +vi.mock('electron', async () => (await import('./filesystem-test-harness')).electronMock) +vi.mock('fs/promises', async () => (await import('./filesystem-test-harness')).fsPromisesMock) +vi.mock( + '../wsl-unc-delete', + async () => (await import('./filesystem-test-harness')).wslUncDeleteMock +) +vi.mock( + '../crash-reporting/crash-breadcrumb-store', + async () => (await import('./filesystem-test-harness')).crashBreadcrumbMock +) +vi.mock( + '../local-downloaded-folder-promotion', + async () => (await import('./filesystem-test-harness')).folderPromotionMock +) +vi.mock( + '../git/status', + async () => (await import('./filesystem-test-harness')).gitStatusModuleMock +) +vi.mock( + '../git/check-ignored-paths', + async () => (await import('./filesystem-test-harness')).gitIgnoredPathsMock +) +vi.mock('../git/worktree', async () => (await import('./filesystem-test-harness')).gitWorktreeMock) +vi.mock( + '../providers/ssh-filesystem-dispatch', + async () => (await import('./filesystem-test-harness')).sshFilesystemDispatchMock +) +vi.mock( + '../providers/ssh-git-dispatch', + async () => (await import('./filesystem-test-harness')).sshGitDispatchMock +) +vi.mock('./local-worktree-runtime-options', () => ({ + getLocalGitOptionsForRegisteredWorktree: getLocalGitOptionsForRegisteredWorktreeMock, + getLocalGitOptionsForRepo: vi.fn(() => ({})), + getLocalRepoForRegisteredWorktree: vi.fn(() => undefined) +})) + +import { registerFilesystemHandlers } from './filesystem' +import { + registerWorktreeRootsForRepo, + invalidateAuthorizedRootsCache +} from './registered-worktree-roots-cache' + +// Why: `git:conflictOperation` reads the worktree's `.git` pointer directly, so it has to run in +// the same host namespace as that worktree's git — a WSL project answers in the guest namespace. +describe('git:conflictOperation local routing', () => { + beforeEach(() => { + resetFilesystemIpcMocks() + invalidateAuthorizedRootsCache() + getLocalGitOptionsForRegisteredWorktreeMock.mockReset() + getLocalGitOptionsForRegisteredWorktreeMock.mockReturnValue({ wslDistro: 'Ubuntu' }) + detectConflictOperationMock.mockResolvedValue('merge') + }) + + it("probes with the registered worktree's local git options", async () => { + registerWorktreeRootsForRepo(store as never, 'repo-1', [REPO_PATH, WORKTREE_FEATURE_PATH]) + + registerFilesystemHandlers(store as never) + + await expect( + handlers.get('git:conflictOperation')!(null, { worktreePath: WORKTREE_FEATURE_PATH }) + ).resolves.toBe('merge') + + expect(getLocalGitOptionsForRegisteredWorktreeMock).toHaveBeenCalledWith( + store, + WORKTREE_FEATURE_PATH, + WORKTREE_FEATURE_PATH + ) + expect(detectConflictOperationMock).toHaveBeenCalledWith(WORKTREE_FEATURE_PATH, { + wslDistro: 'Ubuntu' + }) + }) +}) diff --git a/src/main/ipc/filesystem-path-containment.ts b/src/main/ipc/filesystem-path-containment.ts index b236c90a94e..32d1c00f0e1 100644 --- a/src/main/ipc/filesystem-path-containment.ts +++ b/src/main/ipc/filesystem-path-containment.ts @@ -71,3 +71,11 @@ export function validateGitRelativeFilePath(worktreePath: string, filePath: stri return normalizedRelativePath } + +export function validateFullGitObjectId(value: string, label: string): string { + const pattern = /^(?:[0-9a-fA-F]{40}|[0-9a-fA-F]{64})$/ + if (!pattern.test(value)) { + throw new Error(`${label} must be a full git object id`) + } + return value +} diff --git a/src/main/ipc/filesystem-test-harness.ts b/src/main/ipc/filesystem-test-harness.ts index 78f85f1dbcf..47efa88d6cd 100644 --- a/src/main/ipc/filesystem-test-harness.ts +++ b/src/main/ipc/filesystem-test-harness.ts @@ -28,6 +28,7 @@ export const realpathMock: IpcMock = vi.fn() export const lstatMock: IpcMock = vi.fn() export const commitChangesMock: IpcMock = vi.fn() export const getStatusMock: IpcMock = vi.fn() +export const detectConflictOperationMock: IpcMock = vi.fn() export const abortMergeMock: IpcMock = vi.fn() export const abortRebaseMock: IpcMock = vi.fn() export const getDiffMock: IpcMock = vi.fn() @@ -88,6 +89,7 @@ export const folderPromotionMock = { export const gitStatusModuleMock = { commitChanges: commitChangesMock, getStatus: getStatusMock, + detectConflictOperation: detectConflictOperationMock, abortMerge: abortMergeMock, abortRebase: abortRebaseMock, getDiff: getDiffMock, @@ -105,6 +107,7 @@ export const gitStatusModuleMock = { export const gitIgnoredPathsMock = { checkIgnoredPaths: checkIgnoredPathsMock } export const gitWorktreeMock = { + listWorktreeGraph: listWorktreesMock, listWorktrees: listWorktreesMock, listWorktreesStrict: listWorktreesMock } diff --git a/src/main/ipc/filesystem.ts b/src/main/ipc/filesystem.ts index 41b8b8e8da7..e517a834d5e 100644 --- a/src/main/ipc/filesystem.ts +++ b/src/main/ipc/filesystem.ts @@ -1,2441 +1,48 @@ -/* eslint-disable max-lines */ -import { BrowserWindow, dialog, ipcMain, shell } from 'electron' -import { readdir, readFile, writeFile, stat, lstat, open, rename, rm } from 'node:fs/promises' -import type { FileHandle } from 'node:fs/promises' -import { randomUUID } from 'node:crypto' -import { dirname, extname, join, resolve } from 'node:path' -import type { ChildProcess } from 'node:child_process' -import { awaitWindowsHostGitEnvironmentReady, gitExecFileAsync, wslAwareSpawn } from '../git/runner' -import { parseWslPath, toWindowsWslPath } from '../wsl' -import { tryDeleteWslUncPath } from '../wsl-unc-delete' import type { Store } from '../persistence' -import type { SearchOptions, SearchResult } from '../../shared/code-search-types' -import type { DirEntry, MarkdownDocument } from '../../shared/filesystem-entry-types' -import type { - GitBranchCompareResult, - GitCommitCompareResult, - GitDiffResult -} from '../../shared/git-diff-compare-types' -import type { GitForkSyncExpectedUpstream, GitForkSyncResult } from '../../shared/git-fork-sync' -import type { - GitConflictOperation, - GitStagingArea, - GitStatusResult, - GitUpstreamStatus -} from '../../shared/git-status-types' -import type { GlobalSettings } from '../../shared/global-settings-types' -import type { Repo } from '../../shared/repo-types' -import type { TuiAgent } from '../../shared/tui-agent' -import type { GitPushTarget } from '../../shared/worktree/types' -import type { GitHistoryOptions, GitHistoryResult } from '../../shared/git-history' -import type { GitAdmissionTier } from '../git/command-runner/git-exec-options' -import type { SshMutationExpectation } from '../../shared/ssh-types' -import { sortDirEntries } from '../../shared/file-name-sort' -import { assertSshMutationExpectation } from '../ssh/ssh-connection-generation' -import { - buildRgArgs, - createAccumulator, - DEFAULT_SEARCH_MAX_RESULTS, - finalize, - ingestRgJsonLine, - SEARCH_TIMEOUT_MS -} from '../../shared/text-search' -import { - getStatus, - getSubmoduleStatus, - abortMerge, - abortRebase, - detectConflictOperation, - getDiff, - commitChanges, - stageFile, - unstageFile, - bulkStageFiles, - bulkUnstageFiles, - bulkDiscardChanges, - discardChanges, - getStagedCommitContext, - getBranchCompare, - getBranchDiff, - getCommitCompare, - getCommitDiff -} from '../git/status' -import { getHistory } from '../git/history' -import { - cancelGenerateCommitMessageLocal, - cancelGeneratePullRequestFieldsLocal, - discoverCommitMessageModelsLocal, - discoverCommitMessageModelsRemote, - generateCommitMessageFromContext, - generatePullRequestFieldsFromContext, - resolveCommitMessageSettings, - type DiscoverCommitMessageModelsResult, - type CommitMessageGenerationTarget, - type GenerateCommitMessageResult, - type GeneratePullRequestFieldsResult -} from '../text-generation/commit-message-text-generation' -import { getPullRequestDraftContext } from '../text-generation/pull-request-context' -import { getUpstreamStatus } from '../git/upstream' -import { gitFastForward, gitFetch, gitPull, gitPullRebaseFromBase, gitPush } from '../git/remote' -import { gitSyncForkDefaultBranch } from '../git/fork-sync' -import { validateGitForkSyncExpectedUpstream } from '../../shared/git-fork-sync' -import { checkIgnoredPaths } from '../git/check-ignored-paths' -import { - appendFolderToGitignore, - findKnownHugeFolderPathsToIgnore -} from '../git/huge-folder-ignore' -import { assertGitPushTargetShape } from '../../shared/git-push-target-validation' -import { getCommitMessageModelDiscoveryHostKey } from '../../shared/commit-message-host-key' -import type { HostedReviewProvider } from '../../shared/hosted-review' -import type { ResolvedSourceControlAiGenerationParams } from '../../shared/source-control-ai' -import { withLinkedIssueDraftContext } from '../../shared/source-control-ai-action-variables' -import { validateGitPushTarget } from '../git/push-target-validation' -import { getRemoteCommitUrl, getRemoteFileUrl } from '../git/repo' -import { resolveAuthorizedPath, authorizeExternalPath } from './filesystem-auth' -import { resolveRegisteredWorktreePath } from './registered-worktree-roots-cache' -import { validateGitRelativeFilePath, isENOENT } from './filesystem-path-containment' -import { listQuickOpenFiles } from './filesystem-list-files' -import { registerFilesystemMutationHandlers } from './filesystem-mutations' -import { searchWithGitGrep } from './filesystem-search-git' -import { - getLocalGitOptionsForRegisteredWorktree, - getLocalGitOptionsForRepo, - getLocalRepoForRegisteredWorktree -} from './local-worktree-runtime-options' -import { - resolveSourceControlAiLinkedIssue, - resolveSourceControlAiLinkedIssueMeta -} from './source-control-ai-linked-issue' -import { listMarkdownDocuments, markdownDocumentsFromRelativePaths } from './markdown-documents' -import { checkRgAvailable } from './rg-availability' -import { - absorbPendingRipgrepSpawnError, - isRipgrepUnavailableExit, - killSpawnedRipgrepProcess -} from '../../shared/ripgrep-process-availability' -import { - getSshFilesystemProvider, - requireSshFilesystemProvider -} from '../providers/ssh-filesystem-dispatch' -import { - getSshGitProvider, - SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE -} from '../providers/ssh-git-dispatch' -import { resolveHostedReviewBodyForGeneration } from '../source-control/pull-request-template' -import { loadPullRequestLinkedIssue } from '../source-control/pull-request-linked-issue' -import { - prepareLocalCommitMessageAgentEnv, - type CommitMessageAgentRuntimeTarget, - type CommitMessageAgentEnvironmentResolvers -} from '../text-generation/commit-message-agent-environment' -import { listRepoWorktrees } from '../repo-worktrees' -import { recordCrashBreadcrumb } from '../crash-reporting/crash-breadcrumb-store' -import { buildReadDirErrorBreadcrumb, type ReadDirThrowSite } from './readdir-error-diagnostics' -import { splitWorktreeId } from '../../shared/worktree/id' -import { getRuntimePathBasename } from '../../shared/cross-platform-path' -import type { LocalProjectWorktreeGitOptions } from '../project-runtime-git-options' +import type { CommitMessageAgentEnvironmentResolvers } from '../text-generation/commit-message-agent-environment' import { registerLocalLogTailHandlers } from './local-log-tail' -import { localLogFileIdentity } from '../ai-vault/local-log-tail-reader' -import { sanitizeLocalDownloadFilename } from '../local-download-filename' -import { registerFilesystemDownloadFolderHandlers } from './filesystem-download-folder' -import { getWorktreeSharedLinkPaths } from '../git/worktree-shared-directories' import { createSenderScopedRequestCancellations } from './sender-scoped-request-cancellation' -import { QuickOpenPathRanker } from '../../shared/quick-open-path-search' import { - applyGitStatusUpstreamRefWatchRequest, - type GitStatusUpstreamRefWatchRequest -} from './git-status-upstream-ref-watch-request' - -// Why: Monaco degrades features on large files like VS Code, so a 5MB block would needlessly lock out ordinary JSON/log files. -const MAX_TEXT_FILE_SIZE = 50 * 1024 * 1024 // 50MB -const BINARY_PROBE_BYTES = 8192 -const FULL_GIT_OBJECT_ID_PATTERN = /^(?:[0-9a-fA-F]{40}|[0-9a-fA-F]{64})$/ -// 32 visible matches plus one truncation sentinel stays below the legacy frame ceiling. -const QUICK_OPEN_SSH_LEGACY_RESULT_LIMIT = 33 -// Why: previewable binaries are base64 blobs (not parsed as text), and local IPC has no frame limit (unlike the relay's 10MB), so 50MB is safe. -const MAX_PREVIEWABLE_BINARY_SIZE = 50 * 1024 * 1024 // 50MB -const PREVIEWABLE_BINARY_MIME_TYPES: Record = { - '.png': 'image/png', - '.jpg': 'image/jpeg', - '.jpeg': 'image/jpeg', - '.gif': 'image/gif', - '.svg': 'image/svg+xml', - '.webp': 'image/webp', - '.bmp': 'image/bmp', - '.ico': 'image/x-icon', - '.pdf': 'application/pdf' -} -async function readLocalLogSnapshot(filePath: string): Promise<{ - content: string - isBinary: boolean - fileIdentity?: string -}> { - const handle = await open(filePath, 'r') - try { - const stats = await handle.stat() - if (stats.size > MAX_TEXT_FILE_SIZE) { - throw new Error( - `File too large: ${(stats.size / 1024 / 1024).toFixed(1)}MB exceeds ${MAX_TEXT_FILE_SIZE / 1024 / 1024}MB limit` - ) - } - const buffer = await handle.readFile() - if (buffer.byteLength > MAX_TEXT_FILE_SIZE) { - throw new Error( - `File too large: ${(buffer.byteLength / 1024 / 1024).toFixed(1)}MB exceeds ${MAX_TEXT_FILE_SIZE / 1024 / 1024}MB limit` - ) - } - if (isBinaryBuffer(buffer)) { - return { content: '', isBinary: true } - } - return { - content: buffer.toString('utf8'), - isBinary: false, - fileIdentity: localLogFileIdentity(stats) - } - } finally { - await handle.close() - } -} - -type DownloadFileResult = { canceled: true } | { canceled: false; destinationPath: string } - -function validateRequiredString(value: unknown, label: string): string { - if (typeof value !== 'string' || value.trim() === '') { - throw new Error(`${label} is required`) - } - return value -} - -function decodeDownloadedFileContent(content: string, encoding: 'utf8' | 'base64'): Buffer { - if (encoding === 'base64') { - return Buffer.from(content, 'base64') - } - return Buffer.from(content, 'utf8') -} - -type DownloadSession = { - destinationPath: string - tempPath: string - destinationExisted: boolean - handle: FileHandle - cleanupTimer: ReturnType - senderId: number -} - -const DOWNLOAD_SESSION_TTL_MS = 30 * 60 * 1000 - -function createSiblingTransferPath(destinationPath: string, suffix: string): string { - // Why: promotion renames must stay on the destination volume, so transfer paths remain siblings. - return join(dirname(destinationPath), `.${randomUUID()}.${suffix}`) -} - -async function cleanupLocalTransferPath(filePath: string | null): Promise { - if (!filePath) { - return - } - await rm(filePath, { force: true }).catch(() => {}) -} - -async function inspectDownloadDestination(destinationPath: string): Promise<{ existed: boolean }> { - try { - const destinationStat = await stat(destinationPath) - if (destinationStat.isDirectory()) { - throw new Error('Cannot download to a directory') - } - return { existed: true } - } catch (error) { - if (isENOENT(error)) { - return { existed: false } - } - throw error - } -} - -async function assertDestinationStillUnclaimed(destinationPath: string): Promise { - try { - await stat(destinationPath) - } catch (error) { - if (isENOENT(error)) { - return - } - throw error - } - throw new Error('Destination file appeared before download completed') -} - -async function promoteDownloadedFile( - tempPath: string, - destinationPath: string, - destinationExisted: boolean -): Promise { - if (!destinationExisted) { - await assertDestinationStillUnclaimed(destinationPath) - await rename(tempPath, destinationPath) - return - } - - const backupPath = createSiblingTransferPath(destinationPath, 'backup') - let backupCreated = false - try { - await rename(destinationPath, backupPath) - backupCreated = true - await rename(tempPath, destinationPath) - await cleanupLocalTransferPath(backupPath) - } catch (error) { - if (backupCreated) { - await rename(backupPath, destinationPath).catch(() => {}) - } - throw error - } -} - -function comparableLocalPath(value: string): string { - const normalized = resolve(value) - return process.platform === 'win32' ? normalized.toLowerCase() : normalized -} - -function getCandidateLocalWorktreePaths( - worktreePath: string, - resolvedWorktreePath: string -): Set { - return new Set([worktreePath, resolvedWorktreePath].map(comparableLocalPath)) -} - -function hasRegisteredWorktreeMetaForRepo( - store: Store, - repoId: string, - candidatePaths: Set -): boolean { - for (const worktreeId of Object.keys(store.getAllWorktreeMeta())) { - const parsed = splitWorktreeId(worktreeId) - if (parsed?.repoId === repoId && candidatePaths.has(comparableLocalPath(parsed.worktreePath))) { - return true - } - } - return false -} - -function comparableRemotePath(value: string): string { - return value.replace(/[/\\]+$/g, '') -} - -function hasRegisteredRemoteWorktreeMetaForRepo( - store: Store, - repoId: string, - worktreePath: string -): boolean { - const comparableWorktreePath = comparableRemotePath(worktreePath) - for (const worktreeId of Object.keys(store.getAllWorktreeMeta())) { - const parsed = splitWorktreeId(worktreeId) - if ( - parsed?.repoId === repoId && - comparableRemotePath(parsed.worktreePath) === comparableWorktreePath - ) { - return true - } - } - return false -} - -async function localRepoOwnsWorktree( - store: Store, - repo: Repo, - worktreePath: string -): Promise { - let resolvedWorktreePath: string - try { - resolvedWorktreePath = await resolveRegisteredWorktreePath(worktreePath, store) - } catch { - return false - } - const candidatePaths = getCandidateLocalWorktreePaths(worktreePath, resolvedWorktreePath) - if (candidatePaths.has(comparableLocalPath(repo.path))) { - return true - } - if (hasRegisteredWorktreeMetaForRepo(store, repo.id, candidatePaths)) { - return true - } - try { - const worktrees = await listRepoWorktrees(repo) - return worktrees.some((worktree) => candidatePaths.has(comparableLocalPath(worktree.path))) - } catch { - return false - } -} - -async function remoteRepoOwnsWorktree( - store: Store, - repo: Repo, - worktreePath: string, - connectionId: string -): Promise { - const comparableWorktreePath = comparableRemotePath(worktreePath) - if (comparableRemotePath(repo.path) === comparableWorktreePath) { - return true - } - const provider = getSshGitProvider(connectionId) - if (!provider) { - return hasRegisteredRemoteWorktreeMetaForRepo(store, repo.id, worktreePath) - } - try { - const worktrees = await provider.listWorktrees(repo.path) - return worktrees.some( - (worktree) => comparableRemotePath(worktree.path) === comparableWorktreePath - ) - } catch { - return false - } -} - -async function getRepoForSourceControlAi( - store: Store, - args: { repoId?: string; worktreePath: string; connectionId?: string } -): Promise { - if (!args.repoId) { - return null - } - const repo = store.getRepo(args.repoId) - if (!repo) { - return null - } - if (args.connectionId) { - if (repo.connectionId !== args.connectionId) { - return null - } - // Why: one SSH connection can host several repos; repo-scoped AI overrides apply only when the worktree belongs to that repo. - return (await remoteRepoOwnsWorktree(store, repo, args.worktreePath, args.connectionId)) - ? repo - : null - } - if (repo.connectionId) { - return null - } - // Why: renderer-supplied repoId is advisory; apply repo overrides only when the local worktree belongs to that repo. - return (await localRepoOwnsWorktree(store, repo, args.worktreePath)) ? repo : null -} - -function getLocalAgentRuntimeTarget( - gitOptions: LocalProjectWorktreeGitOptions -): CommitMessageAgentRuntimeTarget { - return gitOptions.wslDistro - ? { runtime: 'wsl', wslDistro: gitOptions.wslDistro } - : { runtime: 'host' } -} - -async function resolveModelDiscoveryLocalPath( - store: Store, - requestedPath: string -): Promise { - try { - return await resolveRegisteredWorktreePath(requestedPath, store) - } catch (error) { - const folderWorkspaces = - typeof store.getFolderWorkspaces === 'function' ? store.getFolderWorkspaces() : [] - const isFolderWorkspaceRoot = folderWorkspaces.some( - (workspace) => - comparableLocalPath(workspace.folderPath) === comparableLocalPath(requestedPath) - ) - if (!isFolderWorkspaceRoot) { - throw error - } - return resolveAuthorizedPath(requestedPath, store) - } -} - -function getLocalTextGenerationTarget( - worktreePath: string, - gitOptions: LocalProjectWorktreeGitOptions, - env?: NodeJS.ProcessEnv -): Extract { - return { - kind: 'local', - cwd: worktreePath, - ...(gitOptions.wslDistro ? { wslDistro: gitOptions.wslDistro } : {}), - ...(env ? { env } : {}) - } -} - -function validateFullGitObjectId(value: string, label: string): string { - if (!FULL_GIT_OBJECT_ID_PATTERN.test(value)) { - throw new Error(`${label} must be a full git object id`) - } - return value -} - -/** - * Check if a buffer appears to be binary (contains null bytes in first 8KB). - */ -function isBinaryBuffer(buffer: Buffer): boolean { - const len = Math.min(buffer.length, 8192) - for (let i = 0; i < len; i++) { - if (buffer[i] === 0) { - return true - } - } - return false -} - -async function isBinaryFilePrefix(filePath: string): Promise { - const handle = await open(filePath, 'r') - try { - const probe = Buffer.alloc(BINARY_PROBE_BYTES) - const { bytesRead } = await handle.read(probe, 0, probe.length, 0) - return isBinaryBuffer(probe.subarray(0, bytesRead)) - } finally { - await handle.close() - } -} - -function isDirectoryEntry(entry: { isDirectory(): boolean; isSymbolicLink(): boolean }): boolean { - // Why: following a symlink in readDir can touch macOS TCC-protected containers; treat links as file-like until explicitly opened. - if (entry.isSymbolicLink()) { - return false - } - if (entry.isDirectory()) { - return true - } - return false -} + createFilesystemHandlerContext, + type FilesystemHandlerContext +} from './filesystem/filesystem-handler-context' +import { registerFilesystemReadHandlers } from './filesystem/filesystem-read-handlers' +import { registerFilesystemDownloadHandlers } from './filesystem/filesystem-download-handlers' +import { registerFilesystemWriteHandlers } from './filesystem/filesystem-write-handlers' +import { registerFilesystemSearchHandlers } from './filesystem/filesystem-search-handlers' +import { registerFilesystemGitStatusHandlers } from './filesystem/filesystem-git-status-handlers' +import { registerFilesystemGitCommitHandlers } from './filesystem/filesystem-git-commit-handlers' +import { registerFilesystemGitCommitGenerationHandlers } from './filesystem/filesystem-git-commit-generation-handlers' +import { registerFilesystemGitModelDiscoveryHandlers } from './filesystem/filesystem-git-model-discovery-handlers' +import { registerFilesystemGitPullRequestGenerationHandlers } from './filesystem/filesystem-git-pull-request-generation-handlers' +import { registerFilesystemGitRemoteHandlers } from './filesystem/filesystem-git-remote-handlers' +import { registerFilesystemGitDiffHandlers } from './filesystem/filesystem-git-diff-handlers' +import { registerFilesystemGitIndexHandlers } from './filesystem/filesystem-git-index-handlers' +import { registerFilesystemGitUrlHandlers } from './filesystem/filesystem-git-url-handlers' export function registerFilesystemHandlers( store: Store, commitMessageAgentEnv?: CommitMessageAgentEnvironmentResolvers ): void { - const activeTextSearches = new Map() - const downloadSessions = new Map() - - async function closeDownloadSession( - transferId: string, - cleanupTemp: boolean - ): Promise { - const session = downloadSessions.get(transferId) - if (!session) { - return null - } - downloadSessions.delete(transferId) - clearTimeout(session.cleanupTimer) - await session.handle.close().catch(() => {}) - if (cleanupTemp) { - await cleanupLocalTransferPath(session.tempPath) - } - return session - } - - function cleanupDownloadSessionsForSender(senderId: number): void { - for (const [transferId, session] of Array.from(downloadSessions)) { - if (session.senderId === senderId) { - void closeDownloadSession(transferId, true) - } - } - } - - // ─── Filesystem ───────────────────────────────────────── - ipcMain.handle( - 'fs:readDir', - async (_event, args: { dirPath: string; connectionId?: string }): Promise => { - // Why: fs:readDir throws surface as opaque IPC errors; record the throw site + redacted path shape to keep them diagnosable. - let throwSite: ReadDirThrowSite = 'authorize' - try { - if (args.connectionId) { - throwSite = 'ssh-provider' - const provider = requireSshFilesystemProvider(args.connectionId) - // Why: re-sort locally — the remote relay may be an older build with - // lexicographic ordering. - return sortDirEntries(await provider.readDir(args.dirPath)) - } - throwSite = 'authorize' - const dirPath = await resolveAuthorizedPath(args.dirPath, store) - throwSite = 'readdir' - const entries = await readdir(dirPath, { withFileTypes: true }) - const mapped = entries.map((entry) => ({ - name: entry.name, - isDirectory: isDirectoryEntry(entry), - isSymlink: entry.isSymbolicLink() - })) - return sortDirEntries(mapped) - } catch (error: unknown) { - recordCrashBreadcrumb( - 'fs_readdir_error', - buildReadDirErrorBreadcrumb({ - dirPath: args.dirPath, - connectionId: args.connectionId, - throwSite, - error - }) - ) - throw error - } - } - ) - - ipcMain.handle( - 'fs:readFile', - async ( - _event, - args: { filePath: string; connectionId?: string; includeLocalLogMetadata?: boolean } - ): Promise<{ - content: string - isBinary: boolean - isImage?: boolean - mimeType?: string - fileIdentity?: string - }> => { - if (args.connectionId) { - const provider = requireSshFilesystemProvider(args.connectionId) - return provider.readFile(args.filePath) - } - const filePath = await resolveAuthorizedPath(args.filePath, store) - if (args.includeLocalLogMetadata === true) { - return readLocalLogSnapshot(filePath) - } - const stats = await stat(filePath) - const mimeType = PREVIEWABLE_BINARY_MIME_TYPES[extname(filePath).toLowerCase()] - const sizeLimit = mimeType ? MAX_PREVIEWABLE_BINARY_SIZE : MAX_TEXT_FILE_SIZE - if (stats.size > sizeLimit) { - throw new Error( - `File too large: ${(stats.size / 1024 / 1024).toFixed(1)}MB exceeds ${sizeLimit / 1024 / 1024}MB limit` - ) - } - - if (mimeType) { - const buffer = await readFile(filePath) - return { - content: buffer.toString('base64'), - isBinary: true, - // Why: the renderer keys previewable-binary rendering off `isImage`, so set it for PDFs too to stay compatible. - isImage: true, - mimeType - } - } - - // Why: probe large unknown files first so archives aren't fully buffered only to discover they aren't editable text. - if (stats.size > BINARY_PROBE_BYTES && (await isBinaryFilePrefix(filePath))) { - return { content: '', isBinary: true } - } - - const buffer = await readFile(filePath) - if (isBinaryBuffer(buffer)) { - return { content: '', isBinary: true } - } - - return { content: buffer.toString('utf-8'), isBinary: false } - } - ) - - ipcMain.handle( - 'fs:downloadFile', - async ( - event, - args: { filePath?: string; connectionId?: string } - ): Promise => { - const filePath = validateRequiredString(args?.filePath, 'filePath') - const connectionId = validateRequiredString(args?.connectionId, 'connectionId') - const provider = requireSshFilesystemProvider(connectionId) - const remoteStat = await provider.stat(filePath) - if (remoteStat.type === 'directory') { - throw new Error('Cannot download a directory') - } - if (!provider.downloadFile) { - throw new Error('Remote file download is unavailable. Reconnect the SSH target and retry.') - } - - const remoteBasename = getRuntimePathBasename(filePath) - const defaultPath = sanitizeLocalDownloadFilename(remoteBasename) - const parentWindow = BrowserWindow.fromWebContents(event.sender) ?? undefined - const dialogResult = parentWindow - ? await dialog.showSaveDialog(parentWindow, { defaultPath }) - : await dialog.showSaveDialog({ defaultPath }) - if (dialogResult.canceled || !dialogResult.filePath) { - return { canceled: true } - } - - const destinationPath = dialogResult.filePath - const { existed } = await inspectDownloadDestination(destinationPath) - const tempPath = createSiblingTransferPath(destinationPath, 'download') - let promoted = false - try { - await provider.downloadFile(filePath, tempPath) - await promoteDownloadedFile(tempPath, destinationPath, existed) - promoted = true - return { canceled: false, destinationPath } - } finally { - if (!promoted) { - await cleanupLocalTransferPath(tempPath) - } - } - } - ) - - registerFilesystemDownloadFolderHandlers() - - ipcMain.handle( - 'fs:saveDownloadedFile', - async ( - event, - args: { suggestedName?: string; content?: string; encoding?: 'utf8' | 'base64' } - ): Promise => { - const suggestedName = sanitizeLocalDownloadFilename( - validateRequiredString(args?.suggestedName, 'suggestedName') - ) - if (typeof args?.content !== 'string') { - throw new Error('content is required') - } - const content = args.content - const encoding = args?.encoding === 'base64' ? 'base64' : 'utf8' - const parentWindow = BrowserWindow.fromWebContents(event.sender) ?? undefined - const dialogResult = parentWindow - ? await dialog.showSaveDialog(parentWindow, { defaultPath: suggestedName }) - : await dialog.showSaveDialog({ defaultPath: suggestedName }) - if (dialogResult.canceled || !dialogResult.filePath) { - return { canceled: true } - } - - const destinationPath = dialogResult.filePath - const { existed } = await inspectDownloadDestination(destinationPath) - const tempPath = createSiblingTransferPath(destinationPath, 'download') - let promoted = false - try { - await writeFile(tempPath, decodeDownloadedFileContent(content, encoding)) - await promoteDownloadedFile(tempPath, destinationPath, existed) - promoted = true - return { canceled: false, destinationPath } - } finally { - if (!promoted) { - await cleanupLocalTransferPath(tempPath) - } - } - } - ) - - ipcMain.handle( - 'fs:startDownloadedFile', - async ( - event, - args: { suggestedName?: string } - ): Promise< - | { canceled: true } - | { - canceled: false - transferId: string - destinationPath: string - } - > => { - const suggestedName = sanitizeLocalDownloadFilename( - validateRequiredString(args?.suggestedName, 'suggestedName') - ) - const parentWindow = BrowserWindow.fromWebContents(event.sender) ?? undefined - const dialogResult = parentWindow - ? await dialog.showSaveDialog(parentWindow, { defaultPath: suggestedName }) - : await dialog.showSaveDialog({ defaultPath: suggestedName }) - if (dialogResult.canceled || !dialogResult.filePath) { - return { canceled: true } - } - - const destinationPath = dialogResult.filePath - const { existed } = await inspectDownloadDestination(destinationPath) - const tempPath = createSiblingTransferPath(destinationPath, 'download') - const transferId = randomUUID() - try { - const handle = await open(tempPath, 'wx') - const senderId = typeof event.sender.id === 'number' ? event.sender.id : Number.NaN - const cleanupTimer = setTimeout(() => { - void closeDownloadSession(transferId, true) - }, DOWNLOAD_SESSION_TTL_MS) - if (typeof cleanupTimer.unref === 'function') { - cleanupTimer.unref() - } - downloadSessions.set(transferId, { - destinationPath, - tempPath, - destinationExisted: existed, - handle, - cleanupTimer, - senderId - }) - event.sender.once?.('destroyed', () => cleanupDownloadSessionsForSender(senderId)) - return { canceled: false, transferId, destinationPath } - } catch (error) { - await cleanupLocalTransferPath(tempPath) - throw error - } - } - ) - - ipcMain.handle( - 'fs:appendDownloadedFileChunk', - async ( - _event, - args: { transferId?: string; contentBase64?: string } - ): Promise<{ ok: true }> => { - const transferId = validateRequiredString(args?.transferId, 'transferId') - const contentBase64 = validateRequiredString(args?.contentBase64, 'contentBase64') - const session = downloadSessions.get(transferId) - if (!session) { - throw new Error('Download session not found') - } - await session.handle.writeFile(Buffer.from(contentBase64, 'base64')) - return { ok: true } - } - ) - - ipcMain.handle( - 'fs:finishDownloadedFile', - async ( - _event, - args: { transferId?: string } - ): Promise<{ canceled: false; destinationPath: string }> => { - const transferId = validateRequiredString(args?.transferId, 'transferId') - const session = await closeDownloadSession(transferId, false) - if (!session) { - throw new Error('Download session not found') - } - let promoted = false - try { - await promoteDownloadedFile( - session.tempPath, - session.destinationPath, - session.destinationExisted - ) - promoted = true - return { canceled: false, destinationPath: session.destinationPath } - } finally { - if (!promoted) { - await cleanupLocalTransferPath(session.tempPath) - } - } - } - ) - - ipcMain.handle( - 'fs:cancelDownloadedFile', - async (_event, args: { transferId?: string }): Promise<{ ok: true }> => { - const transferId = validateRequiredString(args?.transferId, 'transferId') - await closeDownloadSession(transferId, true) - return { ok: true } - } - ) - - ipcMain.handle( - 'fs:listMarkdownDocuments', - async ( - _event, - args: { rootPath: string; connectionId?: string } - ): Promise => { - if (args.connectionId) { - const provider = requireSshFilesystemProvider(args.connectionId) - const relativePaths = await provider.listFiles(args.rootPath) - return markdownDocumentsFromRelativePaths(args.rootPath, relativePaths) - } - - const rootPath = await resolveRegisteredWorktreePath(args.rootPath, store) - return listMarkdownDocuments(rootPath) - } - ) - - ipcMain.handle( - 'fs:writeFile', - async ( - _event, - args: { filePath: string; content: string; connectionId?: string } & SshMutationExpectation - ): Promise => { - assertSshMutationExpectation( - args.connectionId, - args.expectedSshTargetId, - args.expectedSshConnectionGeneration, - args.expectedExecutionHostId - ) - if (args.connectionId) { - const provider = requireSshFilesystemProvider(args.connectionId) - return provider.writeFile(args.filePath, args.content) - } - const filePath = await resolveAuthorizedPath(args.filePath, store) - - try { - const fileStats = await lstat(filePath) - if (fileStats.isDirectory()) { - throw new Error('Cannot write to a directory') - } - } catch (error) { - if (!isENOENT(error)) { - throw error - } - } - - await writeFile(filePath, args.content, 'utf-8') - } - ) - - ipcMain.handle( - 'fs:deletePath', - async ( - _event, - args: { - targetPath: string - connectionId?: string - recursive?: boolean - } & SshMutationExpectation - ): Promise => { - assertSshMutationExpectation( - args.connectionId, - args.expectedSshTargetId, - args.expectedSshConnectionGeneration, - args.expectedExecutionHostId - ) - if (args.connectionId) { - const provider = requireSshFilesystemProvider(args.connectionId) - return provider.deletePath(args.targetPath, args.recursive) - } - // Why: preserve the symlink so we delete the link, not its target (realpath would trash the real file, possibly outside all roots). - const targetPath = await resolveAuthorizedPath(args.targetPath, store, { - preserveSymlink: true - }) - - // Why: WSL UNC targets have no Recycle Bin (shell.trashItem throws), so hard-delete via `rm` inside the distro (issue #6415). - if (await tryDeleteWslUncPath(targetPath, { recursive: args.recursive })) { - return - } - - // Why: swallow ENOENT so an external delete racing this UI delete stays idempotent (design §7.1). - try { - await shell.trashItem(targetPath) - } catch (error) { - if (isENOENT(error)) { - return - } - throw error - } - } - ) - - registerFilesystemMutationHandlers(store) - - ipcMain.handle('fs:authorizeExternalPath', (_event, args: { targetPath: string }): void => { - authorizeExternalPath(args.targetPath) - }) - - ipcMain.handle( - 'fs:stat', - async ( - _event, - args: { filePath: string; connectionId?: string } - ): Promise<{ size: number; isDirectory: boolean; mtime: number }> => { - if (args.connectionId) { - const provider = requireSshFilesystemProvider(args.connectionId) - const s = await provider.stat(args.filePath) - return { size: s.size, isDirectory: s.type === 'directory', mtime: s.mtime } - } - const filePath = await resolveAuthorizedPath(args.filePath, store) - const stats = await stat(filePath) - return { - size: stats.size, - isDirectory: stats.isDirectory(), - mtime: stats.mtimeMs - } - } - ) - - ipcMain.handle( - 'fs:pathExists', - async (_event, args: { filePath: string; connectionId?: string }): Promise => { - try { - if (args.connectionId) { - const provider = requireSshFilesystemProvider(args.connectionId) - await provider.stat(args.filePath) - return true - } - const filePath = await resolveAuthorizedPath(args.filePath, store) - await stat(filePath) - return true - } catch (error) { - if (isENOENT(error)) { - return false - } - throw error - } - } - ) - - // ─── Search ──────────────────────────────────────────── - ipcMain.handle( - 'fs:search', - async (event, args: SearchOptions & { connectionId?: string }): Promise => { - if (args.connectionId) { - const provider = requireSshFilesystemProvider(args.connectionId) - return provider.search(args) - } - const rootPath = await resolveAuthorizedPath(args.rootPath, store) - const localGitOptions = getLocalGitOptionsForRegisteredWorktree( - store, - args.rootPath, - rootPath - ) - const maxResults = Math.max( - 1, - Math.min(args.maxResults ?? DEFAULT_SEARCH_MAX_RESULTS, DEFAULT_SEARCH_MAX_RESULTS) - ) - const searchKey = `${event.sender.id}:${rootPath}` - // Why: WSL's bash exit 127 is ambiguous with a real executable returning 127. - const wslDistroForOutput = parseWslPath(rootPath)?.distro ?? localGitOptions.wslDistro - - if (wslDistroForOutput && !(await checkRgAvailable(rootPath, localGitOptions.wslDistro))) { - return searchWithGitGrep(rootPath, args, maxResults, localGitOptions) - } - - return new Promise((resolvePromise) => { - const rgArgs = buildRgArgs(args.query, rootPath, args) - - // Why: kill the prior rg so it stops parsing thousands of matches on the main thread (the large-repo freeze) after the UI moved on. - const previousChild = activeTextSearches.get(searchKey) - if (previousChild) { - killSpawnedRipgrepProcess(previousChild) - } - - const acc = createAccumulator() - let stdoutBuffer = '' - let resolved = false - let processErrorObserved = false - let unavailableExitObserved = false - let child: ChildProcess | null = null - let killTimeout: ReturnType - - const transformAbsPath = wslDistroForOutput - ? (p: string): string => (p.startsWith('/') ? toWindowsWslPath(p, wslDistroForOutput) : p) - : undefined - - const finish = (result: SearchResult | PromiseLike): void => { - if (resolved) { - return - } - resolved = true - if (activeTextSearches.get(searchKey) === child) { - activeTextSearches.delete(searchKey) - } - clearTimeout(killTimeout) - // Why: child.kill() is advisory; detach our closures so repeated searches don't retain old scans if rg ignores it. - child?.stdout?.off('data', handleStdoutData) - child?.stderr?.off('data', handleStderrData) - child?.off('error', handleError) - child?.off('close', handleClose) - if (child) { - absorbPendingRipgrepSpawnError(child, { - errorObserved: processErrorObserved, - unavailableExitObserved - }) - } - resolvePromise(result) - } - const resolveOnce = (): void => finish(finalize(acc)) - const resolveWithoutRipgrep = (): void => - finish(searchWithGitGrep(rootPath, args, maxResults, localGitOptions)) - - const processLine = (line: string): void => { - const verdict = ingestRgJsonLine(line, rootPath, acc, maxResults, transformAbsPath) - if (verdict === 'stop' && child) { - killSpawnedRipgrepProcess(child) - } - } - - const nextChild = wslAwareSpawn('rg', rgArgs, { - cwd: rootPath, - ...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {}), - stdio: ['ignore', 'pipe', 'pipe'] - }) - child = nextChild - activeTextSearches.set(searchKey, nextChild) - - const handleStdoutData = (chunk: string): void => { - stdoutBuffer += chunk - const lines = stdoutBuffer.split('\n') - stdoutBuffer = lines.pop() ?? '' - for (const line of lines) { - processLine(line) - } - } - const handleStderrData = (): void => { - // Drain stderr so rg cannot block on a full pipe. - } - const handleError = (): void => { - processErrorObserved = true - if (child && isRipgrepUnavailableExit(child, null, null)) { - resolveWithoutRipgrep() - return - } - resolveOnce() - } - const handleClose = (code: number | null, signal: NodeJS.Signals | null): void => { - if ( - child && - isRipgrepUnavailableExit(child, code, signal, { - classifyNativeLauncherExit: !wslDistroForOutput - }) - ) { - unavailableExitObserved = true - resolveWithoutRipgrep() - return - } - if (stdoutBuffer) { - processLine(stdoutBuffer) - } - resolveOnce() - } - - nextChild.stdout!.setEncoding('utf-8') - nextChild.stdout!.on('data', handleStdoutData) - nextChild.stderr!.on('data', handleStderrData) - nextChild.once('error', handleError) - nextChild.once('close', handleClose) - - // Why: timeout kills the child mid-scan; mark truncated so the UI shows incomplete results. - killTimeout = setTimeout(() => { - acc.truncated = true - if (child) { - killSpawnedRipgrepProcess(child) - } - resolveOnce() - }, SEARCH_TIMEOUT_MS) - }) - } - ) - - // ─── List all files (for quick-open) ───────────────────── - // Why #7721: token-keyed so a workspace switch aborts the prior full-tree scan (SSH otherwise stacks scans past the 30s timeout). - const listFilesCancellations = createSenderScopedRequestCancellations() - ipcMain.handle( - 'fs:listFiles', - async ( - event, - args: { - rootPath: string - connectionId?: string - excludePaths?: string[] - requestToken?: string - maxResults?: number - searchQuery?: string - } - ): Promise => { - const controller = listFilesCancellations.begin(event, args.requestToken) - try { - if (args.connectionId) { - const provider = getSshFilesystemProvider(args.connectionId) - // Why: no provider (cold start / disconnected) → return [] so quick-open shows "No matching files" instead of an error. - if (!provider) { - return [] - } - // Why: forward excludePaths or nested linked worktrees get double-scanned over SSH, causing timeout-induced partial results. - if ( - args.searchQuery !== undefined && - provider.supportsQuickOpenSearch && - !(await provider.supportsQuickOpenSearch({ signal: controller?.signal })) - ) { - const legacyFiles = await provider.listFiles(args.rootPath, { - excludePaths: args.excludePaths, - maxResults: QUICK_OPEN_SSH_LEGACY_RESULT_LIMIT, - signal: controller?.signal - }) - const ranker = new QuickOpenPathRanker( - args.searchQuery, - args.maxResults ?? QUICK_OPEN_SSH_LEGACY_RESULT_LIMIT - ) - for (const file of legacyFiles) { - ranker.consider(file) - } - return ranker.result().paths - } - return await provider.listFiles(args.rootPath, { - excludePaths: args.excludePaths, - ...(args.maxResults === undefined ? {} : { maxResults: args.maxResults }), - ...(args.searchQuery === undefined ? {} : { searchQuery: args.searchQuery }), - signal: controller?.signal - }) - } - return await listQuickOpenFiles(args.rootPath, store, args.excludePaths, controller?.signal) - } finally { - listFilesCancellations.finish(event, args.requestToken, controller) - } - } - ) - - ipcMain.handle('fs:cancelListFiles', (event, args: { requestToken: string }): void => { - listFilesCancellations.cancel(event, args.requestToken) - }) - - // ─── Git operations ───────────────────────────────────── - const gitStatusCancellations = createSenderScopedRequestCancellations() - ipcMain.handle( - 'git:status', - async ( - event, - args: { - worktreePath: string - connectionId?: string - admissionTier?: GitAdmissionTier - includeIgnored?: boolean - includeLineStats?: boolean - bypassEffectiveUpstreamNegativeCache?: boolean - reuseLineStats?: boolean - branchLineTotalMergeBase?: string - requestToken?: string - } - ): Promise => { - const controller = gitStatusCancellations.begin(event, args.requestToken) - const options = { - includeIgnored: args.includeIgnored ?? false, - admissionTier: args.admissionTier ?? ('status' as const), - ...(args.includeLineStats === false ? { includeLineStats: false } : {}), - ...(args.reuseLineStats === true ? { reuseLineStats: true } : {}), - ...(args.branchLineTotalMergeBase === undefined - ? {} - : { branchLineTotalMergeBase: args.branchLineTotalMergeBase }), - ...(args.bypassEffectiveUpstreamNegativeCache === true - ? { bypassEffectiveUpstreamNegativeCache: true } - : {}), - ...(controller ? { signal: controller.signal } : {}) - } - try { - if (args.connectionId) { - const provider = getSshGitProvider(args.connectionId) - if (!provider) { - throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) - } - // Why: await keeps the cancellation token registered until the remote request settles (an early finally would free it). - return await provider.getStatus(args.worktreePath, options) - } - const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) - // Why: one registered-worktree lookup feeds both — status polls this - // handler, and the scan walks every repo's worktree meta. - const repo = getLocalRepoForRegisteredWorktree(store, args.worktreePath, worktreePath) - const gitOptions = getLocalGitOptionsForRepo(store, repo) - const sharedLinkPaths = repo ? getWorktreeSharedLinkPaths(repo) : [] - return await getStatus(worktreePath, { - ...options, - ...gitOptions, - ...(sharedLinkPaths.length > 0 ? { sharedLinkPaths } : {}) - }) - } finally { - gitStatusCancellations.finish(event, args.requestToken, controller) - } - } - ) - - ipcMain.handle('git:cancelStatus', (event, args: { requestToken: string }): void => { - gitStatusCancellations.cancel(event, args.requestToken) - }) - - ipcMain.handle( - 'git:setStatusUpstreamRefWatch', - (_event, args: GitStatusUpstreamRefWatchRequest): Promise => - applyGitStatusUpstreamRefWatchRequest(store, args) - ) - - // Why: parent status reports only one gitlink row per submodule; fetch inner per-file changes from the submodule's own worktree. - ipcMain.handle( - 'git:submoduleStatus', - async ( - _event, - args: { - worktreePath: string - submodulePath: string - connectionId?: string - area?: GitStagingArea - } - ): Promise => { - if (args.connectionId) { - const provider = getSshGitProvider(args.connectionId) - if (!provider) { - throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) - } - return provider.getSubmoduleStatus(args.worktreePath, args.submodulePath, args.area) - } - const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) - const gitOptions = getLocalGitOptionsForRegisteredWorktree( - store, - args.worktreePath, - worktreePath - ) - return getSubmoduleStatus(worktreePath, args.submodulePath, { - ...gitOptions, - ...(args.area === 'staged' ? { staged: true } : {}) - }) - } - ) - - ipcMain.handle( - 'git:checkIgnored', - async ( - _event, - args: { worktreePath: string; paths: string[]; connectionId?: string } - ): Promise => { - if (args.connectionId) { - const paths = args.paths.map((p) => validateGitRelativeFilePath(args.worktreePath, p)) - const provider = getSshGitProvider(args.connectionId) - if (!provider) { - throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) - } - return provider.checkIgnoredPaths(args.worktreePath, paths) - } - const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) - const paths = args.paths.map((p) => validateGitRelativeFilePath(worktreePath, p)) - const gitOptions = getLocalGitOptionsForRegisteredWorktree( - store, - args.worktreePath, - worktreePath - ) - return checkIgnoredPaths(worktreePath, paths, gitOptions) - } - ) - - // Why: backs the SCM "ignore the flooding folder" flow; local-only since huge untracked folders are a local-dev pathology. - ipcMain.handle( - 'git:findHugeFoldersToIgnore', - async (_event, args: { worktreePath: string }): Promise => { - const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) - const gitOptions = getLocalGitOptionsForRegisteredWorktree( - store, - args.worktreePath, - worktreePath - ) - return findKnownHugeFolderPathsToIgnore(worktreePath, gitOptions) - } - ) - - ipcMain.handle( - 'git:appendGitignore', - async (_event, args: { worktreePath: string; folderName: string }): Promise => { - const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) - return appendFolderToGitignore(worktreePath, args.folderName) - } - ) - - ipcMain.handle( - 'git:history', - async ( - _event, - args: { worktreePath: string; connectionId?: string } & GitHistoryOptions - ): Promise => { - const options: GitHistoryOptions = { limit: args.limit, baseRef: args.baseRef } - if (args.connectionId) { - const provider = getSshGitProvider(args.connectionId) - if (!provider) { - throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) - } - return provider.getHistory(args.worktreePath, options) - } - const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) - const gitOptions = getLocalGitOptionsForRegisteredWorktree( - store, - args.worktreePath, - worktreePath - ) - return getHistory(worktreePath, { ...options, ...gitOptions }) - } - ) - - // Why: fs-only conflict-state check so non-active worktrees can clear their Rebasing/Merging badges without a full git status. - ipcMain.handle( - 'git:conflictOperation', - async ( - _event, - args: { worktreePath: string; connectionId?: string } - ): Promise => { - if (args.connectionId) { - const provider = getSshGitProvider(args.connectionId) - if (!provider) { - throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) - } - return provider.detectConflictOperation(args.worktreePath) - } - const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) - return detectConflictOperation(worktreePath) - } - ) - - ipcMain.handle( - 'git:abortMerge', - async (_event, args: { worktreePath: string; connectionId?: string }): Promise => { - if (args.connectionId) { - const provider = getSshGitProvider(args.connectionId) - if (!provider) { - throw new Error(`No git provider for connection "${args.connectionId}"`) - } - return provider.abortMerge(args.worktreePath) - } - const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) - const gitOptions = getLocalGitOptionsForRegisteredWorktree( - store, - args.worktreePath, - worktreePath - ) - await abortMerge(worktreePath, { ...gitOptions, admissionTier: 'interactive' }) - } - ) - - ipcMain.handle( - 'git:abortRebase', - async (_event, args: { worktreePath: string; connectionId?: string }): Promise => { - if (args.connectionId) { - const provider = getSshGitProvider(args.connectionId) - if (!provider) { - throw new Error(`No git provider for connection "${args.connectionId}"`) - } - return provider.abortRebase(args.worktreePath) - } - const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) - const gitOptions = getLocalGitOptionsForRegisteredWorktree( - store, - args.worktreePath, - worktreePath - ) - await abortRebase(worktreePath, { ...gitOptions, admissionTier: 'interactive' }) - } - ) - - ipcMain.handle( - 'git:diff', - async ( - _event, - args: { - worktreePath: string - filePath: string - staged: boolean - compareAgainstHead?: boolean - connectionId?: string - } - ): Promise => { - if (args.connectionId) { - const provider = getSshGitProvider(args.connectionId) - if (!provider) { - throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) - } - return provider.getDiff( - args.worktreePath, - args.filePath, - args.staged, - args.compareAgainstHead - ) - } - const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) - const filePath = validateGitRelativeFilePath(worktreePath, args.filePath) - const gitOptions = getLocalGitOptionsForRegisteredWorktree( - store, - args.worktreePath, - worktreePath - ) - return getDiff(worktreePath, filePath, args.staged, args.compareAgainstHead, { - ...gitOptions, - admissionTier: 'interactive' - }) - } - ) - - ipcMain.handle( - 'git:commit', - async ( - _event, - args: { worktreePath: string; message: string; connectionId?: string } - ): Promise<{ success: boolean; error?: string }> => { - // Why: validate at the IPC boundary so the renderer gets a clear error instead of an opaque execFile failure. - if (typeof args.message !== 'string' || args.message.trim().length === 0) { - throw new Error('Commit message is required') - } - if (args.connectionId) { - const provider = getSshGitProvider(args.connectionId) - if (!provider) { - throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) - } - return provider.commit(args.worktreePath, args.message) - } - const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) - const gitOptions = getLocalGitOptionsForRegisteredWorktree( - store, - args.worktreePath, - worktreePath - ) - return commitChanges(worktreePath, args.message, { - ...gitOptions, - admissionTier: 'interactive' - }) - } - ) - - ipcMain.handle( - 'git:generateCommitMessage', - async ( - _event, - args: { - worktreePath: string - // Raw (unstripped) meta key; validated against worktreePath before any meta read. - worktreeId?: string - repoId?: string - connectionId?: string - sourceControlAiResolvedParams?: ResolvedSourceControlAiGenerationParams - sourceControlAi?: GlobalSettings['sourceControlAi'] - agentCmdOverrides?: GlobalSettings['agentCmdOverrides'] - } - ): Promise => { - const discoveryHostKey = getCommitMessageModelDiscoveryHostKey(args.connectionId ?? null) - const baseSettings = store.getSettings() - const requestSettings = { - ...baseSettings, - ...(args.sourceControlAi !== undefined ? { sourceControlAi: args.sourceControlAi } : {}), - ...(args.agentCmdOverrides !== undefined - ? { agentCmdOverrides: args.agentCmdOverrides } - : {}) - } - const resolvedSettings = args.sourceControlAiResolvedParams - ? { ok: true as const, params: args.sourceControlAiResolvedParams } - : resolveCommitMessageSettings( - requestSettings, - discoveryHostKey, - 'commitMessage', - await getRepoForSourceControlAi(store, args) - ) - if (!resolvedSettings.ok) { - return { success: false, error: resolvedSettings.error } - } - if (args.connectionId) { - const provider = getSshGitProvider(args.connectionId) - if (!provider) { - return { - success: false, - error: SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE - } - } - let context - try { - context = await provider.getStagedCommitContext(args.worktreePath) - } catch (error) { - console.error('[filesystem] Failed to read remote staged commit context:', error) - return { - success: false, - error: 'Failed to read staged changes.' - } - } - if (!context) { - return { success: false, error: 'No staged changes to summarize.' } - } - context = withLinkedIssueDraftContext( - context, - resolveSourceControlAiLinkedIssue(store, args) - ) - return generateCommitMessageFromContext(context, resolvedSettings.params, { - kind: 'remote', - cwd: args.worktreePath, - execute: (plan, cwd, timeoutMs, operation) => - provider.executeCommitMessagePlan(plan, cwd, timeoutMs, operation), - missingBinaryLocation: 'remote PATH' - }) - } - const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) - const gitOptions = getLocalGitOptionsForRegisteredWorktree( - store, - args.worktreePath, - worktreePath - ) - let context - try { - context = await getStagedCommitContext(worktreePath, { - ...gitOptions, - admissionTier: 'interactive' - }) - } catch (error) { - console.error('[filesystem] Failed to read staged commit context:', error) - return { - success: false, - error: 'Failed to read staged changes.' - } - } - if (!context) { - return { success: false, error: 'No staged changes to summarize.' } - } - context = withLinkedIssueDraftContext( - context, - resolveSourceControlAiLinkedIssue(store, args, worktreePath) - ) - const localEnv = await prepareLocalCommitMessageAgentEnv( - resolvedSettings.params.agentId, - commitMessageAgentEnv, - getLocalAgentRuntimeTarget(gitOptions) - ) - if (!localEnv.ok) { - return { success: false, error: localEnv.error } - } - return generateCommitMessageFromContext( - context, - resolvedSettings.params, - getLocalTextGenerationTarget(worktreePath, gitOptions, localEnv.env) - ) - } - ) - - ipcMain.handle( - 'git:cancelGenerateCommitMessage', - async (_event, args: { worktreePath: string; connectionId?: string }): Promise => { - if (args.connectionId) { - const provider = getSshGitProvider(args.connectionId) - if (!provider) { - return - } - await provider.cancelGenerateCommitMessage(args.worktreePath, 'commit-message') - return - } - const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) - cancelGenerateCommitMessageLocal(worktreePath) - } - ) - - ipcMain.handle( - 'git:discoverCommitMessageModels', - async ( - _event, - args: { agentId: string; worktreePath?: string; connectionId?: string } - ): Promise => { - const agentId = args.agentId - const agentCommandOverride = store.getSettings().agentCmdOverrides?.[agentId as TuiAgent] - if (args.connectionId) { - if (!args.worktreePath) { - return { success: false, error: 'Missing worktree path for remote model discovery.' } - } - const provider = getSshGitProvider(args.connectionId) - if (!provider) { - return { - success: false, - error: `No git provider for connection "${args.connectionId}"` - } - } - return discoverCommitMessageModelsRemote( - agentId as TuiAgent, - args.worktreePath, - (plan, cwd, timeoutMs) => provider.executeCommitMessagePlan(plan, cwd, timeoutMs), - agentCommandOverride - ) - } - let localRuntimeTarget: CommitMessageAgentRuntimeTarget = { runtime: 'host' } - let localDiscoveryOptions: Parameters[3] - if (args.worktreePath) { - const worktreePath = await resolveModelDiscoveryLocalPath(store, args.worktreePath) - const gitOptions = getLocalGitOptionsForRegisteredWorktree( - store, - args.worktreePath, - worktreePath - ) - const wslDistro = gitOptions.wslDistro ?? parseWslPath(args.worktreePath)?.distro - localRuntimeTarget = wslDistro - ? { runtime: 'wsl', wslDistro } - : getLocalAgentRuntimeTarget(gitOptions) - localDiscoveryOptions = wslDistro ? { cwd: worktreePath, wslDistro } : { cwd: worktreePath } - } - const localEnv = await prepareLocalCommitMessageAgentEnv( - agentId, - commitMessageAgentEnv, - localRuntimeTarget - ) - if (!localEnv.ok) { - return { success: false, error: localEnv.error } - } - return localDiscoveryOptions - ? discoverCommitMessageModelsLocal( - agentId as TuiAgent, - localEnv.env, - agentCommandOverride, - localDiscoveryOptions - ) - : discoverCommitMessageModelsLocal(agentId as TuiAgent, localEnv.env, agentCommandOverride) - } - ) - - ipcMain.handle( - 'git:generatePullRequestFields', - async ( - _event, - args: { - worktreePath: string - // Raw (unstripped) meta key; validated against worktreePath before any meta read. - worktreeId?: string - repoId?: string - base: string - title: string - body: string - draft: boolean - provider?: HostedReviewProvider - useTemplate?: boolean - connectionId?: string - sourceControlAiResolvedParams?: ResolvedSourceControlAiGenerationParams - sourceControlAi?: GlobalSettings['sourceControlAi'] - agentCmdOverrides?: GlobalSettings['agentCmdOverrides'] - } - ): Promise => { - const discoveryHostKey = getCommitMessageModelDiscoveryHostKey(args.connectionId ?? null) - const baseSettings = store.getSettings() - const requestSettings = { - ...baseSettings, - ...(args.sourceControlAi !== undefined ? { sourceControlAi: args.sourceControlAi } : {}), - ...(args.agentCmdOverrides !== undefined - ? { agentCmdOverrides: args.agentCmdOverrides } - : {}) - } - const resolvedSettings = args.sourceControlAiResolvedParams - ? { ok: true as const, params: args.sourceControlAiResolvedParams } - : resolveCommitMessageSettings( - requestSettings, - discoveryHostKey, - 'pullRequest', - await getRepoForSourceControlAi(store, args) - ) - if (!resolvedSettings.ok) { - return { success: false, error: resolvedSettings.error } - } - if (args.connectionId) { - const provider = getSshGitProvider(args.connectionId) - if (!provider) { - return { - success: false, - error: SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE - } - } - const issueMeta = resolveSourceControlAiLinkedIssueMeta(store, args) - const linkedIssueDetailsPromise = loadPullRequestLinkedIssue({ - meta: issueMeta, - provider: args.provider, - repoPath: args.worktreePath, - connectionId: args.connectionId - }) - let context: Awaited> - try { - const currentBody = await resolveHostedReviewBodyForGeneration({ - body: args.body, - repoPath: args.worktreePath, - connectionId: args.connectionId, - provider: args.provider, - useTemplate: args.useTemplate - }) - context = await getPullRequestDraftContext( - (argv, commandOptions) => - commandOptions?.timeoutMs !== undefined - ? provider.exec(argv, args.worktreePath, { timeoutMs: commandOptions.timeoutMs }) - : commandOptions?.timeout !== undefined - ? provider.exec(argv, args.worktreePath, { timeoutMs: commandOptions.timeout }) - : provider.exec(argv, args.worktreePath), - { - base: args.base, - currentTitle: args.title, - currentBody, - currentDraft: args.draft - } - ) - } catch (error) { - return { - success: false, - error: - error instanceof Error ? error.message : 'Failed to prepare branch for PR details.' - } - } - if (!context) { - return { success: false, error: 'No branch changes to summarize.' } - } - const linkedIssueDetails = await linkedIssueDetailsPromise - context = { - ...withLinkedIssueDraftContext(context, issueMeta?.linkedIssue), - ...(args.provider ? { provider: args.provider } : {}), - ...(linkedIssueDetails ? { linkedIssueDetails } : {}) - } - return generatePullRequestFieldsFromContext(context, resolvedSettings.params, { - kind: 'remote', - cwd: args.worktreePath, - execute: (plan, cwd, timeoutMs, operation) => - provider.executeCommitMessagePlan(plan, cwd, timeoutMs, operation), - missingBinaryLocation: 'remote PATH' - }) - } - - const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) - const gitOptions = getLocalGitOptionsForRegisteredWorktree( - store, - args.worktreePath, - worktreePath - ) - const issueMeta = resolveSourceControlAiLinkedIssueMeta(store, args, worktreePath) - const linkedIssueDetailsPromise = loadPullRequestLinkedIssue({ - meta: issueMeta, - provider: args.provider, - repoPath: worktreePath, - connectionId: args.connectionId, - localGitOptions: gitOptions - }) - let context: Awaited> - try { - const currentBody = await resolveHostedReviewBodyForGeneration({ - body: args.body, - repoPath: worktreePath, - connectionId: args.connectionId, - provider: args.provider, - useTemplate: args.useTemplate - }) - context = await getPullRequestDraftContext( - (argv, options) => - gitExecFileAsync(argv, { - cwd: worktreePath, - ...gitOptions, - ...(options?.maxBuffer === undefined ? {} : { maxBuffer: options.maxBuffer }), - ...(options?.timeoutMs === undefined && options?.timeout === undefined - ? {} - : { timeout: options?.timeoutMs ?? options?.timeout }) - }), - { - base: args.base, - currentTitle: args.title, - currentBody, - currentDraft: args.draft - } - ) - } catch (error) { - return { - success: false, - error: error instanceof Error ? error.message : 'Failed to prepare branch for PR details.' - } - } - if (!context) { - return { success: false, error: 'No branch changes to summarize.' } - } - const linkedIssueDetails = await linkedIssueDetailsPromise - context = { - ...withLinkedIssueDraftContext(context, issueMeta?.linkedIssue), - ...(args.provider ? { provider: args.provider } : {}), - ...(linkedIssueDetails ? { linkedIssueDetails } : {}) - } - const localEnv = await prepareLocalCommitMessageAgentEnv( - resolvedSettings.params.agentId, - commitMessageAgentEnv, - getLocalAgentRuntimeTarget(gitOptions) - ) - if (!localEnv.ok) { - return { success: false, error: localEnv.error } - } - return generatePullRequestFieldsFromContext( - context, - resolvedSettings.params, - getLocalTextGenerationTarget(worktreePath, gitOptions, localEnv.env) - ) - } - ) - - ipcMain.handle( - 'git:cancelGeneratePullRequestFields', - async (_event, args: { worktreePath: string; connectionId?: string }): Promise => { - if (args.connectionId) { - const provider = getSshGitProvider(args.connectionId) - if (!provider) { - return - } - await provider.cancelGenerateCommitMessage(args.worktreePath, 'pull-request-fields') - return - } - const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) - cancelGeneratePullRequestFieldsLocal(worktreePath) - } - ) - - ipcMain.handle( - 'git:branchCompare', - async ( - _event, - args: { - worktreePath: string - baseRef: string - connectionId?: string - admissionTier?: GitAdmissionTier - } - ): Promise => { - if (args.connectionId) { - const provider = getSshGitProvider(args.connectionId) - if (!provider) { - throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) - } - return args.admissionTier - ? provider.getBranchCompare(args.worktreePath, args.baseRef, { - admissionTier: args.admissionTier - }) - : provider.getBranchCompare(args.worktreePath, args.baseRef) - } - const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) - const gitOptions = getLocalGitOptionsForRegisteredWorktree( - store, - args.worktreePath, - worktreePath - ) - return getBranchCompare(worktreePath, args.baseRef, { - ...gitOptions, - ...(args.admissionTier ? { admissionTier: args.admissionTier } : {}) - }) - } - ) - - ipcMain.handle( - 'git:commitCompare', - async ( - _event, - args: { worktreePath: string; commitId: string; connectionId?: string } - ): Promise => { - const commitId = validateFullGitObjectId(args.commitId, 'commitId') - if (args.connectionId) { - const provider = getSshGitProvider(args.connectionId) - if (!provider) { - throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) - } - return provider.getCommitCompare(args.worktreePath, commitId) - } - const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) - const gitOptions = getLocalGitOptionsForRegisteredWorktree( - store, - args.worktreePath, - worktreePath - ) - return getCommitCompare(worktreePath, commitId, gitOptions) - } - ) - - ipcMain.handle( - 'git:upstreamStatus', - async ( - _event, - args: { worktreePath: string; connectionId?: string; pushTarget?: GitPushTarget } - ): Promise => { - if (args.connectionId) { - if (args.pushTarget) { - assertGitPushTargetShape(args.pushTarget) - } - const provider = getSshGitProvider(args.connectionId) - if (!provider) { - throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) - } - return provider.getUpstreamStatus(args.worktreePath, args.pushTarget) - } - const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) - const gitOptions = getLocalGitOptionsForRegisteredWorktree( - store, - args.worktreePath, - worktreePath - ) - return getUpstreamStatus(worktreePath, args.pushTarget, gitOptions) - } - ) - - ipcMain.handle( - 'git:fetch', - async ( - _event, - args: { worktreePath: string; connectionId?: string; pushTarget?: GitPushTarget } - ): Promise => { - if (args.connectionId) { - if (args.pushTarget) { - assertGitPushTargetShape(args.pushTarget) - } - const provider = getSshGitProvider(args.connectionId) - if (!provider) { - throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) - } - return provider.fetchRemote(args.worktreePath, args.pushTarget) - } - const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) - const gitOptions = getLocalGitOptionsForRegisteredWorktree( - store, - args.worktreePath, - worktreePath - ) - if (args.pushTarget) { - await validateGitPushTarget(worktreePath, args.pushTarget, { - ...gitOptions, - admissionTier: 'interactive' - }) - } - await gitFetch(worktreePath, args.pushTarget, { - ...gitOptions, - admissionTier: 'interactive' - }) - } - ) - - ipcMain.handle( - 'git:syncFork', - async ( - _event, - args: { - worktreePath: string - connectionId?: string - expectedUpstream: GitForkSyncExpectedUpstream - } - ): Promise => { - const expectedUpstream = validateGitForkSyncExpectedUpstream(args.expectedUpstream, { - required: true - }) - if (args.connectionId) { - const provider = getSshGitProvider(args.connectionId) - if (!provider) { - throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) - } - return provider.syncForkDefaultBranch(args.worktreePath, expectedUpstream) - } - const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) - const gitOptions = getLocalGitOptionsForRegisteredWorktree( - store, - args.worktreePath, - worktreePath - ) - return gitSyncForkDefaultBranch(worktreePath, expectedUpstream, { - ...gitOptions, - admissionTier: 'interactive' - }) - } - ) - - ipcMain.handle( - 'git:push', - async ( - _event, - args: { - worktreePath: string - publish?: boolean - forceWithLease?: boolean - connectionId?: string - pushTarget?: GitPushTarget - } - ): Promise => { - // Why: coerce to strict boolean so a malformed payload (e.g. string 'false') can't enable --set-upstream; mirror in src/relay/git-handler.ts. - const publish = args.publish === true - if (args.connectionId) { - if (args.pushTarget) { - assertGitPushTargetShape(args.pushTarget) - } - const provider = getSshGitProvider(args.connectionId) - if (!provider) { - throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) - } - return provider.pushBranch(args.worktreePath, publish, args.pushTarget, { - forceWithLease: args.forceWithLease === true - }) - } - const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) - const gitOptions = getLocalGitOptionsForRegisteredWorktree( - store, - args.worktreePath, - worktreePath - ) - if (args.pushTarget) { - await validateGitPushTarget(worktreePath, args.pushTarget, { - ...gitOptions, - admissionTier: 'interactive' - }) - } - await gitPush(worktreePath, publish, args.pushTarget, { - forceWithLease: args.forceWithLease === true, - ...gitOptions, - admissionTier: 'interactive' - }) - } - ) - - ipcMain.handle( - 'git:pull', - async ( - _event, - args: { worktreePath: string; connectionId?: string; pushTarget?: GitPushTarget } - ): Promise => { - if (args.connectionId) { - if (args.pushTarget) { - assertGitPushTargetShape(args.pushTarget) - } - const provider = getSshGitProvider(args.connectionId) - if (!provider) { - throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) - } - return provider.pullBranch(args.worktreePath, args.pushTarget) - } - const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) - const gitOptions = getLocalGitOptionsForRegisteredWorktree( - store, - args.worktreePath, - worktreePath - ) - if (args.pushTarget) { - await validateGitPushTarget(worktreePath, args.pushTarget, { - ...gitOptions, - admissionTier: 'interactive' - }) - } - await gitPull(worktreePath, args.pushTarget, { - ...gitOptions, - admissionTier: 'interactive' - }) - } - ) - - ipcMain.handle( - 'git:fastForward', - async ( - _event, - args: { worktreePath: string; connectionId?: string; pushTarget?: GitPushTarget } - ): Promise => { - if (args.connectionId) { - if (args.pushTarget) { - assertGitPushTargetShape(args.pushTarget) - } - const provider = getSshGitProvider(args.connectionId) - if (!provider) { - throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) - } - return provider.fastForwardBranch(args.worktreePath, args.pushTarget) - } - const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) - const gitOptions = getLocalGitOptionsForRegisteredWorktree( - store, - args.worktreePath, - worktreePath - ) - if (args.pushTarget) { - await validateGitPushTarget(worktreePath, args.pushTarget, { - ...gitOptions, - admissionTier: 'interactive' - }) - } - await gitFastForward(worktreePath, args.pushTarget, { - ...gitOptions, - admissionTier: 'interactive' - }) - } - ) - - ipcMain.handle( - 'git:rebaseFromBase', - async ( - _event, - args: { worktreePath: string; baseRef: string; connectionId?: string } - ): Promise => { - if (args.connectionId) { - const provider = getSshGitProvider(args.connectionId) - if (!provider) { - throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) - } - return provider.rebaseFromBase(args.worktreePath, args.baseRef) - } - const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) - const gitOptions = getLocalGitOptionsForRegisteredWorktree( - store, - args.worktreePath, - worktreePath - ) - await gitPullRebaseFromBase(worktreePath, args.baseRef, { - ...gitOptions, - admissionTier: 'interactive' - }) - } - ) - - ipcMain.handle( - 'git:branchDiff', - async ( - _event, - args: { - worktreePath: string - compare: { - baseRef: string - baseOid: string - headOid: string - mergeBase: string - } - filePath: string - oldPath?: string - connectionId?: string - } - ): Promise => { - if (args.connectionId) { - const provider = getSshGitProvider(args.connectionId) - if (!provider) { - throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) - } - const results = await provider.getBranchDiff(args.worktreePath, args.compare.mergeBase, { - includePatch: true, - headOid: args.compare.headOid, - filePath: args.filePath, - oldPath: args.oldPath - }) - return ( - results[0] ?? { - kind: 'text', - originalContent: '', - modifiedContent: '', - originalIsBinary: false, - modifiedIsBinary: false - } - ) - } - const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) - const filePath = validateGitRelativeFilePath(worktreePath, args.filePath) - const oldPath = args.oldPath - ? validateGitRelativeFilePath(worktreePath, args.oldPath) - : undefined - const gitOptions = getLocalGitOptionsForRegisteredWorktree( - store, - args.worktreePath, - worktreePath - ) - return getBranchDiff( - worktreePath, - { - mergeBase: args.compare.mergeBase, - headOid: args.compare.headOid, - filePath, - oldPath - }, - { ...gitOptions, admissionTier: 'interactive' } - ) - } - ) - - ipcMain.handle( - 'git:commitDiff', - async ( - _event, - args: { - worktreePath: string - commitOid: string - parentOid?: string | null - filePath: string - oldPath?: string - connectionId?: string - } - ): Promise => { - const commitOid = validateFullGitObjectId(args.commitOid, 'commitOid') - const parentOid = args.parentOid ? validateFullGitObjectId(args.parentOid, 'parentOid') : null - if (args.connectionId) { - const provider = getSshGitProvider(args.connectionId) - if (!provider) { - throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) - } - return provider.getCommitDiff(args.worktreePath, { - commitOid, - parentOid, - filePath: args.filePath, - oldPath: args.oldPath - }) - } - const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) - const filePath = validateGitRelativeFilePath(worktreePath, args.filePath) - const oldPath = args.oldPath - ? validateGitRelativeFilePath(worktreePath, args.oldPath) - : undefined - const gitOptions = getLocalGitOptionsForRegisteredWorktree( - store, - args.worktreePath, - worktreePath - ) - return getCommitDiff( - worktreePath, - { - commitOid, - parentOid, - filePath, - oldPath - }, - { ...gitOptions, admissionTier: 'interactive' } - ) - } - ) - - ipcMain.handle( - 'git:stage', - async ( - _event, - args: { worktreePath: string; filePath: string; connectionId?: string } - ): Promise => { - if (args.connectionId) { - const provider = getSshGitProvider(args.connectionId) - if (!provider) { - throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) - } - return provider.stageFile(args.worktreePath, args.filePath) - } - const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) - const filePath = validateGitRelativeFilePath(worktreePath, args.filePath) - const gitOptions = getLocalGitOptionsForRegisteredWorktree( - store, - args.worktreePath, - worktreePath - ) - await stageFile(worktreePath, filePath, { ...gitOptions, admissionTier: 'interactive' }) - } - ) - - ipcMain.handle( - 'git:unstage', - async ( - _event, - args: { worktreePath: string; filePath: string; connectionId?: string } - ): Promise => { - if (args.connectionId) { - const provider = getSshGitProvider(args.connectionId) - if (!provider) { - throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) - } - return provider.unstageFile(args.worktreePath, args.filePath) - } - const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) - const filePath = validateGitRelativeFilePath(worktreePath, args.filePath) - const gitOptions = getLocalGitOptionsForRegisteredWorktree( - store, - args.worktreePath, - worktreePath - ) - await unstageFile(worktreePath, filePath, { ...gitOptions, admissionTier: 'interactive' }) - } - ) - - ipcMain.handle( - 'git:discard', - async ( - _event, - args: { worktreePath: string; filePath: string; connectionId?: string } - ): Promise => { - if (args.connectionId) { - const provider = getSshGitProvider(args.connectionId) - if (!provider) { - throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) - } - return provider.discardChanges(args.worktreePath, args.filePath) - } - const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) - const filePath = validateGitRelativeFilePath(worktreePath, args.filePath) - const gitOptions = getLocalGitOptionsForRegisteredWorktree( - store, - args.worktreePath, - worktreePath - ) - await discardChanges(worktreePath, filePath, { - ...gitOptions, - admissionTier: 'interactive' - }) - } - ) - - ipcMain.handle( - 'git:bulkDiscard', - async ( - _event, - args: { worktreePath: string; filePaths: string[]; connectionId?: string } - ): Promise => { - if (args.connectionId) { - const provider = getSshGitProvider(args.connectionId) - if (!provider) { - throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) - } - return provider.bulkDiscardChanges(args.worktreePath, args.filePaths) - } - const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) - const filePaths = args.filePaths.map((p) => validateGitRelativeFilePath(worktreePath, p)) - const gitOptions = getLocalGitOptionsForRegisteredWorktree( - store, - args.worktreePath, - worktreePath - ) - await bulkDiscardChanges(worktreePath, filePaths, { - ...gitOptions, - admissionTier: 'interactive' - }) - } - ) - - ipcMain.handle( - 'git:bulkStage', - async ( - _event, - args: { worktreePath: string; filePaths: string[]; connectionId?: string } - ): Promise => { - if (args.connectionId) { - const provider = getSshGitProvider(args.connectionId) - if (!provider) { - throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) - } - return provider.bulkStageFiles(args.worktreePath, args.filePaths) - } - const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) - const filePaths = args.filePaths.map((p) => validateGitRelativeFilePath(worktreePath, p)) - const gitOptions = getLocalGitOptionsForRegisteredWorktree( - store, - args.worktreePath, - worktreePath - ) - await bulkStageFiles(worktreePath, filePaths, { - ...gitOptions, - admissionTier: 'interactive' - }) - } - ) - - ipcMain.handle( - 'git:bulkUnstage', - async ( - _event, - args: { worktreePath: string; filePaths: string[]; connectionId?: string } - ): Promise => { - if (args.connectionId) { - const provider = getSshGitProvider(args.connectionId) - if (!provider) { - throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) - } - return provider.bulkUnstageFiles(args.worktreePath, args.filePaths) - } - const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) - const filePaths = args.filePaths.map((p) => validateGitRelativeFilePath(worktreePath, p)) - const gitOptions = getLocalGitOptionsForRegisteredWorktree( - store, - args.worktreePath, - worktreePath - ) - await bulkUnstageFiles(worktreePath, filePaths, { - ...gitOptions, - admissionTier: 'interactive' - }) - } - ) - - ipcMain.handle( - 'git:remoteFileUrl', - async ( - _event, - args: { worktreePath: string; relativePath: string; line: number; connectionId?: string } - ): Promise => { - // Why: remote repos can't read relay-side .git/config locally; delegate URL construction to the SSH provider. - if (args.connectionId) { - const provider = getSshGitProvider(args.connectionId) - if (!provider) { - throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) - } - return provider.getRemoteFileUrl(args.worktreePath, args.relativePath, args.line) - } - const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) - await awaitWindowsHostGitEnvironmentReady({ cwd: worktreePath }) - return getRemoteFileUrl(worktreePath, args.relativePath, args.line) - } - ) - - ipcMain.handle( - 'git:remoteCommitUrl', - async ( - _event, - args: { worktreePath: string; sha: string; connectionId?: string } - ): Promise => { - const sha = validateFullGitObjectId(args.sha, 'sha') - // Why: remote repos can't read relay-side .git/config locally; delegate URL construction to the SSH provider. - if (args.connectionId) { - const provider = getSshGitProvider(args.connectionId) - if (!provider) { - throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) - } - return provider.getRemoteCommitUrl(args.worktreePath, sha) - } - const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) - await awaitWindowsHostGitEnvironmentReady({ cwd: worktreePath }) - return getRemoteCommitUrl(worktreePath, sha) - } + const context: FilesystemHandlerContext = createFilesystemHandlerContext( + store, + commitMessageAgentEnv, + createSenderScopedRequestCancellations(), + createSenderScopedRequestCancellations() ) + registerFilesystemReadHandlers(context) + registerFilesystemDownloadHandlers(context) + registerFilesystemWriteHandlers(context) + registerFilesystemSearchHandlers(context) + registerFilesystemGitStatusHandlers(context) + registerFilesystemGitCommitHandlers(context) + registerFilesystemGitCommitGenerationHandlers(context) + registerFilesystemGitModelDiscoveryHandlers(context) + registerFilesystemGitPullRequestGenerationHandlers(context) + registerFilesystemGitRemoteHandlers(context) + registerFilesystemGitDiffHandlers(context) + registerFilesystemGitIndexHandlers(context) + registerFilesystemGitUrlHandlers(context) registerLocalLogTailHandlers(store) } diff --git a/src/main/ipc/filesystem/filesystem-download-handlers.ts b/src/main/ipc/filesystem/filesystem-download-handlers.ts new file mode 100644 index 00000000000..6b73df909da --- /dev/null +++ b/src/main/ipc/filesystem/filesystem-download-handlers.ts @@ -0,0 +1,216 @@ +import { BrowserWindow, dialog, ipcMain } from 'electron' +import { randomUUID } from 'node:crypto' +import { open, writeFile } from 'node:fs/promises' +import { getRuntimePathBasename } from '../../../shared/cross-platform-path' +import { requireSshFilesystemProvider } from '../../providers/ssh-filesystem-dispatch' +import { sanitizeLocalDownloadFilename } from '../../local-download-filename' +import { registerFilesystemDownloadFolderHandlers } from '../filesystem-download-folder' +import type { FilesystemHandlerContext } from './filesystem-handler-context' +import { + cleanupLocalTransferPath, + decodeDownloadedFileContent, + DOWNLOAD_SESSION_TTL_MS, + inspectDownloadDestination, + promoteDownloadedFile, + createSiblingTransferPath, + type DownloadFileResult +} from './filesystem-download-promotion' + +function validateRequiredString(value: unknown, label: string): string { + if (typeof value !== 'string' || value.trim() === '') { + throw new Error(`${label} is required`) + } + return value +} + +export function registerFilesystemDownloadHandlers(context: FilesystemHandlerContext): void { + const { downloadSessions, closeDownloadSession, cleanupDownloadSessionsForSender } = context + + ipcMain.handle( + 'fs:downloadFile', + async ( + event, + args: { filePath?: string; connectionId?: string } + ): Promise => { + const filePath = validateRequiredString(args?.filePath, 'filePath') + const connectionId = validateRequiredString(args?.connectionId, 'connectionId') + const provider = requireSshFilesystemProvider(connectionId) + const remoteStat = await provider.stat(filePath) + if (remoteStat.type === 'directory') { + throw new Error('Cannot download a directory') + } + if (!provider.downloadFile) { + throw new Error('Remote file download is unavailable. Reconnect the SSH target and retry.') + } + + const remoteBasename = getRuntimePathBasename(filePath) + const defaultPath = sanitizeLocalDownloadFilename(remoteBasename) + const parentWindow = BrowserWindow.fromWebContents(event.sender) ?? undefined + const dialogResult = parentWindow + ? await dialog.showSaveDialog(parentWindow, { defaultPath }) + : await dialog.showSaveDialog({ defaultPath }) + if (dialogResult.canceled || !dialogResult.filePath) { + return { canceled: true } + } + + const destinationPath = dialogResult.filePath + const { existed } = await inspectDownloadDestination(destinationPath) + const tempPath = createSiblingTransferPath(destinationPath, 'download') + let promoted = false + try { + await provider.downloadFile(filePath, tempPath) + await promoteDownloadedFile(tempPath, destinationPath, existed) + promoted = true + return { canceled: false, destinationPath } + } finally { + if (!promoted) { + await cleanupLocalTransferPath(tempPath) + } + } + } + ) + + registerFilesystemDownloadFolderHandlers() + + ipcMain.handle( + 'fs:saveDownloadedFile', + async ( + event, + args: { suggestedName?: string; content?: string; encoding?: 'utf8' | 'base64' } + ): Promise => { + const suggestedName = sanitizeLocalDownloadFilename( + validateRequiredString(args?.suggestedName, 'suggestedName') + ) + if (typeof args?.content !== 'string') { + throw new Error('content is required') + } + const content = args.content + const encoding = args?.encoding === 'base64' ? 'base64' : 'utf8' + const parentWindow = BrowserWindow.fromWebContents(event.sender) ?? undefined + const dialogResult = parentWindow + ? await dialog.showSaveDialog(parentWindow, { defaultPath: suggestedName }) + : await dialog.showSaveDialog({ defaultPath: suggestedName }) + if (dialogResult.canceled || !dialogResult.filePath) { + return { canceled: true } + } + + const destinationPath = dialogResult.filePath + const { existed } = await inspectDownloadDestination(destinationPath) + const tempPath = createSiblingTransferPath(destinationPath, 'download') + let promoted = false + try { + await writeFile(tempPath, decodeDownloadedFileContent(content, encoding)) + await promoteDownloadedFile(tempPath, destinationPath, existed) + promoted = true + return { canceled: false, destinationPath } + } finally { + if (!promoted) { + await cleanupLocalTransferPath(tempPath) + } + } + } + ) + + ipcMain.handle( + 'fs:startDownloadedFile', + async ( + event, + args: { suggestedName?: string } + ): Promise< + { canceled: true } | { canceled: false; transferId: string; destinationPath: string } + > => { + const suggestedName = sanitizeLocalDownloadFilename( + validateRequiredString(args?.suggestedName, 'suggestedName') + ) + const parentWindow = BrowserWindow.fromWebContents(event.sender) ?? undefined + const dialogResult = parentWindow + ? await dialog.showSaveDialog(parentWindow, { defaultPath: suggestedName }) + : await dialog.showSaveDialog({ defaultPath: suggestedName }) + if (dialogResult.canceled || !dialogResult.filePath) { + return { canceled: true } + } + + const destinationPath = dialogResult.filePath + const { existed } = await inspectDownloadDestination(destinationPath) + const tempPath = createSiblingTransferPath(destinationPath, 'download') + const transferId = randomUUID() + try { + const handle = await open(tempPath, 'wx') + const senderId = typeof event.sender.id === 'number' ? event.sender.id : Number.NaN + const cleanupTimer = setTimeout(() => { + void closeDownloadSession(transferId, true) + }, DOWNLOAD_SESSION_TTL_MS) + if (typeof cleanupTimer.unref === 'function') { + cleanupTimer.unref() + } + downloadSessions.set(transferId, { + destinationPath, + tempPath, + destinationExisted: existed, + handle, + cleanupTimer, + senderId + }) + event.sender.once?.('destroyed', () => cleanupDownloadSessionsForSender(senderId)) + return { canceled: false, transferId, destinationPath } + } catch (error) { + await cleanupLocalTransferPath(tempPath) + throw error + } + } + ) + + ipcMain.handle( + 'fs:appendDownloadedFileChunk', + async ( + _event, + args: { transferId?: string; contentBase64?: string } + ): Promise<{ ok: true }> => { + const transferId = validateRequiredString(args?.transferId, 'transferId') + const contentBase64 = validateRequiredString(args?.contentBase64, 'contentBase64') + const session = downloadSessions.get(transferId) + if (!session) { + throw new Error('Download session not found') + } + await session.handle.writeFile(Buffer.from(contentBase64, 'base64')) + return { ok: true } + } + ) + + ipcMain.handle( + 'fs:finishDownloadedFile', + async ( + _event, + args: { transferId?: string } + ): Promise<{ canceled: false; destinationPath: string }> => { + const transferId = validateRequiredString(args?.transferId, 'transferId') + const session = await closeDownloadSession(transferId, false) + if (!session) { + throw new Error('Download session not found') + } + let promoted = false + try { + await promoteDownloadedFile( + session.tempPath, + session.destinationPath, + session.destinationExisted + ) + promoted = true + return { canceled: false, destinationPath: session.destinationPath } + } finally { + if (!promoted) { + await cleanupLocalTransferPath(session.tempPath) + } + } + } + ) + + ipcMain.handle( + 'fs:cancelDownloadedFile', + async (_event, args: { transferId?: string }): Promise<{ ok: true }> => { + const transferId = validateRequiredString(args?.transferId, 'transferId') + await closeDownloadSession(transferId, true) + return { ok: true } + } + ) +} diff --git a/src/main/ipc/filesystem/filesystem-download-promotion.ts b/src/main/ipc/filesystem/filesystem-download-promotion.ts new file mode 100644 index 00000000000..9fbc8acddd2 --- /dev/null +++ b/src/main/ipc/filesystem/filesystem-download-promotion.ts @@ -0,0 +1,79 @@ +import { randomUUID } from 'node:crypto' +import { rename, rm, stat } from 'node:fs/promises' +import { dirname, join } from 'node:path' +import { isENOENT } from '../filesystem-path-containment' + +export function decodeDownloadedFileContent(content: string, encoding: 'utf8' | 'base64'): Buffer { + return encoding === 'base64' ? Buffer.from(content, 'base64') : Buffer.from(content, 'utf8') +} + +export function createSiblingTransferPath(destinationPath: string, suffix: string): string { + // Why: promotion renames must stay on the destination volume, so transfer paths remain siblings. + return join(dirname(destinationPath), `.${randomUUID()}.${suffix}`) +} + +export async function cleanupLocalTransferPath(filePath: string | null): Promise { + if (!filePath) { + return + } + await rm(filePath, { force: true }).catch(() => {}) +} + +export async function inspectDownloadDestination( + destinationPath: string +): Promise<{ existed: boolean }> { + try { + const destinationStat = await stat(destinationPath) + if (destinationStat.isDirectory()) { + throw new Error('Cannot download to a directory') + } + return { existed: true } + } catch (error) { + if (isENOENT(error)) { + return { existed: false } + } + throw error + } +} + +export async function assertDestinationStillUnclaimed(destinationPath: string): Promise { + try { + await stat(destinationPath) + } catch (error) { + if (isENOENT(error)) { + return + } + throw error + } + throw new Error('Destination file appeared before download completed') +} + +export async function promoteDownloadedFile( + tempPath: string, + destinationPath: string, + destinationExisted: boolean +): Promise { + if (!destinationExisted) { + await assertDestinationStillUnclaimed(destinationPath) + await rename(tempPath, destinationPath) + return + } + + const backupPath = createSiblingTransferPath(destinationPath, 'backup') + let backupCreated = false + try { + await rename(destinationPath, backupPath) + backupCreated = true + await rename(tempPath, destinationPath) + await cleanupLocalTransferPath(backupPath) + } catch (error) { + if (backupCreated) { + await rename(backupPath, destinationPath).catch(() => {}) + } + throw error + } +} + +export type DownloadFileResult = { canceled: true } | { canceled: false; destinationPath: string } + +export const DOWNLOAD_SESSION_TTL_MS = 30 * 60 * 1000 diff --git a/src/main/ipc/filesystem/filesystem-file-content-inspection.ts b/src/main/ipc/filesystem/filesystem-file-content-inspection.ts new file mode 100644 index 00000000000..b140532c372 --- /dev/null +++ b/src/main/ipc/filesystem/filesystem-file-content-inspection.ts @@ -0,0 +1,85 @@ +import { open } from 'node:fs/promises' +import type { FileHandle } from 'node:fs/promises' +import { localLogFileIdentity } from '../../ai-vault/local-log-tail-reader' + +// Why: Monaco degrades features on large files like VS Code, so a 5MB block would needlessly lock out ordinary JSON/log files. +export const MAX_TEXT_FILE_SIZE = 50 * 1024 * 1024 // 50MB +export const BINARY_PROBE_BYTES = 8192 +// Why: previewable binaries are base64 blobs (not parsed as text), and local IPC has no frame limit (unlike the relay's 10MB), so 50MB is safe. +export const MAX_PREVIEWABLE_BINARY_SIZE = 50 * 1024 * 1024 // 50MB +export const PREVIEWABLE_BINARY_MIME_TYPES: Record = { + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.gif': 'image/gif', + '.svg': 'image/svg+xml', + '.webp': 'image/webp', + '.bmp': 'image/bmp', + '.ico': 'image/x-icon', + '.pdf': 'application/pdf' +} + +export async function readLocalLogSnapshot(filePath: string): Promise<{ + content: string + isBinary: boolean + fileIdentity?: string +}> { + const handle = await open(filePath, 'r') + try { + const stats = await handle.stat() + if (stats.size > MAX_TEXT_FILE_SIZE) { + throw new Error( + `File too large: ${(stats.size / 1024 / 1024).toFixed(1)}MB exceeds ${MAX_TEXT_FILE_SIZE / 1024 / 1024}MB limit` + ) + } + const buffer = await handle.readFile() + if (buffer.byteLength > MAX_TEXT_FILE_SIZE) { + throw new Error( + `File too large: ${(buffer.byteLength / 1024 / 1024).toFixed(1)}MB exceeds ${MAX_TEXT_FILE_SIZE / 1024 / 1024}MB limit` + ) + } + if (isBinaryBuffer(buffer)) { + return { content: '', isBinary: true } + } + return { + content: buffer.toString('utf8'), + isBinary: false, + fileIdentity: localLogFileIdentity(stats) + } + } finally { + await handle.close() + } +} + +/** Check if a buffer appears to be binary (contains null bytes in first 8KB). */ +export function isBinaryBuffer(buffer: Buffer): boolean { + const len = Math.min(buffer.length, BINARY_PROBE_BYTES) + for (let i = 0; i < len; i++) { + if (buffer[i] === 0) { + return true + } + } + return false +} + +export async function isBinaryFilePrefix(filePath: string): Promise { + const handle: FileHandle = await open(filePath, 'r') + try { + const probe = Buffer.alloc(BINARY_PROBE_BYTES) + const { bytesRead } = await handle.read(probe, 0, probe.length, 0) + return isBinaryBuffer(probe.subarray(0, bytesRead)) + } finally { + await handle.close() + } +} + +export function isDirectoryEntry(entry: { + isDirectory(): boolean + isSymbolicLink(): boolean +}): boolean { + // Why: following a symlink in readDir can touch macOS TCC-protected containers; treat links as file-like until explicitly opened. + if (entry.isSymbolicLink()) { + return false + } + return entry.isDirectory() +} diff --git a/src/main/ipc/filesystem/filesystem-git-commit-generation-handlers.ts b/src/main/ipc/filesystem/filesystem-git-commit-generation-handlers.ts new file mode 100644 index 00000000000..ae029dc2f41 --- /dev/null +++ b/src/main/ipc/filesystem/filesystem-git-commit-generation-handlers.ts @@ -0,0 +1,157 @@ +import { ipcMain } from 'electron' +import type { GlobalSettings } from '../../../shared/global-settings-types' +import type { ResolvedSourceControlAiGenerationParams } from '../../../shared/source-control-ai' +import { + cancelGenerateCommitMessageLocal, + generateCommitMessageFromContext, + resolveCommitMessageSettings, + type GenerateCommitMessageResult +} from '../../text-generation/commit-message-text-generation' +import { getCommitMessageModelDiscoveryHostKey } from '../../../shared/commit-message-host-key' +import { getStagedCommitContext } from '../../git/status' +import { + getSshGitProvider, + SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE +} from '../../providers/ssh-git-dispatch' +import { resolveRegisteredWorktreePath } from '../registered-worktree-roots-cache' +import { getLocalGitOptionsForRegisteredWorktree } from '../local-worktree-runtime-options' +import { withLinkedIssueDraftContext } from '../../../shared/source-control-ai-action-variables' +import { resolveSourceControlAiLinkedIssue } from '../source-control-ai-linked-issue' +import { prepareLocalCommitMessageAgentEnv } from '../../text-generation/commit-message-agent-environment' +import type { FilesystemHandlerContext } from './filesystem-handler-context' +import { + getLocalAgentRuntimeTarget, + getLocalTextGenerationTarget, + getRepoForSourceControlAi +} from './filesystem-source-control-ai-targets' + +export function registerFilesystemGitCommitGenerationHandlers( + context: FilesystemHandlerContext +): void { + const { store, commitMessageAgentEnv } = context + ipcMain.handle( + 'git:generateCommitMessage', + async ( + _event, + args: { + worktreePath: string + // Raw (unstripped) meta key; validated against worktreePath before any meta read. + worktreeId?: string + repoId?: string + connectionId?: string + sourceControlAiResolvedParams?: ResolvedSourceControlAiGenerationParams + sourceControlAi?: GlobalSettings['sourceControlAi'] + agentCmdOverrides?: GlobalSettings['agentCmdOverrides'] + } + ): Promise => { + const discoveryHostKey = getCommitMessageModelDiscoveryHostKey(args.connectionId ?? null) + const baseSettings = store.getSettings() + const requestSettings = { + ...baseSettings, + ...(args.sourceControlAi !== undefined ? { sourceControlAi: args.sourceControlAi } : {}), + ...(args.agentCmdOverrides !== undefined + ? { agentCmdOverrides: args.agentCmdOverrides } + : {}) + } + const resolvedSettings = args.sourceControlAiResolvedParams + ? { ok: true as const, params: args.sourceControlAiResolvedParams } + : resolveCommitMessageSettings( + requestSettings, + discoveryHostKey, + 'commitMessage', + await getRepoForSourceControlAi(store, args) + ) + if (!resolvedSettings.ok) { + return { success: false, error: resolvedSettings.error } + } + if (args.connectionId) { + const provider = getSshGitProvider(args.connectionId) + if (!provider) { + return { + success: false, + error: SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE + } + } + let context + try { + context = await provider.getStagedCommitContext(args.worktreePath) + } catch (error) { + console.error('[filesystem] Failed to read remote staged commit context:', error) + return { + success: false, + error: 'Failed to read staged changes.' + } + } + if (!context) { + return { success: false, error: 'No staged changes to summarize.' } + } + context = withLinkedIssueDraftContext( + context, + resolveSourceControlAiLinkedIssue(store, args) + ) + return generateCommitMessageFromContext(context, resolvedSettings.params, { + kind: 'remote', + cwd: args.worktreePath, + execute: (plan, cwd, timeoutMs, operation) => + provider.executeCommitMessagePlan(plan, cwd, timeoutMs, operation), + missingBinaryLocation: 'remote PATH' + }) + } + const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) + const gitOptions = getLocalGitOptionsForRegisteredWorktree( + store, + args.worktreePath, + worktreePath + ) + let context + try { + context = await getStagedCommitContext(worktreePath, { + ...gitOptions, + admissionTier: 'interactive' + }) + } catch (error) { + console.error('[filesystem] Failed to read staged commit context:', error) + return { + success: false, + error: 'Failed to read staged changes.' + } + } + if (!context) { + return { success: false, error: 'No staged changes to summarize.' } + } + context = withLinkedIssueDraftContext( + context, + resolveSourceControlAiLinkedIssue(store, args, worktreePath) + ) + const localEnv = await prepareLocalCommitMessageAgentEnv( + resolvedSettings.params.agentId, + commitMessageAgentEnv, + getLocalAgentRuntimeTarget(gitOptions) + ) + if (!localEnv.ok) { + return { success: false, error: localEnv.error } + } + return generateCommitMessageFromContext( + context, + resolvedSettings.params, + getLocalTextGenerationTarget(worktreePath, gitOptions, localEnv.env) + ) + } + ) + + ipcMain.handle( + 'git:cancelGenerateCommitMessage', + async (_event, args: { worktreePath: string; connectionId?: string }): Promise => { + if (args.connectionId) { + const provider = getSshGitProvider(args.connectionId) + if (!provider) { + return + } + await provider.cancelGenerateCommitMessage(args.worktreePath, 'commit-message') + return + } + const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) + cancelGenerateCommitMessageLocal(worktreePath) + } + ) +} diff --git a/src/main/ipc/filesystem/filesystem-git-commit-handlers.ts b/src/main/ipc/filesystem/filesystem-git-commit-handlers.ts new file mode 100644 index 00000000000..8040a766c2a --- /dev/null +++ b/src/main/ipc/filesystem/filesystem-git-commit-handlers.ts @@ -0,0 +1,42 @@ +import { ipcMain } from 'electron' +import { commitChanges } from '../../git/status' +import { + getSshGitProvider, + SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE +} from '../../providers/ssh-git-dispatch' +import { resolveRegisteredWorktreePath } from '../registered-worktree-roots-cache' +import { getLocalGitOptionsForRegisteredWorktree } from '../local-worktree-runtime-options' +import type { FilesystemHandlerContext } from './filesystem-handler-context' + +export function registerFilesystemGitCommitHandlers(context: FilesystemHandlerContext): void { + const { store } = context + ipcMain.handle( + 'git:commit', + async ( + _event, + args: { worktreePath: string; message: string; connectionId?: string } + ): Promise<{ success: boolean; error?: string }> => { + // Why: validate at the IPC boundary so the renderer gets a clear error instead of an opaque execFile failure. + if (typeof args.message !== 'string' || args.message.trim().length === 0) { + throw new Error('Commit message is required') + } + if (args.connectionId) { + const provider = getSshGitProvider(args.connectionId) + if (!provider) { + throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) + } + return provider.commit(args.worktreePath, args.message) + } + const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) + const gitOptions = getLocalGitOptionsForRegisteredWorktree( + store, + args.worktreePath, + worktreePath + ) + return commitChanges(worktreePath, args.message, { + ...gitOptions, + admissionTier: 'interactive' + }) + } + ) +} diff --git a/src/main/ipc/filesystem/filesystem-git-diff-handlers.ts b/src/main/ipc/filesystem/filesystem-git-diff-handlers.ts new file mode 100644 index 00000000000..8434e02264b --- /dev/null +++ b/src/main/ipc/filesystem/filesystem-git-diff-handlers.ts @@ -0,0 +1,128 @@ +import { ipcMain } from 'electron' +import type { GitDiffResult } from '../../../shared/git-diff-compare-types' +import { getBranchDiff, getCommitDiff } from '../../git/status' +import { + getSshGitProvider, + SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE +} from '../../providers/ssh-git-dispatch' +import { resolveRegisteredWorktreePath } from '../registered-worktree-roots-cache' +import { getLocalGitOptionsForRegisteredWorktree } from '../local-worktree-runtime-options' +import { + validateFullGitObjectId, + validateGitRelativeFilePath +} from '../filesystem-path-containment' +import type { FilesystemHandlerContext } from './filesystem-handler-context' + +export function registerFilesystemGitDiffHandlers(context: FilesystemHandlerContext): void { + const { store } = context + ipcMain.handle( + 'git:branchDiff', + async ( + _event, + args: { + worktreePath: string + compare: { + baseRef: string + baseOid: string + headOid: string + mergeBase: string + } + filePath: string + oldPath?: string + connectionId?: string + } + ): Promise => { + if (args.connectionId) { + const provider = getSshGitProvider(args.connectionId) + if (!provider) { + throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) + } + const results = await provider.getBranchDiff(args.worktreePath, args.compare.mergeBase, { + includePatch: true, + headOid: args.compare.headOid, + filePath: args.filePath, + oldPath: args.oldPath + }) + return ( + results[0] ?? { + kind: 'text', + originalContent: '', + modifiedContent: '', + originalIsBinary: false, + modifiedIsBinary: false + } + ) + } + const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) + const filePath = validateGitRelativeFilePath(worktreePath, args.filePath) + const oldPath = args.oldPath + ? validateGitRelativeFilePath(worktreePath, args.oldPath) + : undefined + const gitOptions = getLocalGitOptionsForRegisteredWorktree( + store, + args.worktreePath, + worktreePath + ) + return getBranchDiff( + worktreePath, + { + mergeBase: args.compare.mergeBase, + headOid: args.compare.headOid, + filePath, + oldPath + }, + { ...gitOptions, admissionTier: 'interactive' } + ) + } + ) + + ipcMain.handle( + 'git:commitDiff', + async ( + _event, + args: { + worktreePath: string + commitOid: string + parentOid?: string | null + filePath: string + oldPath?: string + connectionId?: string + } + ): Promise => { + const commitOid = validateFullGitObjectId(args.commitOid, 'commitOid') + const parentOid = args.parentOid ? validateFullGitObjectId(args.parentOid, 'parentOid') : null + if (args.connectionId) { + const provider = getSshGitProvider(args.connectionId) + if (!provider) { + throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) + } + return provider.getCommitDiff(args.worktreePath, { + commitOid, + parentOid, + filePath: args.filePath, + oldPath: args.oldPath + }) + } + const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) + const filePath = validateGitRelativeFilePath(worktreePath, args.filePath) + const oldPath = args.oldPath + ? validateGitRelativeFilePath(worktreePath, args.oldPath) + : undefined + const gitOptions = getLocalGitOptionsForRegisteredWorktree( + store, + args.worktreePath, + worktreePath + ) + return getCommitDiff( + worktreePath, + { + commitOid, + parentOid, + filePath, + oldPath + }, + { ...gitOptions, admissionTier: 'interactive' } + ) + } + ) +} diff --git a/src/main/ipc/filesystem/filesystem-git-index-handlers.ts b/src/main/ipc/filesystem/filesystem-git-index-handlers.ts new file mode 100644 index 00000000000..fbd550c70e5 --- /dev/null +++ b/src/main/ipc/filesystem/filesystem-git-index-handlers.ts @@ -0,0 +1,173 @@ +import { ipcMain } from 'electron' +import { + stageFile, + unstageFile, + discardChanges, + bulkDiscardChanges, + bulkStageFiles, + bulkUnstageFiles +} from '../../git/status' +import { + getSshGitProvider, + SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE +} from '../../providers/ssh-git-dispatch' +import { resolveRegisteredWorktreePath } from '../registered-worktree-roots-cache' +import { getLocalGitOptionsForRegisteredWorktree } from '../local-worktree-runtime-options' +import { validateGitRelativeFilePath } from '../filesystem-path-containment' +import type { FilesystemHandlerContext } from './filesystem-handler-context' + +export function registerFilesystemGitIndexHandlers(context: FilesystemHandlerContext): void { + const { store } = context + ipcMain.handle( + 'git:stage', + async ( + _event, + args: { worktreePath: string; filePath: string; connectionId?: string } + ): Promise => { + if (args.connectionId) { + const provider = getSshGitProvider(args.connectionId) + if (!provider) { + throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) + } + return provider.stageFile(args.worktreePath, args.filePath) + } + const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) + const filePath = validateGitRelativeFilePath(worktreePath, args.filePath) + const gitOptions = getLocalGitOptionsForRegisteredWorktree( + store, + args.worktreePath, + worktreePath + ) + await stageFile(worktreePath, filePath, { ...gitOptions, admissionTier: 'interactive' }) + } + ) + + ipcMain.handle( + 'git:unstage', + async ( + _event, + args: { worktreePath: string; filePath: string; connectionId?: string } + ): Promise => { + if (args.connectionId) { + const provider = getSshGitProvider(args.connectionId) + if (!provider) { + throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) + } + return provider.unstageFile(args.worktreePath, args.filePath) + } + const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) + const filePath = validateGitRelativeFilePath(worktreePath, args.filePath) + const gitOptions = getLocalGitOptionsForRegisteredWorktree( + store, + args.worktreePath, + worktreePath + ) + await unstageFile(worktreePath, filePath, { ...gitOptions, admissionTier: 'interactive' }) + } + ) + + ipcMain.handle( + 'git:discard', + async ( + _event, + args: { worktreePath: string; filePath: string; connectionId?: string } + ): Promise => { + if (args.connectionId) { + const provider = getSshGitProvider(args.connectionId) + if (!provider) { + throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) + } + return provider.discardChanges(args.worktreePath, args.filePath) + } + const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) + const filePath = validateGitRelativeFilePath(worktreePath, args.filePath) + const gitOptions = getLocalGitOptionsForRegisteredWorktree( + store, + args.worktreePath, + worktreePath + ) + await discardChanges(worktreePath, filePath, { ...gitOptions, admissionTier: 'interactive' }) + } + ) + + ipcMain.handle( + 'git:bulkDiscard', + async ( + _event, + args: { worktreePath: string; filePaths: string[]; connectionId?: string } + ): Promise => { + if (args.connectionId) { + const provider = getSshGitProvider(args.connectionId) + if (!provider) { + throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) + } + return provider.bulkDiscardChanges(args.worktreePath, args.filePaths) + } + const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) + const filePaths = args.filePaths.map((p) => validateGitRelativeFilePath(worktreePath, p)) + const gitOptions = getLocalGitOptionsForRegisteredWorktree( + store, + args.worktreePath, + worktreePath + ) + await bulkDiscardChanges(worktreePath, filePaths, { + ...gitOptions, + admissionTier: 'interactive' + }) + } + ) + + ipcMain.handle( + 'git:bulkStage', + async ( + _event, + args: { worktreePath: string; filePaths: string[]; connectionId?: string } + ): Promise => { + if (args.connectionId) { + const provider = getSshGitProvider(args.connectionId) + if (!provider) { + throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) + } + return provider.bulkStageFiles(args.worktreePath, args.filePaths) + } + const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) + const filePaths = args.filePaths.map((p) => validateGitRelativeFilePath(worktreePath, p)) + const gitOptions = getLocalGitOptionsForRegisteredWorktree( + store, + args.worktreePath, + worktreePath + ) + await bulkStageFiles(worktreePath, filePaths, { + ...gitOptions, + admissionTier: 'interactive' + }) + } + ) + + ipcMain.handle( + 'git:bulkUnstage', + async ( + _event, + args: { worktreePath: string; filePaths: string[]; connectionId?: string } + ): Promise => { + if (args.connectionId) { + const provider = getSshGitProvider(args.connectionId) + if (!provider) { + throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) + } + return provider.bulkUnstageFiles(args.worktreePath, args.filePaths) + } + const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) + const filePaths = args.filePaths.map((p) => validateGitRelativeFilePath(worktreePath, p)) + const gitOptions = getLocalGitOptionsForRegisteredWorktree( + store, + args.worktreePath, + worktreePath + ) + await bulkUnstageFiles(worktreePath, filePaths, { + ...gitOptions, + admissionTier: 'interactive' + }) + } + ) +} diff --git a/src/main/ipc/filesystem/filesystem-git-model-discovery-handlers.ts b/src/main/ipc/filesystem/filesystem-git-model-discovery-handlers.ts new file mode 100644 index 00000000000..8de07853057 --- /dev/null +++ b/src/main/ipc/filesystem/filesystem-git-model-discovery-handlers.ts @@ -0,0 +1,82 @@ +import { ipcMain } from 'electron' +import type { TuiAgent } from '../../../shared/tui-agent' +import type { CommitMessageAgentRuntimeTarget } from '../../text-generation/commit-message-agent-environment' +import { + discoverCommitMessageModelsLocal, + discoverCommitMessageModelsRemote, + type DiscoverCommitMessageModelsResult +} from '../../text-generation/commit-message-text-generation' +import { prepareLocalCommitMessageAgentEnv } from '../../text-generation/commit-message-agent-environment' +import { parseWslPath } from '../../wsl' +import { getSshGitProvider } from '../../providers/ssh-git-dispatch' +import { + resolveModelDiscoveryLocalPath, + getLocalAgentRuntimeTarget +} from './filesystem-source-control-ai-targets' +import { getLocalGitOptionsForRegisteredWorktree } from '../local-worktree-runtime-options' +import type { FilesystemHandlerContext } from './filesystem-handler-context' + +export function registerFilesystemGitModelDiscoveryHandlers( + context: FilesystemHandlerContext +): void { + const { store, commitMessageAgentEnv } = context + ipcMain.handle( + 'git:discoverCommitMessageModels', + async ( + _event, + args: { agentId: string; worktreePath?: string; connectionId?: string } + ): Promise => { + const agentId = args.agentId + const agentCommandOverride = store.getSettings().agentCmdOverrides?.[agentId as TuiAgent] + if (args.connectionId) { + if (!args.worktreePath) { + return { success: false, error: 'Missing worktree path for remote model discovery.' } + } + const provider = getSshGitProvider(args.connectionId) + if (!provider) { + return { + success: false, + error: `No git provider for connection "${args.connectionId}"` + } + } + return discoverCommitMessageModelsRemote( + agentId as TuiAgent, + args.worktreePath, + (plan, cwd, timeoutMs) => provider.executeCommitMessagePlan(plan, cwd, timeoutMs), + agentCommandOverride + ) + } + let localRuntimeTarget: CommitMessageAgentRuntimeTarget = { runtime: 'host' } + let localDiscoveryOptions: Parameters[3] + if (args.worktreePath) { + const worktreePath = await resolveModelDiscoveryLocalPath(store, args.worktreePath) + const gitOptions = getLocalGitOptionsForRegisteredWorktree( + store, + args.worktreePath, + worktreePath + ) + const wslDistro = gitOptions.wslDistro ?? parseWslPath(args.worktreePath)?.distro + localRuntimeTarget = wslDistro + ? { runtime: 'wsl', wslDistro } + : getLocalAgentRuntimeTarget(gitOptions) + localDiscoveryOptions = wslDistro ? { cwd: worktreePath, wslDistro } : { cwd: worktreePath } + } + const localEnv = await prepareLocalCommitMessageAgentEnv( + agentId, + commitMessageAgentEnv, + localRuntimeTarget + ) + if (!localEnv.ok) { + return { success: false, error: localEnv.error } + } + return localDiscoveryOptions + ? discoverCommitMessageModelsLocal( + agentId as TuiAgent, + localEnv.env, + agentCommandOverride, + localDiscoveryOptions + ) + : discoverCommitMessageModelsLocal(agentId as TuiAgent, localEnv.env, agentCommandOverride) + } + ) +} diff --git a/src/main/ipc/filesystem/filesystem-git-pull-request-generation-handlers.ts b/src/main/ipc/filesystem/filesystem-git-pull-request-generation-handlers.ts new file mode 100644 index 00000000000..1c16a119b3b --- /dev/null +++ b/src/main/ipc/filesystem/filesystem-git-pull-request-generation-handlers.ts @@ -0,0 +1,226 @@ +import { ipcMain } from 'electron' +import type { GlobalSettings } from '../../../shared/global-settings-types' +import type { ResolvedSourceControlAiGenerationParams } from '../../../shared/source-control-ai' +import type { HostedReviewProvider } from '../../../shared/hosted-review' +import { + cancelGeneratePullRequestFieldsLocal, + generatePullRequestFieldsFromContext, + resolveCommitMessageSettings, + type GeneratePullRequestFieldsResult +} from '../../text-generation/commit-message-text-generation' +import { getCommitMessageModelDiscoveryHostKey } from '../../../shared/commit-message-host-key' +import { getPullRequestDraftContext } from '../../text-generation/pull-request-context' +import { prepareLocalCommitMessageAgentEnv } from '../../text-generation/commit-message-agent-environment' +import { + getSshGitProvider, + SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE +} from '../../providers/ssh-git-dispatch' +import { resolveRegisteredWorktreePath } from '../registered-worktree-roots-cache' +import { getLocalGitOptionsForRegisteredWorktree } from '../local-worktree-runtime-options' +import { gitExecFileAsync } from '../../git/runner' +import { withLinkedIssueDraftContext } from '../../../shared/source-control-ai-action-variables' +import { resolveSourceControlAiLinkedIssueMeta } from '../source-control-ai-linked-issue' +import { resolveHostedReviewBodyForGeneration } from '../../source-control/pull-request-template' +import { loadPullRequestLinkedIssue } from '../../source-control/pull-request-linked-issue' +import type { FilesystemHandlerContext } from './filesystem-handler-context' +import { + getLocalAgentRuntimeTarget, + getLocalTextGenerationTarget, + getRepoForSourceControlAi +} from './filesystem-source-control-ai-targets' + +export function registerFilesystemGitPullRequestGenerationHandlers( + context: FilesystemHandlerContext +): void { + const { store, commitMessageAgentEnv } = context + ipcMain.handle( + 'git:generatePullRequestFields', + async ( + _event, + args: { + worktreePath: string + // Raw (unstripped) meta key; validated against worktreePath before any meta read. + worktreeId?: string + repoId?: string + base: string + title: string + body: string + draft: boolean + provider?: HostedReviewProvider + useTemplate?: boolean + connectionId?: string + sourceControlAiResolvedParams?: ResolvedSourceControlAiGenerationParams + sourceControlAi?: GlobalSettings['sourceControlAi'] + agentCmdOverrides?: GlobalSettings['agentCmdOverrides'] + } + ): Promise => { + const discoveryHostKey = getCommitMessageModelDiscoveryHostKey(args.connectionId ?? null) + const baseSettings = store.getSettings() + const requestSettings = { + ...baseSettings, + ...(args.sourceControlAi !== undefined ? { sourceControlAi: args.sourceControlAi } : {}), + ...(args.agentCmdOverrides !== undefined + ? { agentCmdOverrides: args.agentCmdOverrides } + : {}) + } + const resolvedSettings = args.sourceControlAiResolvedParams + ? { ok: true as const, params: args.sourceControlAiResolvedParams } + : resolveCommitMessageSettings( + requestSettings, + discoveryHostKey, + 'pullRequest', + await getRepoForSourceControlAi(store, args) + ) + if (!resolvedSettings.ok) { + return { success: false, error: resolvedSettings.error } + } + if (args.connectionId) { + const provider = getSshGitProvider(args.connectionId) + if (!provider) { + return { + success: false, + error: SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE + } + } + const issueMeta = resolveSourceControlAiLinkedIssueMeta(store, args) + const linkedIssueDetailsPromise = loadPullRequestLinkedIssue({ + meta: issueMeta, + provider: args.provider, + repoPath: args.worktreePath, + connectionId: args.connectionId + }) + let context: Awaited> + try { + const currentBody = await resolveHostedReviewBodyForGeneration({ + body: args.body, + repoPath: args.worktreePath, + connectionId: args.connectionId, + provider: args.provider, + useTemplate: args.useTemplate + }) + context = await getPullRequestDraftContext( + (argv, commandOptions) => + commandOptions?.timeoutMs !== undefined + ? provider.exec(argv, args.worktreePath, { timeoutMs: commandOptions.timeoutMs }) + : commandOptions?.timeout !== undefined + ? provider.exec(argv, args.worktreePath, { timeoutMs: commandOptions.timeout }) + : provider.exec(argv, args.worktreePath), + { + base: args.base, + currentTitle: args.title, + currentBody, + currentDraft: args.draft + } + ) + } catch (error) { + return { + success: false, + error: + error instanceof Error ? error.message : 'Failed to prepare branch for PR details.' + } + } + if (!context) { + return { success: false, error: 'No branch changes to summarize.' } + } + const linkedIssueDetails = await linkedIssueDetailsPromise + context = { + ...withLinkedIssueDraftContext(context, issueMeta?.linkedIssue), + ...(args.provider ? { provider: args.provider } : {}), + ...(linkedIssueDetails ? { linkedIssueDetails } : {}) + } + return generatePullRequestFieldsFromContext(context, resolvedSettings.params, { + kind: 'remote', + cwd: args.worktreePath, + execute: (plan, cwd, timeoutMs, operation) => + provider.executeCommitMessagePlan(plan, cwd, timeoutMs, operation), + missingBinaryLocation: 'remote PATH' + }) + } + + const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) + const gitOptions = getLocalGitOptionsForRegisteredWorktree( + store, + args.worktreePath, + worktreePath + ) + const issueMeta = resolveSourceControlAiLinkedIssueMeta(store, args, worktreePath) + const linkedIssueDetailsPromise = loadPullRequestLinkedIssue({ + meta: issueMeta, + provider: args.provider, + repoPath: worktreePath, + connectionId: args.connectionId, + localGitOptions: gitOptions + }) + let context: Awaited> + try { + const currentBody = await resolveHostedReviewBodyForGeneration({ + body: args.body, + repoPath: worktreePath, + connectionId: args.connectionId, + provider: args.provider, + useTemplate: args.useTemplate + }) + context = await getPullRequestDraftContext( + (argv, options) => + gitExecFileAsync(argv, { + cwd: worktreePath, + ...gitOptions, + ...(options?.maxBuffer === undefined ? {} : { maxBuffer: options.maxBuffer }), + ...(options?.timeoutMs === undefined && options?.timeout === undefined + ? {} + : { timeout: options?.timeoutMs ?? options?.timeout }) + }), + { + base: args.base, + currentTitle: args.title, + currentBody, + currentDraft: args.draft + } + ) + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to prepare branch for PR details.' + } + } + if (!context) { + return { success: false, error: 'No branch changes to summarize.' } + } + const linkedIssueDetails = await linkedIssueDetailsPromise + context = { + ...withLinkedIssueDraftContext(context, issueMeta?.linkedIssue), + ...(args.provider ? { provider: args.provider } : {}), + ...(linkedIssueDetails ? { linkedIssueDetails } : {}) + } + const localEnv = await prepareLocalCommitMessageAgentEnv( + resolvedSettings.params.agentId, + commitMessageAgentEnv, + getLocalAgentRuntimeTarget(gitOptions) + ) + if (!localEnv.ok) { + return { success: false, error: localEnv.error } + } + return generatePullRequestFieldsFromContext( + context, + resolvedSettings.params, + getLocalTextGenerationTarget(worktreePath, gitOptions, localEnv.env) + ) + } + ) + + ipcMain.handle( + 'git:cancelGeneratePullRequestFields', + async (_event, args: { worktreePath: string; connectionId?: string }): Promise => { + if (args.connectionId) { + const provider = getSshGitProvider(args.connectionId) + if (!provider) { + return + } + await provider.cancelGenerateCommitMessage(args.worktreePath, 'pull-request-fields') + return + } + const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) + cancelGeneratePullRequestFieldsLocal(worktreePath) + } + ) +} diff --git a/src/main/ipc/filesystem/filesystem-git-remote-handlers.ts b/src/main/ipc/filesystem/filesystem-git-remote-handlers.ts new file mode 100644 index 00000000000..9450094109a --- /dev/null +++ b/src/main/ipc/filesystem/filesystem-git-remote-handlers.ts @@ -0,0 +1,10 @@ +import type { FilesystemHandlerContext } from './filesystem-handler-context' +import { registerGitRemoteBranchMutationHandlers } from './git-remote/branch-mutation-handlers' +import { registerGitRemoteCompareHandlers } from './git-remote/compare-handlers' +import { registerGitRemoteSyncHandlers } from './git-remote/sync-handlers' + +export function registerFilesystemGitRemoteHandlers(context: FilesystemHandlerContext): void { + registerGitRemoteCompareHandlers(context) + registerGitRemoteSyncHandlers(context) + registerGitRemoteBranchMutationHandlers(context) +} diff --git a/src/main/ipc/filesystem/filesystem-git-status-handlers.ts b/src/main/ipc/filesystem/filesystem-git-status-handlers.ts new file mode 100644 index 00000000000..7e76dd7e2c2 --- /dev/null +++ b/src/main/ipc/filesystem/filesystem-git-status-handlers.ts @@ -0,0 +1,312 @@ +import { ipcMain } from 'electron' +import type { + GitConflictOperation, + GitStagingArea, + GitStatusResult +} from '../../../shared/git-status-types' +import type { GitDiffResult } from '../../../shared/git-diff-compare-types' +import type { GitHistoryOptions, GitHistoryResult } from '../../../shared/git-history' +import type { GitStatusUpstreamRefWatchRequest } from '../git-status-upstream-ref-watch-request' +import type { GitAdmissionTier } from '../../git/command-runner/git-exec-options' +import { + getStatus, + getSubmoduleStatus, + abortMerge, + abortRebase, + detectConflictOperation, + getDiff +} from '../../git/status' +import { getHistory } from '../../git/history' +import { checkIgnoredPaths } from '../../git/check-ignored-paths' +import { + appendFolderToGitignore, + findKnownHugeFolderPathsToIgnore +} from '../../git/huge-folder-ignore' +import { + getSshGitProvider, + SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE +} from '../../providers/ssh-git-dispatch' +import { resolveRegisteredWorktreePath } from '../registered-worktree-roots-cache' +import { validateGitRelativeFilePath } from '../filesystem-path-containment' +import { + getLocalGitOptionsForRegisteredWorktree, + getLocalGitOptionsForRepo, + getLocalRepoForRegisteredWorktree +} from '../local-worktree-runtime-options' +import { getWorktreeSharedLinkPaths } from '../../git/worktree-shared-directories' +import { applyGitStatusUpstreamRefWatchRequest } from '../git-status-upstream-ref-watch-request' +import type { FilesystemHandlerContext } from './filesystem-handler-context' + +export function registerFilesystemGitStatusHandlers(context: FilesystemHandlerContext): void { + const { store, gitStatusCancellations } = context + ipcMain.handle( + 'git:status', + async ( + event, + args: { + worktreePath: string + connectionId?: string + admissionTier?: GitAdmissionTier + includeIgnored?: boolean + includeLineStats?: boolean + bypassEffectiveUpstreamNegativeCache?: boolean + reuseLineStats?: boolean + branchLineTotalMergeBase?: string + requestToken?: string + } + ): Promise => { + const controller = gitStatusCancellations.begin(event, args.requestToken) + const options = { + includeIgnored: args.includeIgnored ?? false, + admissionTier: args.admissionTier ?? ('status' as const), + ...(args.includeLineStats === false ? { includeLineStats: false } : {}), + ...(args.reuseLineStats === true ? { reuseLineStats: true } : {}), + ...(args.branchLineTotalMergeBase === undefined + ? {} + : { branchLineTotalMergeBase: args.branchLineTotalMergeBase }), + ...(args.bypassEffectiveUpstreamNegativeCache === true + ? { bypassEffectiveUpstreamNegativeCache: true } + : {}), + ...(controller ? { signal: controller.signal } : {}) + } + try { + if (args.connectionId) { + const provider = getSshGitProvider(args.connectionId) + if (!provider) { + throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) + } + // Why: await keeps the cancellation token registered until the remote request settles (an early finally would free it). + return await provider.getStatus(args.worktreePath, options) + } + const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) + // Why: one registered-worktree lookup feeds both — status polls this + // handler, and the scan walks every repo's worktree meta. + const repo = getLocalRepoForRegisteredWorktree(store, args.worktreePath, worktreePath) + const gitOptions = getLocalGitOptionsForRepo(store, repo) + const sharedLinkPaths = repo ? getWorktreeSharedLinkPaths(repo) : [] + return await getStatus(worktreePath, { + ...options, + ...gitOptions, + ...(sharedLinkPaths.length > 0 ? { sharedLinkPaths } : {}) + }) + } finally { + gitStatusCancellations.finish(event, args.requestToken, controller) + } + } + ) + + ipcMain.handle('git:cancelStatus', (event, args: { requestToken: string }): void => { + gitStatusCancellations.cancel(event, args.requestToken) + }) + + ipcMain.handle( + 'git:setStatusUpstreamRefWatch', + (_event, args: GitStatusUpstreamRefWatchRequest): Promise => + applyGitStatusUpstreamRefWatchRequest(store, args) + ) + + // Why: parent status reports only one gitlink row per submodule; fetch inner per-file changes from the submodule's own worktree. + ipcMain.handle( + 'git:submoduleStatus', + async ( + _event, + args: { + worktreePath: string + submodulePath: string + connectionId?: string + area?: GitStagingArea + } + ): Promise => { + if (args.connectionId) { + const provider = getSshGitProvider(args.connectionId) + if (!provider) { + throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) + } + return provider.getSubmoduleStatus(args.worktreePath, args.submodulePath, args.area) + } + const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) + const gitOptions = getLocalGitOptionsForRegisteredWorktree( + store, + args.worktreePath, + worktreePath + ) + return getSubmoduleStatus(worktreePath, args.submodulePath, { + ...gitOptions, + ...(args.area === 'staged' ? { staged: true } : {}) + }) + } + ) + + ipcMain.handle( + 'git:checkIgnored', + async ( + _event, + args: { worktreePath: string; paths: string[]; connectionId?: string } + ): Promise => { + if (args.connectionId) { + const paths = args.paths.map((p) => validateGitRelativeFilePath(args.worktreePath, p)) + const provider = getSshGitProvider(args.connectionId) + if (!provider) { + throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) + } + return provider.checkIgnoredPaths(args.worktreePath, paths) + } + const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) + const paths = args.paths.map((p) => validateGitRelativeFilePath(worktreePath, p)) + const gitOptions = getLocalGitOptionsForRegisteredWorktree( + store, + args.worktreePath, + worktreePath + ) + return checkIgnoredPaths(worktreePath, paths, gitOptions) + } + ) + + // Why: backs the SCM "ignore the flooding folder" flow; local-only since huge untracked folders are a local-dev pathology. + ipcMain.handle( + 'git:findHugeFoldersToIgnore', + async (_event, args: { worktreePath: string }): Promise => { + const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) + const gitOptions = getLocalGitOptionsForRegisteredWorktree( + store, + args.worktreePath, + worktreePath + ) + return findKnownHugeFolderPathsToIgnore(worktreePath, gitOptions) + } + ) + + ipcMain.handle( + 'git:appendGitignore', + async (_event, args: { worktreePath: string; folderName: string }): Promise => { + const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) + return appendFolderToGitignore(worktreePath, args.folderName) + } + ) + + ipcMain.handle( + 'git:history', + async ( + _event, + args: { worktreePath: string; connectionId?: string } & GitHistoryOptions + ): Promise => { + const options: GitHistoryOptions = { limit: args.limit, baseRef: args.baseRef } + if (args.connectionId) { + const provider = getSshGitProvider(args.connectionId) + if (!provider) { + throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) + } + return provider.getHistory(args.worktreePath, options) + } + const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) + const gitOptions = getLocalGitOptionsForRegisteredWorktree( + store, + args.worktreePath, + worktreePath + ) + return getHistory(worktreePath, { ...options, ...gitOptions }) + } + ) + + // Why: fs-only conflict-state check so non-active worktrees can clear their Rebasing/Merging badges without a full git status. + ipcMain.handle( + 'git:conflictOperation', + async ( + _event, + args: { worktreePath: string; connectionId?: string } + ): Promise => { + if (args.connectionId) { + const provider = getSshGitProvider(args.connectionId) + if (!provider) { + throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) + } + return provider.detectConflictOperation(args.worktreePath) + } + const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) + const gitOptions = getLocalGitOptionsForRegisteredWorktree( + store, + args.worktreePath, + worktreePath + ) + return detectConflictOperation(worktreePath, gitOptions) + } + ) + + ipcMain.handle( + 'git:abortMerge', + async (_event, args: { worktreePath: string; connectionId?: string }): Promise => { + if (args.connectionId) { + const provider = getSshGitProvider(args.connectionId) + if (!provider) { + throw new Error(`No git provider for connection "${args.connectionId}"`) + } + return provider.abortMerge(args.worktreePath) + } + const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) + const gitOptions = getLocalGitOptionsForRegisteredWorktree( + store, + args.worktreePath, + worktreePath + ) + await abortMerge(worktreePath, { ...gitOptions, admissionTier: 'interactive' }) + } + ) + + ipcMain.handle( + 'git:abortRebase', + async (_event, args: { worktreePath: string; connectionId?: string }): Promise => { + if (args.connectionId) { + const provider = getSshGitProvider(args.connectionId) + if (!provider) { + throw new Error(`No git provider for connection "${args.connectionId}"`) + } + return provider.abortRebase(args.worktreePath) + } + const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) + const gitOptions = getLocalGitOptionsForRegisteredWorktree( + store, + args.worktreePath, + worktreePath + ) + await abortRebase(worktreePath, { ...gitOptions, admissionTier: 'interactive' }) + } + ) + + ipcMain.handle( + 'git:diff', + async ( + _event, + args: { + worktreePath: string + filePath: string + staged: boolean + compareAgainstHead?: boolean + connectionId?: string + } + ): Promise => { + if (args.connectionId) { + const provider = getSshGitProvider(args.connectionId) + if (!provider) { + throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) + } + return provider.getDiff( + args.worktreePath, + args.filePath, + args.staged, + args.compareAgainstHead + ) + } + const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) + const filePath = validateGitRelativeFilePath(worktreePath, args.filePath) + const gitOptions = getLocalGitOptionsForRegisteredWorktree( + store, + args.worktreePath, + worktreePath + ) + return getDiff(worktreePath, filePath, args.staged, args.compareAgainstHead, { + ...gitOptions, + admissionTier: 'interactive' + }) + } + ) +} diff --git a/src/main/ipc/filesystem/filesystem-git-url-handlers.ts b/src/main/ipc/filesystem/filesystem-git-url-handlers.ts new file mode 100644 index 00000000000..4ed9b55057e --- /dev/null +++ b/src/main/ipc/filesystem/filesystem-git-url-handlers.ts @@ -0,0 +1,54 @@ +import { ipcMain } from 'electron' +import { awaitWindowsHostGitEnvironmentReady } from '../../git/runner' +import { getRemoteCommitUrl, getRemoteFileUrl } from '../../git/repo' +import { + getSshGitProvider, + SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE +} from '../../providers/ssh-git-dispatch' +import { resolveRegisteredWorktreePath } from '../registered-worktree-roots-cache' +import { validateFullGitObjectId } from '../filesystem-path-containment' +import type { FilesystemHandlerContext } from './filesystem-handler-context' + +export function registerFilesystemGitUrlHandlers(context: FilesystemHandlerContext): void { + const { store } = context + ipcMain.handle( + 'git:remoteFileUrl', + async ( + _event, + args: { worktreePath: string; relativePath: string; line: number; connectionId?: string } + ): Promise => { + // Why: remote repos can't read relay-side .git/config locally; delegate URL construction to the SSH provider. + if (args.connectionId) { + const provider = getSshGitProvider(args.connectionId) + if (!provider) { + throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) + } + return provider.getRemoteFileUrl(args.worktreePath, args.relativePath, args.line) + } + const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) + await awaitWindowsHostGitEnvironmentReady({ cwd: worktreePath }) + return getRemoteFileUrl(worktreePath, args.relativePath, args.line) + } + ) + + ipcMain.handle( + 'git:remoteCommitUrl', + async ( + _event, + args: { worktreePath: string; sha: string; connectionId?: string } + ): Promise => { + const sha = validateFullGitObjectId(args.sha, 'sha') + // Why: remote repos can't read relay-side .git/config locally; delegate URL construction to the SSH provider. + if (args.connectionId) { + const provider = getSshGitProvider(args.connectionId) + if (!provider) { + throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) + } + return provider.getRemoteCommitUrl(args.worktreePath, sha) + } + const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) + await awaitWindowsHostGitEnvironmentReady({ cwd: worktreePath }) + return getRemoteCommitUrl(worktreePath, sha) + } + ) +} diff --git a/src/main/ipc/filesystem/filesystem-handler-context.ts b/src/main/ipc/filesystem/filesystem-handler-context.ts new file mode 100644 index 00000000000..02488931e1e --- /dev/null +++ b/src/main/ipc/filesystem/filesystem-handler-context.ts @@ -0,0 +1,75 @@ +import type { ChildProcess } from 'node:child_process' +import type { FileHandle } from 'node:fs/promises' +import type { Store } from '../../persistence' +import type { CommitMessageAgentEnvironmentResolvers } from '../../text-generation/commit-message-agent-environment' +import type { SenderScopedRequestCancellations } from '../sender-scoped-request-cancellation' +import { cleanupLocalTransferPath } from './filesystem-download-promotion' + +export type DownloadSession = { + destinationPath: string + tempPath: string + destinationExisted: boolean + handle: FileHandle + cleanupTimer: ReturnType + senderId: number +} + +export type FilesystemHandlerContext = { + store: Store + commitMessageAgentEnv?: CommitMessageAgentEnvironmentResolvers + activeTextSearches: Map + downloadSessions: Map + listFilesCancellations: SenderScopedRequestCancellations + gitStatusCancellations: SenderScopedRequestCancellations + closeDownloadSession: ( + transferId: string, + cleanupTemp: boolean + ) => Promise + cleanupDownloadSessionsForSender: (senderId: number) => void +} + +export function createFilesystemHandlerContext( + store: Store, + commitMessageAgentEnv: CommitMessageAgentEnvironmentResolvers | undefined, + listFilesCancellations: SenderScopedRequestCancellations, + gitStatusCancellations: SenderScopedRequestCancellations +): FilesystemHandlerContext { + const activeTextSearches = new Map() + const downloadSessions = new Map() + + const closeDownloadSession = async ( + transferId: string, + cleanupTemp: boolean + ): Promise => { + const session = downloadSessions.get(transferId) + if (!session) { + return null + } + downloadSessions.delete(transferId) + clearTimeout(session.cleanupTimer) + await session.handle.close().catch(() => {}) + if (cleanupTemp) { + await cleanupLocalTransferPath(session.tempPath) + } + return session + } + + const cleanupDownloadSessionsForSender = (senderId: number): void => { + for (const [transferId, session] of Array.from(downloadSessions)) { + if (session.senderId === senderId) { + void closeDownloadSession(transferId, true) + } + } + } + + return { + store, + commitMessageAgentEnv, + activeTextSearches, + downloadSessions, + listFilesCancellations, + gitStatusCancellations, + closeDownloadSession, + cleanupDownloadSessionsForSender + } +} diff --git a/src/main/ipc/filesystem/filesystem-read-handlers.ts b/src/main/ipc/filesystem/filesystem-read-handlers.ts new file mode 100644 index 00000000000..938370a2816 --- /dev/null +++ b/src/main/ipc/filesystem/filesystem-read-handlers.ts @@ -0,0 +1,170 @@ +import { ipcMain } from 'electron' +import { readdir, readFile, stat } from 'node:fs/promises' +import { extname } from 'node:path' +import type { DirEntry, MarkdownDocument } from '../../../shared/filesystem-entry-types' +import { sortDirEntries } from '../../../shared/file-name-sort' +import { requireSshFilesystemProvider } from '../../providers/ssh-filesystem-dispatch' +import { resolveRegisteredWorktreePath } from '../registered-worktree-roots-cache' +import { resolveAuthorizedPath } from '../filesystem-auth' +import { isENOENT } from '../filesystem-path-containment' +import { listMarkdownDocuments, markdownDocumentsFromRelativePaths } from '../markdown-documents' +import { recordCrashBreadcrumb } from '../../crash-reporting/crash-breadcrumb-store' +import { buildReadDirErrorBreadcrumb, type ReadDirThrowSite } from '../readdir-error-diagnostics' +import type { FilesystemHandlerContext } from './filesystem-handler-context' +import { + BINARY_PROBE_BYTES, + isBinaryBuffer, + isBinaryFilePrefix, + isDirectoryEntry, + MAX_PREVIEWABLE_BINARY_SIZE, + MAX_TEXT_FILE_SIZE, + PREVIEWABLE_BINARY_MIME_TYPES, + readLocalLogSnapshot +} from './filesystem-file-content-inspection' + +export function registerFilesystemReadHandlers(context: FilesystemHandlerContext): void { + const { store } = context + + ipcMain.handle( + 'fs:readDir', + async (_event, args: { dirPath: string; connectionId?: string }): Promise => { + // Why: fs:readDir throws surface as opaque IPC errors; record the throw site + redacted path shape to keep them diagnosable. + let throwSite: ReadDirThrowSite = 'authorize' + try { + if (args.connectionId) { + throwSite = 'ssh-provider' + const provider = requireSshFilesystemProvider(args.connectionId) + // Why: re-sort locally — the remote relay may be an older build with lexicographic ordering. + return sortDirEntries(await provider.readDir(args.dirPath)) + } + const dirPath = await resolveAuthorizedPath(args.dirPath, store) + throwSite = 'readdir' + const entries = await readdir(dirPath, { withFileTypes: true }) + const mapped = entries.map((entry) => ({ + name: entry.name, + isDirectory: isDirectoryEntry(entry), + isSymlink: entry.isSymbolicLink() + })) + return sortDirEntries(mapped) + } catch (error: unknown) { + recordCrashBreadcrumb( + 'fs_readdir_error', + buildReadDirErrorBreadcrumb({ + dirPath: args.dirPath, + connectionId: args.connectionId, + throwSite, + error + }) + ) + throw error + } + } + ) + + ipcMain.handle( + 'fs:readFile', + async ( + _event, + args: { filePath: string; connectionId?: string; includeLocalLogMetadata?: boolean } + ): Promise<{ + content: string + isBinary: boolean + isImage?: boolean + mimeType?: string + fileIdentity?: string + }> => { + if (args.connectionId) { + const provider = requireSshFilesystemProvider(args.connectionId) + return provider.readFile(args.filePath) + } + const filePath = await resolveAuthorizedPath(args.filePath, store) + if (args.includeLocalLogMetadata === true) { + return readLocalLogSnapshot(filePath) + } + const stats = await stat(filePath) + const mimeType = PREVIEWABLE_BINARY_MIME_TYPES[extname(filePath).toLowerCase()] + const sizeLimit = mimeType ? MAX_PREVIEWABLE_BINARY_SIZE : MAX_TEXT_FILE_SIZE + if (stats.size > sizeLimit) { + throw new Error( + `File too large: ${(stats.size / 1024 / 1024).toFixed(1)}MB exceeds ${sizeLimit / 1024 / 1024}MB limit` + ) + } + + if (mimeType) { + const buffer = await readFile(filePath) + return { + content: buffer.toString('base64'), + isBinary: true, + // Why: the renderer keys previewable-binary rendering off `isImage`, so set it for PDFs too to stay compatible. + isImage: true, + mimeType + } + } + + // Why: probe large unknown files first so archives aren't fully buffered only to discover they aren't editable text. + if (stats.size > BINARY_PROBE_BYTES && (await isBinaryFilePrefix(filePath))) { + return { content: '', isBinary: true } + } + + const buffer = await readFile(filePath) + if (isBinaryBuffer(buffer)) { + return { content: '', isBinary: true } + } + return { content: buffer.toString('utf-8'), isBinary: false } + } + ) + + ipcMain.handle( + 'fs:listMarkdownDocuments', + async ( + _event, + args: { rootPath: string; connectionId?: string } + ): Promise => { + if (args.connectionId) { + const provider = requireSshFilesystemProvider(args.connectionId) + const relativePaths = await provider.listFiles(args.rootPath) + return markdownDocumentsFromRelativePaths(args.rootPath, relativePaths) + } + const rootPath = await resolveRegisteredWorktreePath(args.rootPath, store) + return listMarkdownDocuments(rootPath) + } + ) + + ipcMain.handle( + 'fs:stat', + async ( + _event, + args: { filePath: string; connectionId?: string } + ): Promise<{ size: number; isDirectory: boolean; mtime: number }> => { + if (args.connectionId) { + const provider = requireSshFilesystemProvider(args.connectionId) + const result = await provider.stat(args.filePath) + return { size: result.size, isDirectory: result.type === 'directory', mtime: result.mtime } + } + const filePath = await resolveAuthorizedPath(args.filePath, store) + const stats = await stat(filePath) + return { size: stats.size, isDirectory: stats.isDirectory(), mtime: stats.mtimeMs } + } + ) + + ipcMain.handle( + 'fs:pathExists', + async (_event, args: { filePath: string; connectionId?: string }): Promise => { + try { + if (args.connectionId) { + const provider = requireSshFilesystemProvider(args.connectionId) + await provider.stat(args.filePath) + return true + } + const filePath = await resolveAuthorizedPath(args.filePath, store) + await stat(filePath) + return true + } catch (error) { + if (isENOENT(error)) { + return false + } + throw error + } + } + ) +} diff --git a/src/main/ipc/filesystem/filesystem-search-handlers.ts b/src/main/ipc/filesystem/filesystem-search-handlers.ts new file mode 100644 index 00000000000..f6dd8d57284 --- /dev/null +++ b/src/main/ipc/filesystem/filesystem-search-handlers.ts @@ -0,0 +1,236 @@ +import { ipcMain } from 'electron' +import type { ChildProcess } from 'node:child_process' +import type { SearchOptions, SearchResult } from '../../../shared/code-search-types' +import { + buildRgArgs, + createAccumulator, + DEFAULT_SEARCH_MAX_RESULTS, + finalize, + ingestRgJsonLine, + SEARCH_TIMEOUT_MS +} from '../../../shared/text-search' +import { + absorbPendingRipgrepSpawnError, + isRipgrepUnavailableExit, + killSpawnedRipgrepProcess +} from '../../../shared/ripgrep-process-availability' +import { toWindowsWslPath, parseWslPath } from '../../wsl' +import { wslAwareSpawn } from '../../git/runner' +import { + getSshFilesystemProvider, + requireSshFilesystemProvider +} from '../../providers/ssh-filesystem-dispatch' +import { checkRgAvailable } from '../rg-availability' +import { resolveAuthorizedPath } from '../filesystem-auth' +import { listQuickOpenFiles } from '../filesystem-list-files' +import { searchWithGitGrep } from '../filesystem-search-git' +import { getLocalGitOptionsForRegisteredWorktree } from '../local-worktree-runtime-options' +import { QuickOpenPathRanker } from '../../../shared/quick-open-path-search' +import type { FilesystemHandlerContext } from './filesystem-handler-context' + +// 32 visible matches plus one truncation sentinel stays below the legacy frame ceiling. +const QUICK_OPEN_SSH_LEGACY_RESULT_LIMIT = 33 + +export function registerFilesystemSearchHandlers(context: FilesystemHandlerContext): void { + const { store, activeTextSearches } = context + + ipcMain.handle( + 'fs:search', + async (event, args: SearchOptions & { connectionId?: string }): Promise => { + if (args.connectionId) { + const provider = requireSshFilesystemProvider(args.connectionId) + return provider.search(args) + } + const rootPath = await resolveAuthorizedPath(args.rootPath, store) + const localGitOptions = getLocalGitOptionsForRegisteredWorktree( + store, + args.rootPath, + rootPath + ) + const maxResults = Math.max( + 1, + Math.min(args.maxResults ?? DEFAULT_SEARCH_MAX_RESULTS, DEFAULT_SEARCH_MAX_RESULTS) + ) + const searchKey = `${event.sender.id}:${rootPath}` + // Why: WSL's bash exit 127 is ambiguous with a real executable returning 127. + const wslDistroForOutput = parseWslPath(rootPath)?.distro ?? localGitOptions.wslDistro + + if (wslDistroForOutput && !(await checkRgAvailable(rootPath, localGitOptions.wslDistro))) { + return searchWithGitGrep(rootPath, args, maxResults, localGitOptions) + } + + return new Promise((resolvePromise) => { + const rgArgs = buildRgArgs(args.query, rootPath, args) + // Why: kill the prior rg so it stops parsing thousands of matches on the main thread (the large-repo freeze) after the UI moved on. + const previousChild = activeTextSearches.get(searchKey) + if (previousChild) { + killSpawnedRipgrepProcess(previousChild) + } + + const acc = createAccumulator() + let stdoutBuffer = '' + let resolved = false + let processErrorObserved = false + let unavailableExitObserved = false + let child: ChildProcess | null = null + let killTimeout: ReturnType + + const transformAbsPath = wslDistroForOutput + ? (path: string): string => + path.startsWith('/') ? toWindowsWslPath(path, wslDistroForOutput) : path + : undefined + + const finish = (result: SearchResult | PromiseLike): void => { + if (resolved) { + return + } + resolved = true + if (activeTextSearches.get(searchKey) === child) { + activeTextSearches.delete(searchKey) + } + clearTimeout(killTimeout) + // Why: child.kill() is advisory; detach our closures so repeated searches don't retain old scans if rg ignores it. + child?.stdout?.off('data', handleStdoutData) + child?.stderr?.off('data', handleStderrData) + child?.off('error', handleError) + child?.off('close', handleClose) + if (child) { + absorbPendingRipgrepSpawnError(child, { + errorObserved: processErrorObserved, + unavailableExitObserved + }) + } + resolvePromise(result) + } + const resolveOnce = (): void => finish(finalize(acc)) + const resolveWithoutRipgrep = (): void => + finish(searchWithGitGrep(rootPath, args, maxResults, localGitOptions)) + const processLine = (line: string): void => { + const verdict = ingestRgJsonLine(line, rootPath, acc, maxResults, transformAbsPath) + if (verdict === 'stop' && child) { + killSpawnedRipgrepProcess(child) + } + } + + const nextChild = wslAwareSpawn('rg', rgArgs, { + cwd: rootPath, + ...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {}), + stdio: ['ignore', 'pipe', 'pipe'] + }) + child = nextChild + activeTextSearches.set(searchKey, nextChild) + + const handleStdoutData = (chunk: string): void => { + stdoutBuffer += chunk + const lines = stdoutBuffer.split('\n') + stdoutBuffer = lines.pop() ?? '' + for (const line of lines) { + processLine(line) + } + } + const handleStderrData = (): void => { + // Drain stderr so rg cannot block on a full pipe. + } + const handleError = (): void => { + processErrorObserved = true + if (child && isRipgrepUnavailableExit(child, null, null)) { + resolveWithoutRipgrep() + return + } + resolveOnce() + } + const handleClose = (code: number | null, signal: NodeJS.Signals | null): void => { + if ( + child && + isRipgrepUnavailableExit(child, code, signal, { + classifyNativeLauncherExit: !wslDistroForOutput + }) + ) { + unavailableExitObserved = true + resolveWithoutRipgrep() + return + } + if (stdoutBuffer) { + processLine(stdoutBuffer) + } + resolveOnce() + } + + nextChild.stdout!.setEncoding('utf-8') + nextChild.stdout!.on('data', handleStdoutData) + nextChild.stderr!.on('data', handleStderrData) + nextChild.once('error', handleError) + nextChild.once('close', handleClose) + + // Why: timeout kills the child mid-scan; mark truncated so the UI shows incomplete results. + killTimeout = setTimeout(() => { + acc.truncated = true + if (child) { + killSpawnedRipgrepProcess(child) + } + resolveOnce() + }, SEARCH_TIMEOUT_MS) + }) + } + ) + + const { listFilesCancellations } = context + ipcMain.handle( + 'fs:listFiles', + async ( + event, + args: { + rootPath: string + connectionId?: string + excludePaths?: string[] + requestToken?: string + maxResults?: number + searchQuery?: string + } + ): Promise => { + const controller = listFilesCancellations.begin(event, args.requestToken) + try { + if (args.connectionId) { + const provider = getSshFilesystemProvider(args.connectionId) + // Why: no provider (cold start / disconnected) → return [] so quick-open shows "No matching files" instead of an error. + if (!provider) { + return [] + } + // Why: forward excludePaths or nested linked worktrees get double-scanned over SSH, causing timeout-induced partial results. + if ( + args.searchQuery !== undefined && + provider.supportsQuickOpenSearch && + !(await provider.supportsQuickOpenSearch({ signal: controller?.signal })) + ) { + const legacyFiles = await provider.listFiles(args.rootPath, { + excludePaths: args.excludePaths, + maxResults: QUICK_OPEN_SSH_LEGACY_RESULT_LIMIT, + signal: controller?.signal + }) + const ranker = new QuickOpenPathRanker( + args.searchQuery, + args.maxResults ?? QUICK_OPEN_SSH_LEGACY_RESULT_LIMIT + ) + for (const file of legacyFiles) { + ranker.consider(file) + } + return ranker.result().paths + } + return await provider.listFiles(args.rootPath, { + excludePaths: args.excludePaths, + ...(args.maxResults === undefined ? {} : { maxResults: args.maxResults }), + ...(args.searchQuery === undefined ? {} : { searchQuery: args.searchQuery }), + signal: controller?.signal + }) + } + return await listQuickOpenFiles(args.rootPath, store, args.excludePaths, controller?.signal) + } finally { + listFilesCancellations.finish(event, args.requestToken, controller) + } + } + ) + + ipcMain.handle('fs:cancelListFiles', (event, args: { requestToken: string }): void => { + listFilesCancellations.cancel(event, args.requestToken) + }) +} diff --git a/src/main/ipc/filesystem/filesystem-source-control-ai-targets.ts b/src/main/ipc/filesystem/filesystem-source-control-ai-targets.ts new file mode 100644 index 00000000000..2f9e5e92c09 --- /dev/null +++ b/src/main/ipc/filesystem/filesystem-source-control-ai-targets.ts @@ -0,0 +1,177 @@ +import type { Repo } from '../../../shared/repo-types' +import type { Store } from '../../persistence' +import type { LocalProjectWorktreeGitOptions } from '../../project-runtime-git-options' +import type { CommitMessageAgentRuntimeTarget } from '../../text-generation/commit-message-agent-environment' +import type { CommitMessageGenerationTarget } from '../../text-generation/commit-message-text-generation' +import { resolve } from 'node:path' +import { getSshGitProvider } from '../../providers/ssh-git-dispatch' +import { listRepoWorktreeGraph } from '../../repo-worktrees' +import { resolveAuthorizedPath } from '../filesystem-auth' +import { resolveRegisteredWorktreePath } from '../registered-worktree-roots-cache' +import { splitWorktreeId } from '../../../shared/worktree/id' + +function comparableLocalPath(value: string): string { + const normalized = resolve(value) + return process.platform === 'win32' ? normalized.toLowerCase() : normalized +} + +function getCandidateLocalWorktreePaths( + worktreePath: string, + resolvedWorktreePath: string +): Set { + return new Set([worktreePath, resolvedWorktreePath].map(comparableLocalPath)) +} + +function hasRegisteredWorktreeMetaForRepo( + store: Store, + repoId: string, + candidatePaths: Set +): boolean { + for (const worktreeId of Object.keys(store.getAllWorktreeMeta())) { + const parsed = splitWorktreeId(worktreeId) + if (parsed?.repoId === repoId && candidatePaths.has(comparableLocalPath(parsed.worktreePath))) { + return true + } + } + return false +} + +function comparableRemotePath(value: string): string { + return value.replace(/[/\\]+$/g, '') +} + +function hasRegisteredRemoteWorktreeMetaForRepo( + store: Store, + repoId: string, + worktreePath: string +): boolean { + const comparableWorktreePath = comparableRemotePath(worktreePath) + for (const worktreeId of Object.keys(store.getAllWorktreeMeta())) { + const parsed = splitWorktreeId(worktreeId) + if ( + parsed?.repoId === repoId && + comparableRemotePath(parsed.worktreePath) === comparableWorktreePath + ) { + return true + } + } + return false +} + +async function localRepoOwnsWorktree( + store: Store, + repo: Repo, + worktreePath: string +): Promise { + let resolvedWorktreePath: string + try { + resolvedWorktreePath = await resolveRegisteredWorktreePath(worktreePath, store) + } catch { + return false + } + const candidatePaths = getCandidateLocalWorktreePaths(worktreePath, resolvedWorktreePath) + if (candidatePaths.has(comparableLocalPath(repo.path))) { + return true + } + if (hasRegisteredWorktreeMetaForRepo(store, repo.id, candidatePaths)) { + return true + } + try { + const worktrees = await listRepoWorktreeGraph(repo) + return worktrees.some((worktree) => candidatePaths.has(comparableLocalPath(worktree.path))) + } catch { + return false + } +} + +async function remoteRepoOwnsWorktree( + store: Store, + repo: Repo, + worktreePath: string, + connectionId: string +): Promise { + const comparableWorktreePath = comparableRemotePath(worktreePath) + if (comparableRemotePath(repo.path) === comparableWorktreePath) { + return true + } + const provider = getSshGitProvider(connectionId) + if (!provider) { + return hasRegisteredRemoteWorktreeMetaForRepo(store, repo.id, worktreePath) + } + try { + const worktrees = await provider.listWorktrees(repo.path) + return worktrees.some( + (worktree) => comparableRemotePath(worktree.path) === comparableWorktreePath + ) + } catch { + return false + } +} + +export async function getRepoForSourceControlAi( + store: Store, + args: { repoId?: string; worktreePath: string; connectionId?: string } +): Promise { + if (!args.repoId) { + return null + } + const repo = store.getRepo(args.repoId) + if (!repo) { + return null + } + if (args.connectionId) { + if (repo.connectionId !== args.connectionId) { + return null + } + // Why: one SSH connection can host several repos; repo-scoped AI overrides apply only when the worktree belongs to that repo. + return (await remoteRepoOwnsWorktree(store, repo, args.worktreePath, args.connectionId)) + ? repo + : null + } + if (repo.connectionId) { + return null + } + // Why: renderer-supplied repoId is advisory; apply repo overrides only when the local worktree belongs to that repo. + return (await localRepoOwnsWorktree(store, repo, args.worktreePath)) ? repo : null +} + +export function getLocalAgentRuntimeTarget( + gitOptions: LocalProjectWorktreeGitOptions +): CommitMessageAgentRuntimeTarget { + return gitOptions.wslDistro + ? { runtime: 'wsl', wslDistro: gitOptions.wslDistro } + : { runtime: 'host' } +} + +export async function resolveModelDiscoveryLocalPath( + store: Store, + requestedPath: string +): Promise { + try { + return await resolveRegisteredWorktreePath(requestedPath, store) + } catch (error) { + const folderWorkspaces = + typeof store.getFolderWorkspaces === 'function' ? store.getFolderWorkspaces() : [] + const isFolderWorkspaceRoot = folderWorkspaces.some( + (workspace) => + comparableLocalPath(workspace.folderPath) === comparableLocalPath(requestedPath) + ) + if (!isFolderWorkspaceRoot) { + throw error + } + return resolveAuthorizedPath(requestedPath, store) + } +} + +export function getLocalTextGenerationTarget( + worktreePath: string, + gitOptions: LocalProjectWorktreeGitOptions, + env?: NodeJS.ProcessEnv +): Extract { + return { + kind: 'local', + cwd: worktreePath, + ...(gitOptions.wslDistro ? { wslDistro: gitOptions.wslDistro } : {}), + ...(env ? { env } : {}) + } +} diff --git a/src/main/ipc/filesystem/filesystem-write-handlers.ts b/src/main/ipc/filesystem/filesystem-write-handlers.ts new file mode 100644 index 00000000000..07d4de5b56a --- /dev/null +++ b/src/main/ipc/filesystem/filesystem-write-handlers.ts @@ -0,0 +1,91 @@ +import { ipcMain, shell } from 'electron' +import { lstat, writeFile } from 'node:fs/promises' +import type { SshMutationExpectation } from '../../../shared/ssh-types' +import { assertSshMutationExpectation } from '../../ssh/ssh-connection-generation' +import { requireSshFilesystemProvider } from '../../providers/ssh-filesystem-dispatch' +import { tryDeleteWslUncPath } from '../../wsl-unc-delete' +import { authorizeExternalPath, resolveAuthorizedPath } from '../filesystem-auth' +import { isENOENT } from '../filesystem-path-containment' +import { registerFilesystemMutationHandlers } from '../filesystem-mutations' +import type { FilesystemHandlerContext } from './filesystem-handler-context' + +export function registerFilesystemWriteHandlers(context: FilesystemHandlerContext): void { + const { store } = context + + ipcMain.handle( + 'fs:writeFile', + async ( + _event, + args: { filePath: string; content: string; connectionId?: string } & SshMutationExpectation + ): Promise => { + assertSshMutationExpectation( + args.connectionId, + args.expectedSshTargetId, + args.expectedSshConnectionGeneration, + args.expectedExecutionHostId + ) + if (args.connectionId) { + const provider = requireSshFilesystemProvider(args.connectionId) + return provider.writeFile(args.filePath, args.content) + } + const filePath = await resolveAuthorizedPath(args.filePath, store) + try { + const fileStats = await lstat(filePath) + if (fileStats.isDirectory()) { + throw new Error('Cannot write to a directory') + } + } catch (error) { + if (!isENOENT(error)) { + throw error + } + } + await writeFile(filePath, args.content, 'utf-8') + } + ) + + ipcMain.handle( + 'fs:deletePath', + async ( + _event, + args: { + targetPath: string + connectionId?: string + recursive?: boolean + } & SshMutationExpectation + ): Promise => { + assertSshMutationExpectation( + args.connectionId, + args.expectedSshTargetId, + args.expectedSshConnectionGeneration, + args.expectedExecutionHostId + ) + if (args.connectionId) { + const provider = requireSshFilesystemProvider(args.connectionId) + return provider.deletePath(args.targetPath, args.recursive) + } + // Why: preserve the symlink so we delete the link, not its target (realpath would trash the real file, possibly outside all roots). + const targetPath = await resolveAuthorizedPath(args.targetPath, store, { + preserveSymlink: true + }) + // Why: WSL UNC targets have no Recycle Bin (shell.trashItem throws), so hard-delete via `rm` inside the distro (issue #6415). + if (await tryDeleteWslUncPath(targetPath, { recursive: args.recursive })) { + return + } + // Why: swallow ENOENT so an external delete racing this UI delete stays idempotent (design §7.1). + try { + await shell.trashItem(targetPath) + } catch (error) { + if (isENOENT(error)) { + return + } + throw error + } + } + ) + + registerFilesystemMutationHandlers(store) + + ipcMain.handle('fs:authorizeExternalPath', (_event, args: { targetPath: string }): void => { + authorizeExternalPath(args.targetPath) + }) +} diff --git a/src/main/ipc/filesystem/git-remote/branch-mutation-handlers.ts b/src/main/ipc/filesystem/git-remote/branch-mutation-handlers.ts new file mode 100644 index 00000000000..53ba72d58a4 --- /dev/null +++ b/src/main/ipc/filesystem/git-remote/branch-mutation-handlers.ts @@ -0,0 +1,158 @@ +import { ipcMain } from 'electron' +import type { GitPushTarget } from '../../../../shared/worktree/types' +import { gitFastForward, gitPull, gitPullRebaseFromBase, gitPush } from '../../../git/remote' +import { validateGitPushTarget } from '../../../git/push-target-validation' +import { + getSshGitProvider, + SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE +} from '../../../providers/ssh-git-dispatch' +import { resolveRegisteredWorktreePath } from '../../registered-worktree-roots-cache' +import { getLocalGitOptionsForRegisteredWorktree } from '../../local-worktree-runtime-options' +import { assertGitPushTargetShape } from '../../../../shared/git-push-target-validation' +import type { FilesystemHandlerContext } from '../filesystem-handler-context' + +export function registerGitRemoteBranchMutationHandlers(context: FilesystemHandlerContext): void { + const { store } = context + + ipcMain.handle( + 'git:push', + async ( + _event, + args: { + worktreePath: string + publish?: boolean + forceWithLease?: boolean + connectionId?: string + pushTarget?: GitPushTarget + } + ): Promise => { + // Why: coerce to strict boolean so a malformed payload (e.g. string 'false') can't enable --set-upstream; mirror in src/relay/git-handler.ts. + const publish = args.publish === true + if (args.connectionId) { + if (args.pushTarget) { + assertGitPushTargetShape(args.pushTarget) + } + const provider = getSshGitProvider(args.connectionId) + if (!provider) { + throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) + } + return provider.pushBranch(args.worktreePath, publish, args.pushTarget, { + forceWithLease: args.forceWithLease === true + }) + } + const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) + const gitOptions = getLocalGitOptionsForRegisteredWorktree( + store, + args.worktreePath, + worktreePath + ) + if (args.pushTarget) { + await validateGitPushTarget(worktreePath, args.pushTarget, { + ...gitOptions, + admissionTier: 'interactive' + }) + } + await gitPush(worktreePath, publish, args.pushTarget, { + forceWithLease: args.forceWithLease === true, + ...gitOptions, + admissionTier: 'interactive' + }) + } + ) + + ipcMain.handle( + 'git:pull', + async ( + _event, + args: { worktreePath: string; connectionId?: string; pushTarget?: GitPushTarget } + ): Promise => { + if (args.connectionId) { + if (args.pushTarget) { + assertGitPushTargetShape(args.pushTarget) + } + const provider = getSshGitProvider(args.connectionId) + if (!provider) { + throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) + } + return provider.pullBranch(args.worktreePath, args.pushTarget) + } + const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) + const gitOptions = getLocalGitOptionsForRegisteredWorktree( + store, + args.worktreePath, + worktreePath + ) + if (args.pushTarget) { + await validateGitPushTarget(worktreePath, args.pushTarget, { + ...gitOptions, + admissionTier: 'interactive' + }) + } + await gitPull(worktreePath, args.pushTarget, { + ...gitOptions, + admissionTier: 'interactive' + }) + } + ) + + ipcMain.handle( + 'git:fastForward', + async ( + _event, + args: { worktreePath: string; connectionId?: string; pushTarget?: GitPushTarget } + ): Promise => { + if (args.connectionId) { + if (args.pushTarget) { + assertGitPushTargetShape(args.pushTarget) + } + const provider = getSshGitProvider(args.connectionId) + if (!provider) { + throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) + } + return provider.fastForwardBranch(args.worktreePath, args.pushTarget) + } + const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) + const gitOptions = getLocalGitOptionsForRegisteredWorktree( + store, + args.worktreePath, + worktreePath + ) + if (args.pushTarget) { + await validateGitPushTarget(worktreePath, args.pushTarget, { + ...gitOptions, + admissionTier: 'interactive' + }) + } + await gitFastForward(worktreePath, args.pushTarget, { + ...gitOptions, + admissionTier: 'interactive' + }) + } + ) + + ipcMain.handle( + 'git:rebaseFromBase', + async ( + _event, + args: { worktreePath: string; baseRef: string; connectionId?: string } + ): Promise => { + if (args.connectionId) { + const provider = getSshGitProvider(args.connectionId) + if (!provider) { + throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) + } + return provider.rebaseFromBase(args.worktreePath, args.baseRef) + } + const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) + const gitOptions = getLocalGitOptionsForRegisteredWorktree( + store, + args.worktreePath, + worktreePath + ) + await gitPullRebaseFromBase(worktreePath, args.baseRef, { + ...gitOptions, + admissionTier: 'interactive' + }) + } + ) +} diff --git a/src/main/ipc/filesystem/git-remote/compare-handlers.ts b/src/main/ipc/filesystem/git-remote/compare-handlers.ts new file mode 100644 index 00000000000..a2d7d7a3cd5 --- /dev/null +++ b/src/main/ipc/filesystem/git-remote/compare-handlers.ts @@ -0,0 +1,78 @@ +import { ipcMain } from 'electron' +import type { + GitBranchCompareResult, + GitCommitCompareResult +} from '../../../../shared/git-diff-compare-types' +import { getBranchCompare, getCommitCompare } from '../../../git/status' +import { + getSshGitProvider, + SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE +} from '../../../providers/ssh-git-dispatch' +import { resolveRegisteredWorktreePath } from '../../registered-worktree-roots-cache' +import { getLocalGitOptionsForRegisteredWorktree } from '../../local-worktree-runtime-options' +import { validateFullGitObjectId } from '../../filesystem-path-containment' +import type { FilesystemHandlerContext } from '../filesystem-handler-context' +import type { GitAdmissionTier } from '../../../git/command-runner/git-exec-options' + +export function registerGitRemoteCompareHandlers(context: FilesystemHandlerContext): void { + const { store } = context + + ipcMain.handle( + 'git:branchCompare', + async ( + _event, + args: { + worktreePath: string + baseRef: string + connectionId?: string + admissionTier?: GitAdmissionTier + } + ): Promise => { + if (args.connectionId) { + const provider = getSshGitProvider(args.connectionId) + if (!provider) { + throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) + } + return args.admissionTier + ? provider.getBranchCompare(args.worktreePath, args.baseRef, { + admissionTier: args.admissionTier + }) + : provider.getBranchCompare(args.worktreePath, args.baseRef) + } + const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) + const gitOptions = getLocalGitOptionsForRegisteredWorktree( + store, + args.worktreePath, + worktreePath + ) + return getBranchCompare(worktreePath, args.baseRef, { + ...gitOptions, + ...(args.admissionTier ? { admissionTier: args.admissionTier } : {}) + }) + } + ) + + ipcMain.handle( + 'git:commitCompare', + async ( + _event, + args: { worktreePath: string; commitId: string; connectionId?: string } + ): Promise => { + const commitId = validateFullGitObjectId(args.commitId, 'commitId') + if (args.connectionId) { + const provider = getSshGitProvider(args.connectionId) + if (!provider) { + throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) + } + return provider.getCommitCompare(args.worktreePath, commitId) + } + const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) + const gitOptions = getLocalGitOptionsForRegisteredWorktree( + store, + args.worktreePath, + worktreePath + ) + return getCommitCompare(worktreePath, commitId, gitOptions) + } + ) +} diff --git a/src/main/ipc/filesystem/git-remote/sync-handlers.ts b/src/main/ipc/filesystem/git-remote/sync-handlers.ts new file mode 100644 index 00000000000..eae79c918dd --- /dev/null +++ b/src/main/ipc/filesystem/git-remote/sync-handlers.ts @@ -0,0 +1,118 @@ +import { ipcMain } from 'electron' +import type { + GitForkSyncExpectedUpstream, + GitForkSyncResult +} from '../../../../shared/git-fork-sync' +import type { GitPushTarget } from '../../../../shared/worktree/types' +import type { GitUpstreamStatus } from '../../../../shared/git-status-types' +import { gitFetch } from '../../../git/remote' +import { gitSyncForkDefaultBranch } from '../../../git/fork-sync' +import { getUpstreamStatus } from '../../../git/upstream' +import { validateGitPushTarget } from '../../../git/push-target-validation' +import { + getSshGitProvider, + SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE +} from '../../../providers/ssh-git-dispatch' +import { resolveRegisteredWorktreePath } from '../../registered-worktree-roots-cache' +import { getLocalGitOptionsForRegisteredWorktree } from '../../local-worktree-runtime-options' +import { assertGitPushTargetShape } from '../../../../shared/git-push-target-validation' +import { validateGitForkSyncExpectedUpstream } from '../../../../shared/git-fork-sync' +import type { FilesystemHandlerContext } from '../filesystem-handler-context' + +export function registerGitRemoteSyncHandlers(context: FilesystemHandlerContext): void { + const { store } = context + + ipcMain.handle( + 'git:upstreamStatus', + async ( + _event, + args: { worktreePath: string; connectionId?: string; pushTarget?: GitPushTarget } + ): Promise => { + if (args.connectionId) { + if (args.pushTarget) { + assertGitPushTargetShape(args.pushTarget) + } + const provider = getSshGitProvider(args.connectionId) + if (!provider) { + throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) + } + return provider.getUpstreamStatus(args.worktreePath, args.pushTarget) + } + const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) + const gitOptions = getLocalGitOptionsForRegisteredWorktree( + store, + args.worktreePath, + worktreePath + ) + return getUpstreamStatus(worktreePath, args.pushTarget, gitOptions) + } + ) + + ipcMain.handle( + 'git:fetch', + async ( + _event, + args: { worktreePath: string; connectionId?: string; pushTarget?: GitPushTarget } + ): Promise => { + if (args.connectionId) { + if (args.pushTarget) { + assertGitPushTargetShape(args.pushTarget) + } + const provider = getSshGitProvider(args.connectionId) + if (!provider) { + throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) + } + return provider.fetchRemote(args.worktreePath, args.pushTarget) + } + const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) + const gitOptions = getLocalGitOptionsForRegisteredWorktree( + store, + args.worktreePath, + worktreePath + ) + if (args.pushTarget) { + await validateGitPushTarget(worktreePath, args.pushTarget, { + ...gitOptions, + admissionTier: 'interactive' + }) + } + await gitFetch(worktreePath, args.pushTarget, { + ...gitOptions, + admissionTier: 'interactive' + }) + } + ) + + ipcMain.handle( + 'git:syncFork', + async ( + _event, + args: { + worktreePath: string + connectionId?: string + expectedUpstream: GitForkSyncExpectedUpstream + } + ): Promise => { + const expectedUpstream = validateGitForkSyncExpectedUpstream(args.expectedUpstream, { + required: true + }) + if (args.connectionId) { + const provider = getSshGitProvider(args.connectionId) + if (!provider) { + throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) + } + return provider.syncForkDefaultBranch(args.worktreePath, expectedUpstream) + } + const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) + const gitOptions = getLocalGitOptionsForRegisteredWorktree( + store, + args.worktreePath, + worktreePath + ) + return gitSyncForkDefaultBranch(worktreePath, expectedUpstream, { + ...gitOptions, + admissionTier: 'interactive' + }) + } + ) +} diff --git a/src/main/ipc/github-ipc-channel-parity.test.ts b/src/main/ipc/github-ipc-channel-parity.test.ts index 928e0df9fbf..22a8b4c4178 100644 --- a/src/main/ipc/github-ipc-channel-parity.test.ts +++ b/src/main/ipc/github-ipc-channel-parity.test.ts @@ -85,7 +85,14 @@ describe('GitHub IPC channel parity', () => { it('preserves the facade and fixed IPC channel contract', () => { github.registerGitHubHandlers(harness.store as never, harness.stats as never) - const preloadSource = readFileSync(new URL('../../preload/index.ts', import.meta.url), 'utf8') + // The preload facade now composes the two GitHub bridge owners; inspect + // both owners so the channel census remains tied to the actual invokes. + const preloadSource = [ + '../../preload/api/gh-bridge-pull-requests-and-work-items.ts', + '../../preload/api/gh-bridge-mutations-and-projects.ts' + ] + .map((relativePath) => readFileSync(new URL(relativePath, import.meta.url), 'utf8')) + .join('\n') const exposedChannels = [...preloadSource.matchAll(/ipcRenderer\.invoke\('(gh:[^']+)'/g)].map( (match) => match[1] ) diff --git a/src/main/ipc/hosted-review.test.ts b/src/main/ipc/hosted-review.test.ts index 7cad33211d6..3f273490d7b 100644 --- a/src/main/ipc/hosted-review.test.ts +++ b/src/main/ipc/hosted-review.test.ts @@ -17,7 +17,7 @@ const { getHostedReviewCreationEligibilityMock, getHostedReviewForBranchMock, resolveRegisteredWorktreePathMock, - listRepoWorktreesMock + listRepoWorktreeGraphMock } = vi.hoisted(() => ({ handleMock: vi.fn(), createHostedReviewMock: vi.fn(), @@ -25,7 +25,7 @@ const { getHostedReviewCreationEligibilityMock: vi.fn(), getHostedReviewForBranchMock: vi.fn(), resolveRegisteredWorktreePathMock: vi.fn(), - listRepoWorktreesMock: vi.fn() + listRepoWorktreeGraphMock: vi.fn() })) vi.mock('electron', () => ({ @@ -52,7 +52,7 @@ vi.mock('./registered-worktree-roots-cache', () => ({ })) vi.mock('../repo-worktrees', () => ({ - listRepoWorktrees: listRepoWorktreesMock + listRepoWorktreeGraph: listRepoWorktreeGraphMock })) import { registerHostedReviewHandlers } from './hosted-review' @@ -97,7 +97,7 @@ describe('registerHostedReviewHandlers', () => { getHostedReviewCreationEligibilityMock.mockReset() getHostedReviewForBranchMock.mockReset() resolveRegisteredWorktreePathMock.mockReset() - listRepoWorktreesMock.mockReset() + listRepoWorktreeGraphMock.mockReset() store.getRepo.mockReset() store.getRepos.mockReset() store.getProjects.mockReset() @@ -114,7 +114,7 @@ describe('registerHostedReviewHandlers', () => { store.getRepos.mockReturnValue([repo]) store.getProjects.mockReturnValue([]) store.getSettings.mockReturnValue({ localWindowsRuntimeDefault: { kind: 'windows-host' } }) - listRepoWorktreesMock.mockResolvedValue([{ path: worktreePath }]) + listRepoWorktreeGraphMock.mockResolvedValue([{ path: worktreePath }]) }) it('routes local WSL project review creation through main-process runtime options', async () => { @@ -143,7 +143,7 @@ describe('registerHostedReviewHandlers', () => { ]) const resolvedWorktreePath = resolve('/workspace/feature') resolveRegisteredWorktreePathMock.mockResolvedValue(resolvedWorktreePath) - listRepoWorktreesMock.mockResolvedValue([{ path: resolvedWorktreePath }]) + listRepoWorktreeGraphMock.mockResolvedValue([{ path: resolvedWorktreePath }]) createHostedReviewMock.mockResolvedValueOnce({ ok: true, number: 42, @@ -162,7 +162,7 @@ describe('registerHostedReviewHandlers', () => { title: 'Feature PR' }) - expect(listRepoWorktreesMock).toHaveBeenCalledWith(localRepo, { wslDistro: 'Ubuntu' }) + expect(listRepoWorktreeGraphMock).toHaveBeenCalledWith(localRepo, { wslDistro: 'Ubuntu' }) expect(createHostedReviewMock).toHaveBeenCalledWith( resolvedWorktreePath, expect.objectContaining({ @@ -193,7 +193,7 @@ describe('registerHostedReviewHandlers', () => { store.getRepos.mockReturnValue([localRepo]) const resolvedWorktreePath = resolve('/workspace/feature') resolveRegisteredWorktreePathMock.mockResolvedValue(resolvedWorktreePath) - listRepoWorktreesMock.mockResolvedValue([{ path: resolvedWorktreePath }]) + listRepoWorktreeGraphMock.mockResolvedValue([{ path: resolvedWorktreePath }]) createHostedReviewMock.mockResolvedValueOnce({ ok: true, number: 42, url: 'https://x/1' }) registerHostedReviewHandlers(store as never, stats as never) diff --git a/src/main/ipc/hosted-review.ts b/src/main/ipc/hosted-review.ts index c431459f4f7..f64aeb8aa4d 100644 --- a/src/main/ipc/hosted-review.ts +++ b/src/main/ipc/hosted-review.ts @@ -16,7 +16,7 @@ import { import { createStackedHostedReview } from '../source-control/stacked-hosted-review-creation' import { getHostedReviewForBranch } from '../source-control/hosted-review' import { resolveRegisteredWorktreePath } from './registered-worktree-roots-cache' -import { listRepoWorktrees } from '../repo-worktrees' +import { listRepoWorktreeGraph } from '../repo-worktrees' import { getLocalProjectWorktreeGitOptions } from '../project-runtime-git-options' import { getWorktreeSharedLinkPaths } from '../git/worktree-shared-directories' import { getRepoExecutionHostId } from '../../shared/execution-host' @@ -68,7 +68,7 @@ async function resolveHostedReviewWorktreePath( } if (repo.connectionId) { const remoteWorktreePath = normalizeRemoteHostedReviewPath(worktreePath) - const repoWorktrees = await listRepoWorktrees(repo) + const repoWorktrees = await listRepoWorktreeGraph(repo) if ( !repoWorktrees.some( (worktree) => normalizeRemoteHostedReviewPath(worktree.path) === remoteWorktreePath @@ -82,8 +82,8 @@ async function resolveHostedReviewWorktreePath( const localGitOptions = getLocalProjectWorktreeGitOptions(store, repo) const repoWorktrees = Object.keys(localGitOptions).length > 0 - ? await listRepoWorktrees(repo, localGitOptions) - : await listRepoWorktrees(repo) + ? await listRepoWorktreeGraph(repo, localGitOptions) + : await listRepoWorktreeGraph(repo) if (!repoWorktrees.some((worktree) => resolve(worktree.path) === resolvedWorktreePath)) { throw new Error('Access denied: worktree does not belong to repository') } diff --git a/src/main/ipc/pty-ipc-mock-registry.ts b/src/main/ipc/pty-ipc-mock-registry.ts index bf3ec10dc27..b411febfa5d 100644 --- a/src/main/ipc/pty-ipc-mock-registry.ts +++ b/src/main/ipc/pty-ipc-mock-registry.ts @@ -1,4 +1,3 @@ -import { join } from 'node:path' import { vi } from 'vitest' import type { Mock } from 'vitest' import type * as Wsl from '../wsl' @@ -17,6 +16,7 @@ export const mkdirSyncMock: Mock = vi.fn() export const readFileSyncMock: Mock = vi.fn() export const writeFileSyncMock: Mock = vi.fn() export const chmodSyncMock: Mock = vi.fn() +export const linuxCliShimMock: Mock = vi.fn() export const renameSyncMock: Mock = vi.fn() export const rmSyncMock: Mock = vi.fn() export const getPathMock: Mock = vi.fn() @@ -152,8 +152,7 @@ export const classifyErrorModuleMock = () => ({ // Why: the real ensure writes to process.resourcesPath (absent under vitest); env assembly only needs the returned dir path. export const linuxCliShimModuleMock = () => ({ - ensureLinuxTerminalOrcaCliShimDir: (options: { userDataPath: string }) => - join(options.userDataPath, 'linux-orca-cli-shim') + ensureLinuxTerminalOrcaCliShimDir: linuxCliShimMock }) export const ptyRegistryModuleMock = () => ({ diff --git a/src/main/ipc/pty-ipc-suite-environment.ts b/src/main/ipc/pty-ipc-suite-environment.ts index 95c7dfe3eea..d372ecb734b 100644 --- a/src/main/ipc/pty-ipc-suite-environment.ts +++ b/src/main/ipc/pty-ipc-suite-environment.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, vi } from 'vitest' import * as electron from 'electron' +import { join } from 'node:path' import { installFakeAppEnvironment } from '../../../config/scripts/vitest-host-ports-setup' import { setPtyHostBindings } from './pty-host-bindings' import { testPtyIpcSurface } from './pty-ipc-test-surface' @@ -16,6 +17,7 @@ import { readFileSyncMock, writeFileSyncMock, chmodSyncMock, + linuxCliShimMock, getPathMock, loginPreflightExecFileMock, spawnMock, @@ -139,6 +141,10 @@ export function createPtyIpcSuiteEnvironment(): PtyIpcSuiteEnvironment { readFileSyncMock.mockReset() writeFileSyncMock.mockReset() chmodSyncMock.mockReset() + linuxCliShimMock.mockReset() + linuxCliShimMock.mockImplementation((options: { userDataPath: string }) => + join(options.userDataPath, 'linux-orca-cli-shim') + ) getPathMock.mockReset() loginPreflightExecFileMock.mockReset() spawnMock.mockReset() diff --git a/src/main/ipc/pty-restored-appimage-cli-shim-refresh.test.ts b/src/main/ipc/pty-restored-appimage-cli-shim-refresh.test.ts new file mode 100644 index 00000000000..d42103dc518 --- /dev/null +++ b/src/main/ipc/pty-restored-appimage-cli-shim-refresh.test.ts @@ -0,0 +1,76 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { setupPtyIpcSuite } from './pty-ipc-test-harness' +import { linuxCliShimMock } from './pty-ipc-mock-registry' + +vi.mock('electron', () => import('./pty-ipc-mock-registry').then((m) => m.electronModuleMock())) +vi.mock('fs', () => import('./pty-ipc-mock-registry').then((m) => m.fsModuleMock())) +vi.mock('node-pty', () => import('./pty-ipc-mock-registry').then((m) => m.nodePtyModuleMock())) +vi.mock('node:child_process', async (importOriginal) => + (await import('./pty-ipc-mock-registry')).childProcessModuleMock(await importOriginal()) +) +vi.mock('../opencode/hook-service', () => + import('./pty-ipc-mock-registry').then((m) => m.openCodeHookServiceModuleMock()) +) +vi.mock('../mimo/hook-service', () => + import('./pty-ipc-mock-registry').then((m) => m.mimoHookServiceModuleMock()) +) +vi.mock('../agent-hooks/server', () => + import('./pty-ipc-mock-registry').then((m) => m.agentHookServerModuleMock()) +) +vi.mock('../pi/titlebar-extension-service', () => + import('./pty-ipc-mock-registry').then((m) => m.piTitlebarExtensionModuleMock()) +) +vi.mock('../pwsh', () => import('./pty-ipc-mock-registry').then((m) => m.pwshModuleMock())) +vi.mock('../wsl', async (importOriginal) => + (await import('./pty-ipc-mock-registry')).wslModuleMock(await importOriginal()) +) +vi.mock('../telemetry/client', () => + import('./pty-ipc-mock-registry').then((m) => m.telemetryClientModuleMock()) +) +vi.mock('../telemetry/classify-error', () => + import('./pty-ipc-mock-registry').then((m) => m.classifyErrorModuleMock()) +) +vi.mock('../cli/linux-terminal-orca-cli-shim', () => + import('./pty-ipc-mock-registry').then((m) => m.linuxCliShimModuleMock()) +) +vi.mock('../memory/pty-registry', () => + import('./pty-ipc-mock-registry').then((m) => m.ptyRegistryModuleMock()) +) +vi.mock('../agent-hooks/migration-unsupported-pty-state', () => + import('./pty-ipc-mock-registry').then((m) => m.migrationUnsupportedPtyModuleMock()) +) +vi.mock('../codex/codex-pane-account-registry', () => + import('./pty-ipc-mock-registry').then((m) => m.codexPaneAccountRegistryModuleMock()) +) +vi.mock('../codex/codex-state-db-backfill-recovery', () => + import('./pty-ipc-mock-registry').then((m) => m.codexBackfillRecoveryModuleMock()) +) + +import { registerPtyHandlers } from './pty' + +const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')! + +afterEach(() => { + Object.defineProperty(process, 'platform', originalPlatform) +}) + +describe('restored AppImage CLI shim refresh', () => { + const { mainWindow } = setupPtyIpcSuite() + + it('refreshes the live launcher before any restored pane reattaches', () => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'linux' }) + + registerPtyHandlers(mainWindow as never) + + expect(linuxCliShimMock).toHaveBeenCalledOnce() + expect(linuxCliShimMock).toHaveBeenCalledWith({ userDataPath: '/tmp/orca-user-data' }) + }) + + it('does not publish a host launcher on a non-Linux host', () => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + + registerPtyHandlers(mainWindow as never) + + expect(linuxCliShimMock).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/ipc/pty/register-handlers.ts b/src/main/ipc/pty/register-handlers.ts index 9c46c942383..9a6230ad82b 100644 --- a/src/main/ipc/pty/register-handlers.ts +++ b/src/main/ipc/pty/register-handlers.ts @@ -1,4 +1,5 @@ import type { BrowserWindow } from 'electron' +import { getAppEnvironment } from '../../../shared/app-environment' import type { OrcaRuntimeService } from '../../runtime/orca-runtime' import type { Store } from '../../persistence' import type { GlobalSettings } from '../../../shared/global-settings-types' @@ -58,6 +59,7 @@ import { resolveCodexResumeLaunch, stripSequencedStartupResumeArgv } from './host-env/codex-resume' +import { ensureLinuxTerminalOrcaCliShimDir } from '../../cli/linux-terminal-orca-cli-shim' export function registerPtyHandlers( mainWindow: BrowserWindow, @@ -68,6 +70,12 @@ export function registerPtyHandlers( store?: Store, options?: PtyIpcSessionOptions ): void { + if (process.platform === 'linux') { + const appEnvironment = getAppEnvironment() + if (appEnvironment.isPackaged()) { + ensureLinuxTerminalOrcaCliShimDir({ userDataPath: appEnvironment.getPath('userData') }) + } + } const ipcMain = getPtyIpc() // Why first: the outgoing session owns the producer pauses, so its real reset must run // before the bridge is neutralized or a PTY paused during re-registration stays paused. diff --git a/src/main/ipc/registered-worktree-roots-cache.ts b/src/main/ipc/registered-worktree-roots-cache.ts index 9e1d20733df..ad8c40535cf 100644 --- a/src/main/ipc/registered-worktree-roots-cache.ts +++ b/src/main/ipc/registered-worktree-roots-cache.ts @@ -3,7 +3,7 @@ import { resolve } from 'node:path' import { withTimeout } from '../../shared/promise-timeout-fallback' import { getErrorCode } from '../git/worktree-operation-options' import type { Store } from '../persistence' -import { isRepoRoot, listRepoWorktrees } from '../repo-worktrees' +import { isRepoRoot, listRepoWorktreeGraph } from '../repo-worktrees' import { getLocalRepos } from './filesystem-allowed-roots' import { isDescendantOrEqual, normalizeExistingPath } from './filesystem-path-containment' @@ -54,7 +54,7 @@ export async function rebuildAuthorizedRootsCache(store: Store): Promise { try { roots.push(resolve(repo.path)) - for (const worktree of await listRepoWorktrees(repo)) { + for (const worktree of await listRepoWorktreeGraph(repo)) { roots.push(resolve(worktree.path)) } } catch (error) { diff --git a/src/main/ipc/repos-create.test.ts b/src/main/ipc/repos-create.test.ts index 86e2dc5ee10..44a16f8e10d 100644 --- a/src/main/ipc/repos-create.test.ts +++ b/src/main/ipc/repos-create.test.ts @@ -61,8 +61,11 @@ vi.mock('fs/promises', () => ({ rm: rmMock })) +// `availableParallelism` is read at module load by the git admission scheduler, +// which this module graph reaches; a partial `os` mock breaks that import. vi.mock('os', () => ({ - homedir: homedirMock + homedir: homedirMock, + availableParallelism: () => 8 })) vi.mock('../git/runner', () => ({ diff --git a/src/main/ipc/telemetry.ts b/src/main/ipc/telemetry.ts index c1fe7d0f68d..5e649f51b84 100644 --- a/src/main/ipc/telemetry.ts +++ b/src/main/ipc/telemetry.ts @@ -24,7 +24,9 @@ let storeRef: Store | null = null const MAIN_OWNED_TELEMETRY_EVENTS = new Set([ 'app_starred_orca', + 'daemon_adopted', 'daemon_audit_eligibility', + 'daemon_pty_cwd_denied', 'star_nag_outcome', 'feature_interaction_usage_bucket_reached' ]) diff --git a/src/main/ipc/worktree-base-directory-change-collector.test.ts b/src/main/ipc/worktree-base-directory-change-collector.test.ts new file mode 100644 index 00000000000..03e182ef611 --- /dev/null +++ b/src/main/ipc/worktree-base-directory-change-collector.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from 'vitest' +import { join } from 'node:path' +import { + collectLocalWorktreeBaseChanges, + collectRemoteWorktreeBaseChanges +} from './worktree-base-directory-change-collector' +import type { WorktreeBaseWatchTarget } from './worktree-base-directory-event-filter' +import { EMPTY_HEAD_IDENTITY_SCOPE, FULL_HEAD_IDENTITY_SCOPE } from './worktree-head-identity-scope' + +const COMMON_DIR = join('/repos', 'project', '.git') + +function makeTarget(): WorktreeBaseWatchTarget { + return { + key: `git-common:local:${COMMON_DIR}`, + kind: 'git-common', + path: COMMON_DIR, + repos: new Map([['repo-1', { repoId: 'repo-1', repoName: 'project', nestWorkspaces: false }]]) + } +} + +describe('worktree base change collector', () => { + it('states the full head scope on overflow rather than leaving it absent', () => { + // Overflow means every event in the window was lost. Stating FULL here — not + // relying on a downstream `?? FULL` for a missing field — is what keeps a + // future caller that forwards this object from reading it as "nothing moved". + const changes = collectRemoteWorktreeBaseChanges(makeTarget(), [{ kind: 'overflow' }] as never) + + expect(changes.overflow).toBe(true) + expect(changes.headIdentityScope).toEqual(FULL_HEAD_IDENTITY_SCOPE) + }) + + it('unions the head scopes of every event in a burst', () => { + const changes = collectLocalWorktreeBaseChanges(makeTarget(), [ + { type: 'update', path: join(COMMON_DIR, 'worktrees', 'wt-a', 'logs', 'HEAD') }, + { type: 'update', path: join(COMMON_DIR, 'worktrees', 'wt-b', 'HEAD') }, + { type: 'update', path: join(COMMON_DIR, 'logs', 'HEAD') }, + // Status-tier churn contributes nothing to the head scope. + { type: 'update', path: join(COMMON_DIR, 'worktrees', 'wt-c', 'index') } + ]) + + expect(changes.headIdentityScope).toEqual({ + listing: false, + primary: true, + all: false, + entryNames: new Set(['wt-a', 'wt-b']) + }) + }) + + it('lets one packed-refs write widen the whole burst to a full re-read', () => { + const changes = collectLocalWorktreeBaseChanges(makeTarget(), [ + { type: 'update', path: join(COMMON_DIR, 'worktrees', 'wt-a', 'logs', 'HEAD') }, + { type: 'update', path: join(COMMON_DIR, 'packed-refs') } + ]) + + expect(changes.headIdentityScope).toEqual(FULL_HEAD_IDENTITY_SCOPE) + }) + + it('keeps the head scope empty for a burst that can move no head', () => { + const changes = collectLocalWorktreeBaseChanges(makeTarget(), [ + { type: 'create', path: join(COMMON_DIR, 'worktrees', 'wt-a', 'locked') }, + { type: 'update', path: join(COMMON_DIR, 'worktrees', 'wt-a', 'index') }, + { type: 'update', path: join(COMMON_DIR, 'config') } + ]) + + expect(changes.headIdentityScope).toEqual(EMPTY_HEAD_IDENTITY_SCOPE) + expect(changes.structureRepoIds).toEqual(['repo-1']) + }) + + it('narrows a rename to the entries on both sides', () => { + const changes = collectRemoteWorktreeBaseChanges(makeTarget(), [ + { + kind: 'rename', + absolutePath: join(COMMON_DIR, 'worktrees', 'wt-new', 'HEAD'), + oldAbsolutePath: join(COMMON_DIR, 'worktrees', 'wt-old', 'HEAD') + } + ] as never) + + expect(changes.headIdentityScope).toEqual({ + listing: false, + primary: false, + all: false, + entryNames: new Set(['wt-old', 'wt-new']) + }) + }) +}) diff --git a/src/main/ipc/worktree-base-directory-change-collector.ts b/src/main/ipc/worktree-base-directory-change-collector.ts index 1f1f37dcedd..ed46089031f 100644 --- a/src/main/ipc/worktree-base-directory-change-collector.ts +++ b/src/main/ipc/worktree-base-directory-change-collector.ts @@ -3,6 +3,12 @@ import { classifyWorktreeBaseChange, type WorktreeBaseWatchTarget } from './worktree-base-directory-event-filter' +import { + EMPTY_HEAD_IDENTITY_SCOPE, + FULL_HEAD_IDENTITY_SCOPE, + mergeHeadIdentityScopes, + type WorktreeHeadIdentityScope +} from './worktree-head-identity-scope' type WorktreeBaseWatcherEvent = { type: 'create' | 'update' | 'delete' @@ -14,6 +20,7 @@ export type WorktreeBaseCollectedChanges = { structureRepoIds: string[] gitStatusRepoIds: string[] headIdentityRepoIds: string[] + headIdentityScope: WorktreeHeadIdentityScope } export function hasCollectedWorktreeBaseChanges(changes: WorktreeBaseCollectedChanges): boolean { @@ -26,22 +33,28 @@ type ChangeBuckets = { structureRepoIds: Set gitStatusRepoIds: Set headIdentityRepoIds: Set + headIdentityScope: WorktreeHeadIdentityScope } function emptyBuckets(): ChangeBuckets { return { structureRepoIds: new Set(), gitStatusRepoIds: new Set(), - headIdentityRepoIds: new Set() + headIdentityRepoIds: new Set(), + headIdentityScope: EMPTY_HEAD_IDENTITY_SCOPE } } -function emptyChanges(): WorktreeBaseCollectedChanges { +// Why: overflow means every event in the window was lost, so the scope must be +// stated as FULL here rather than left to a downstream `?? FULL` on an absent +// field — a caller that forwards this object must not read it as "nothing moved". +function overflowChanges(): WorktreeBaseCollectedChanges { return { - overflow: false, + overflow: true, structureRepoIds: [], gitStatusRepoIds: [], - headIdentityRepoIds: [] + headIdentityRepoIds: [], + headIdentityScope: FULL_HEAD_IDENTITY_SCOPE } } @@ -60,6 +73,10 @@ function addMatchingChange( for (const repoId of change.headIdentityRepoIds) { buckets.headIdentityRepoIds.add(repoId) } + buckets.headIdentityScope = mergeHeadIdentityScopes( + buckets.headIdentityScope, + change.headIdentityScope + ) } function toCollectedChanges(buckets: ChangeBuckets): WorktreeBaseCollectedChanges { @@ -67,7 +84,8 @@ function toCollectedChanges(buckets: ChangeBuckets): WorktreeBaseCollectedChange overflow: false, structureRepoIds: [...buckets.structureRepoIds], gitStatusRepoIds: [...buckets.gitStatusRepoIds], - headIdentityRepoIds: [...buckets.headIdentityRepoIds] + headIdentityRepoIds: [...buckets.headIdentityRepoIds], + headIdentityScope: buckets.headIdentityScope } } @@ -89,7 +107,7 @@ export function collectRemoteWorktreeBaseChanges( const buckets = emptyBuckets() for (const event of events) { if (event.kind === 'overflow') { - return { ...emptyChanges(), overflow: true } + return overflowChanges() } if (event.kind === 'rename') { if (event.oldAbsolutePath) { diff --git a/src/main/ipc/worktree-base-directory-event-filter.test.ts b/src/main/ipc/worktree-base-directory-event-filter.test.ts index 5fad0bc7448..18eb3cd91f2 100644 --- a/src/main/ipc/worktree-base-directory-event-filter.test.ts +++ b/src/main/ipc/worktree-base-directory-event-filter.test.ts @@ -5,6 +5,13 @@ import { matchingWorktreeBaseRepoIds, type WorktreeBaseWatchTarget } from './worktree-base-directory-event-filter' +import { + EMPTY_HEAD_IDENTITY_SCOPE, + FULL_HEAD_IDENTITY_SCOPE, + headIdentityScopeForEntry, + LISTING_HEAD_IDENTITY_SCOPE, + PRIMARY_HEAD_IDENTITY_SCOPE +} from './worktree-head-identity-scope' const COMMON_DIR = join('/repos', 'project', '.git') @@ -28,7 +35,8 @@ describe('matchingWorktreeBaseRepoIds (git-common)', () => { ).toEqual({ structureRepoIds: ['repo-1'], gitStatusRepoIds: [], - headIdentityRepoIds: [] + headIdentityRepoIds: [], + headIdentityScope: headIdentityScopeForEntry('wt-a') }) expect( classifyWorktreeBaseChange(target, { @@ -38,7 +46,13 @@ describe('matchingWorktreeBaseRepoIds (git-common)', () => { ).toEqual({ structureRepoIds: ['repo-1'], gitStatusRepoIds: [], - headIdentityRepoIds: [] + headIdentityRepoIds: [], + // Named as well as listed, so a remove+add reusing the name cannot keep + // serving the removed worktree's cached head. + headIdentityScope: { + ...LISTING_HEAD_IDENTITY_SCOPE, + entryNames: new Set(['wt-b']) + } }) expect( matchingWorktreeBaseRepoIds(target, { @@ -50,7 +64,12 @@ describe('matchingWorktreeBaseRepoIds (git-common)', () => { it('classifies primary-checkout branch metadata as structural and index as status-only', () => { const target = makeGitCommonTarget() - for (const file of ['HEAD', 'packed-refs']) { + // A primary HEAD write can only move the primary checkout's head, while a + // packed-refs rewrite can move any branch oid with no admin-dir event. + for (const [file, headIdentityScope] of [ + ['HEAD', PRIMARY_HEAD_IDENTITY_SCOPE], + ['packed-refs', FULL_HEAD_IDENTITY_SCOPE] + ] as const) { expect( classifyWorktreeBaseChange(target, { type: 'update', @@ -59,7 +78,8 @@ describe('matchingWorktreeBaseRepoIds (git-common)', () => { ).toEqual({ structureRepoIds: ['repo-1'], gitStatusRepoIds: [], - headIdentityRepoIds: [] + headIdentityRepoIds: [], + headIdentityScope }) } expect( @@ -70,7 +90,8 @@ describe('matchingWorktreeBaseRepoIds (git-common)', () => { ).toEqual({ structureRepoIds: [], gitStatusRepoIds: ['repo-1'], - headIdentityRepoIds: [] + headIdentityRepoIds: [], + headIdentityScope: EMPTY_HEAD_IDENTITY_SCOPE }) }) @@ -84,7 +105,8 @@ describe('matchingWorktreeBaseRepoIds (git-common)', () => { ).toEqual({ structureRepoIds: [], gitStatusRepoIds: ['repo-1'], - headIdentityRepoIds: [] + headIdentityRepoIds: [], + headIdentityScope: EMPTY_HEAD_IDENTITY_SCOPE }) expect( classifyWorktreeBaseChange(target, { @@ -94,7 +116,8 @@ describe('matchingWorktreeBaseRepoIds (git-common)', () => { ).toEqual({ structureRepoIds: [], gitStatusRepoIds: [], - headIdentityRepoIds: [] + headIdentityRepoIds: [], + headIdentityScope: EMPTY_HEAD_IDENTITY_SCOPE }) }) @@ -109,7 +132,8 @@ describe('matchingWorktreeBaseRepoIds (git-common)', () => { ).toEqual({ structureRepoIds: [], gitStatusRepoIds: [], - headIdentityRepoIds: ['repo-1'] + headIdentityRepoIds: ['repo-1'], + headIdentityScope: headIdentityScopeForEntry('wt-a') }) expect( classifyWorktreeBaseChange(target, { @@ -119,7 +143,8 @@ describe('matchingWorktreeBaseRepoIds (git-common)', () => { ).toEqual({ structureRepoIds: [], gitStatusRepoIds: [], - headIdentityRepoIds: ['repo-1'] + headIdentityRepoIds: ['repo-1'], + headIdentityScope: PRIMARY_HEAD_IDENTITY_SCOPE }) // Per-ref reflogs churn on fetches and stay ignored. expect( @@ -130,7 +155,8 @@ describe('matchingWorktreeBaseRepoIds (git-common)', () => { ).toEqual({ structureRepoIds: [], gitStatusRepoIds: [], - headIdentityRepoIds: [] + headIdentityRepoIds: [], + headIdentityScope: EMPTY_HEAD_IDENTITY_SCOPE }) expect( classifyWorktreeBaseChange(target, { @@ -140,7 +166,8 @@ describe('matchingWorktreeBaseRepoIds (git-common)', () => { ).toEqual({ structureRepoIds: [], gitStatusRepoIds: [], - headIdentityRepoIds: [] + headIdentityRepoIds: [], + headIdentityScope: EMPTY_HEAD_IDENTITY_SCOPE }) }) @@ -154,7 +181,9 @@ describe('matchingWorktreeBaseRepoIds (git-common)', () => { ).toEqual({ structureRepoIds: ['repo-1'], gitStatusRepoIds: [], - headIdentityRepoIds: [] + headIdentityRepoIds: [], + // Sparse-flag only: structural, but provably cannot move a head. + headIdentityScope: EMPTY_HEAD_IDENTITY_SCOPE }) expect( classifyWorktreeBaseChange(target, { @@ -164,7 +193,8 @@ describe('matchingWorktreeBaseRepoIds (git-common)', () => { ).toEqual({ structureRepoIds: ['repo-1'], gitStatusRepoIds: [], - headIdentityRepoIds: [] + headIdentityRepoIds: [], + headIdentityScope: EMPTY_HEAD_IDENTITY_SCOPE }) }) @@ -181,7 +211,8 @@ describe('matchingWorktreeBaseRepoIds (git-common)', () => { ).toEqual({ structureRepoIds: [], gitStatusRepoIds: ['repo-1'], - headIdentityRepoIds: [] + headIdentityRepoIds: [], + headIdentityScope: EMPTY_HEAD_IDENTITY_SCOPE }) const boundPaths = [ join(COMMON_DIR, 'refs', 'remotes', 'origin', 'main'), @@ -195,7 +226,8 @@ describe('matchingWorktreeBaseRepoIds (git-common)', () => { expect(classifyWorktreeBaseChange(target, { type, path })).toEqual({ structureRepoIds: [], gitStatusRepoIds: ['repo-1'], - headIdentityRepoIds: [] + headIdentityRepoIds: [], + headIdentityScope: EMPTY_HEAD_IDENTITY_SCOPE }) } } @@ -208,7 +240,8 @@ describe('matchingWorktreeBaseRepoIds (git-common)', () => { ).toEqual({ structureRepoIds: [], gitStatusRepoIds: [], - headIdentityRepoIds: [] + headIdentityRepoIds: [], + headIdentityScope: EMPTY_HEAD_IDENTITY_SCOPE }) expect( classifyWorktreeBaseChange(target, { @@ -218,7 +251,8 @@ describe('matchingWorktreeBaseRepoIds (git-common)', () => { ).toEqual({ structureRepoIds: [], gitStatusRepoIds: [], - headIdentityRepoIds: [] + headIdentityRepoIds: [], + headIdentityScope: EMPTY_HEAD_IDENTITY_SCOPE }) }) @@ -238,7 +272,8 @@ describe('matchingWorktreeBaseRepoIds (git-common)', () => { ).toEqual({ structureRepoIds: ['repo-1'], gitStatusRepoIds: [], - headIdentityRepoIds: [] + headIdentityRepoIds: [], + headIdentityScope: headIdentityScopeForEntry('wt a') }) expect( classifyWorktreeBaseChange(target, { @@ -248,7 +283,8 @@ describe('matchingWorktreeBaseRepoIds (git-common)', () => { ).toEqual({ structureRepoIds: [], gitStatusRepoIds: ['repo-1'], - headIdentityRepoIds: [] + headIdentityRepoIds: [], + headIdentityScope: EMPTY_HEAD_IDENTITY_SCOPE }) expect( classifyWorktreeBaseChange(target, { @@ -258,7 +294,8 @@ describe('matchingWorktreeBaseRepoIds (git-common)', () => { ).toEqual({ structureRepoIds: [], gitStatusRepoIds: ['repo-1'], - headIdentityRepoIds: [] + headIdentityRepoIds: [], + headIdentityScope: EMPTY_HEAD_IDENTITY_SCOPE }) }) @@ -278,7 +315,24 @@ describe('matchingWorktreeBaseRepoIds (git-common)', () => { expect(classifyWorktreeBaseChange(target, { type: 'update', path })).toEqual({ structureRepoIds: [], gitStatusRepoIds: [], - headIdentityRepoIds: [] + headIdentityRepoIds: [], + headIdentityScope: EMPTY_HEAD_IDENTITY_SCOPE + }) + } + }) + + it('widens to a full head re-read when the worktrees admin root itself changes', () => { + const target = makeGitCommonTarget() + // `git worktree prune` can delete and a later add recreate this dir; the + // watcher's stream is bound to the old inode, so no cached entry is trusted. + for (const type of ['create', 'update', 'delete'] as const) { + expect( + classifyWorktreeBaseChange(target, { type, path: join(COMMON_DIR, 'worktrees') }) + ).toEqual({ + structureRepoIds: ['repo-1'], + gitStatusRepoIds: [], + headIdentityRepoIds: [], + headIdentityScope: FULL_HEAD_IDENTITY_SCOPE }) } }) diff --git a/src/main/ipc/worktree-base-directory-event-filter.ts b/src/main/ipc/worktree-base-directory-event-filter.ts index 7cf91ce80de..2bc27f388f5 100644 --- a/src/main/ipc/worktree-base-directory-event-filter.ts +++ b/src/main/ipc/worktree-base-directory-event-filter.ts @@ -2,6 +2,15 @@ import { normalizeRuntimePathForComparison, relativePathInsideRoot } from '../../shared/cross-platform-path' +import { + EMPTY_HEAD_IDENTITY_SCOPE, + FULL_HEAD_IDENTITY_SCOPE, + headIdentityScopeForEntry, + LISTING_HEAD_IDENTITY_SCOPE, + mergeHeadIdentityScopes, + PRIMARY_HEAD_IDENTITY_SCOPE, + type WorktreeHeadIdentityScope +} from './worktree-head-identity-scope' type WorktreeBaseWatcherEvent = { type: 'create' | 'update' | 'delete' @@ -15,6 +24,9 @@ export type WorktreeBaseChangeClass = { // status churn, but distinct so only these re-read head identities. An index // rewrite cannot move HEAD, so it must never land here. headIdentityRepoIds: string[] + // Which slice of the common dir's head identities this event can have moved. + // Every classification must state one; `EMPTY` is a claim that no head moved. + headIdentityScope: WorktreeHeadIdentityScope } export type WorktreeBaseWatchKind = 'base' | 'git-common' @@ -99,13 +111,24 @@ function matchingBaseRepoIds( // ignored. // `config.worktree` is structural because it is the only file whose write // flips `git worktree list`'s sparse flag, and no status/commit path touches -// it — so it cannot re-open the index-churn fanout this classifier closes. -const GIT_COMMON_PRIMARY_STRUCTURAL_FILES = new Set(['HEAD', 'packed-refs', 'config.worktree']) +// it — so it cannot re-open the index-churn fanout this classifier closes, and +// it can move no head either. A rewritten `packed-refs` by contrast can move +// any branch oid without touching a single admin dir, so no cached head +// survives it. +const GIT_COMMON_PRIMARY_STRUCTURAL_SCOPES = new Map([ + ['HEAD', PRIMARY_HEAD_IDENTITY_SCOPE], + ['packed-refs', FULL_HEAD_IDENTITY_SCOPE], + ['config.worktree', EMPTY_HEAD_IDENTITY_SCOPE] +]) // `config` is status-tier: an external `git push -u` writes only // branch..remote/merge there, and a config write can move neither HEAD // nor the worktree listing. const GIT_COMMON_PRIMARY_STATUS_FILES = new Set(['index', 'config']) const GIT_COMMON_LINKED_STRUCTURAL_FILES = new Set(['HEAD', 'gitdir', 'locked', 'config.worktree']) +// `HEAD` carries the branch and `gitdir` the checkout path; `locked` and +// `config.worktree` are written by `git worktree lock` / sparse toggles, neither +// of which can move a head. +const GIT_COMMON_LINKED_HEAD_SOURCE_FILES = new Set(['HEAD', 'gitdir']) const GIT_COMMON_LINKED_STATUS_FILES = new Set(['index']) // `logs/HEAD` is the head-identity trigger for head moves that rewrite no @@ -138,14 +161,19 @@ function allRepoIds(target: WorktreeBaseWatchTarget): string[] { const NO_CHANGE: WorktreeBaseChangeClass = { structureRepoIds: [], gitStatusRepoIds: [], - headIdentityRepoIds: [] + headIdentityRepoIds: [], + headIdentityScope: EMPTY_HEAD_IDENTITY_SCOPE } -function structuralChange(repoIds: string[]): WorktreeBaseChangeClass { +function structuralChange( + repoIds: string[], + headIdentityScope: WorktreeHeadIdentityScope = EMPTY_HEAD_IDENTITY_SCOPE +): WorktreeBaseChangeClass { return { structureRepoIds: repoIds, gitStatusRepoIds: [], - headIdentityRepoIds: [] + headIdentityRepoIds: [], + headIdentityScope } } @@ -153,15 +181,20 @@ function gitStatusChange(repoIds: string[]): WorktreeBaseChangeClass { return { structureRepoIds: [], gitStatusRepoIds: repoIds, - headIdentityRepoIds: [] + headIdentityRepoIds: [], + headIdentityScope: EMPTY_HEAD_IDENTITY_SCOPE } } -function headIdentityChange(repoIds: string[]): WorktreeBaseChangeClass { +function headIdentityChange( + repoIds: string[], + headIdentityScope: WorktreeHeadIdentityScope +): WorktreeBaseChangeClass { return { structureRepoIds: [], gitStatusRepoIds: [], - headIdentityRepoIds: repoIds + headIdentityRepoIds: repoIds, + headIdentityScope } } @@ -178,10 +211,13 @@ function classifyGitCommonEvent( const repoIds = allRepoIds(target) if (parts.length === 1) { if (parts[0] === 'worktrees') { - return structuralChange(repoIds) + // The admin root itself appearing, vanishing, or being swapped means the + // watcher's view of every entry is suspect. + return structuralChange(repoIds, FULL_HEAD_IDENTITY_SCOPE) } - if (GIT_COMMON_PRIMARY_STRUCTURAL_FILES.has(parts[0])) { - return structuralChange(repoIds) + const primaryScope = GIT_COMMON_PRIMARY_STRUCTURAL_SCOPES.get(parts[0]) + if (primaryScope) { + return structuralChange(repoIds, primaryScope) } if (GIT_COMMON_PRIMARY_STATUS_FILES.has(parts[0])) { return gitStatusChange(repoIds) @@ -190,7 +226,7 @@ function classifyGitCommonEvent( } if (parts[0] !== 'worktrees') { if (isHeadLogParts(parts, 0)) { - return headIdentityChange(repoIds) + return headIdentityChange(repoIds, PRIMARY_HEAD_IDENTITY_SCOPE) } if (isBoundUpstreamRef(target, event.path, parts)) { return gitStatusChange(repoIds) @@ -198,18 +234,31 @@ function classifyGitCommonEvent( return NO_CHANGE } if (parts.length === 2) { - return event.type === 'update' ? NO_CHANGE : structuralChange(repoIds) + // Name the entry as well as the listing: a remove+add reusing one admin dir + // name coalesces into a single refresh, and the listing alone would keep + // serving the removed worktree's cached head. + return event.type === 'update' + ? NO_CHANGE + : structuralChange( + repoIds, + mergeHeadIdentityScopes(LISTING_HEAD_IDENTITY_SCOPE, headIdentityScopeForEntry(parts[1])) + ) } if (parts.length === 3) { if (GIT_COMMON_LINKED_STRUCTURAL_FILES.has(parts[2])) { - return structuralChange(repoIds) + return structuralChange( + repoIds, + GIT_COMMON_LINKED_HEAD_SOURCE_FILES.has(parts[2]) + ? headIdentityScopeForEntry(parts[1]) + : EMPTY_HEAD_IDENTITY_SCOPE + ) } if (GIT_COMMON_LINKED_STATUS_FILES.has(parts[2])) { return gitStatusChange(repoIds) } } if (isHeadLogParts(parts, 2)) { - return headIdentityChange(repoIds) + return headIdentityChange(repoIds, headIdentityScopeForEntry(parts[1])) } return NO_CHANGE } diff --git a/src/main/ipc/worktree-base-directory-marker-poller.ts b/src/main/ipc/worktree-base-directory-marker-poller.ts new file mode 100644 index 00000000000..dba1c4df03c --- /dev/null +++ b/src/main/ipc/worktree-base-directory-marker-poller.ts @@ -0,0 +1,289 @@ +import { readdir, stat } from 'node:fs/promises' +import type { Dirent } from 'node:fs' +import { join } from 'node:path' +import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path' +import { forEachWithConcurrency } from '../../shared/map-with-concurrency' +import type { + WorktreeBaseRepoWatchConfig, + WorktreeBaseWatchTarget +} from './worktree-base-directory-event-filter' +import type { + WorktreeBasePollerOptions, + WorktreeBasePollEvent, + WorktreeBaseSubscription, + WorktreePollerWindowVisibility +} from './worktree-base-directory-poller' + +// Why: the mtime gate is an optimization, not a correctness boundary — some +// filesystems have coarse dir timestamps, and pending `.git` markers expire. +// A periodic ungated scan guarantees eventual convergence. +export const WORKTREE_BASE_BACKSTOP_TICKS = 15 + +// Why: a `.git` completion marker lands within moments of its worktree dir +// (git writes it before populating the checkout). Dirs that never get one are +// not worktrees; stop re-statting them after this many ticks and let the +// backstop scan cover the pathological case. +const PENDING_MARKER_MAX_TICKS = 300 + +// Why: matches the git-common poller's fan-out bound (#17828) — bounded +// concurrency turns hundreds of serial round trips into a handful of batches +// without dumping every candidate onto libuv's 4-thread pool at once. +const MARKER_PROBE_CONCURRENCY = 8 + +function statSignature(s: { mtimeMs: number; ctimeMs: number; ino: number }): string { + return `${s.mtimeMs}:${s.ctimeMs}:${s.ino}` +} + +async function dirSignature(path: string): Promise { + try { + return statSignature(await stat(path)) + } catch { + return 'missing' + } +} + +async function hasGitMarker(dir: string): Promise { + try { + await stat(join(dir, '.git')) + return true + } catch { + return false + } +} + +type BaseSnapshot = { + // worktree-candidate dir → whether its `.git` completion marker exists + markers: Map + // dirs whose listing determines the candidate set: the root plus any + // nested repo containers. Their stat signatures gate the next full scan. + gateDirs: string[] + // index-aligned with gateDirs, each sampled *before* that dir's listing + gateSignatures: string[] +} + +async function readdirSafe(path: string): Promise { + try { + return await readdir(path, { withFileTypes: true }) + } catch { + return [] + } +} + +// Depth-1 worktree dirs (flat layout), plus depth-2 dirs under each nested +// repo's container, mirroring what worktree-base-directory-event-filter +// matches: `/.git` completion markers and `` deletions. +async function snapshotBase( + rootPath: string, + repos: ReadonlyMap +): Promise { + const markers = new Map() + const gateDirs = [rootPath] + // Why: sampling the signature before the listing makes a write that races the + // scan look stale next tick (one redundant rescan) instead of invisible until + // the backstop, which is up to 15 ticks of missed creates/deletes. + const gateSignatures = [await dirSignature(rootPath)] + const configs = [...repos.values()] + const includeFlat = configs.some((config) => !config.nestWorkspaces) + const nestedRepoNames = new Set( + configs + .filter((config) => config.nestWorkspaces) + .map((config) => normalizeRuntimePathForComparison(config.repoName)) + ) + + // Root vanished or unreadable: readdirSafe yields [], producing the same + // empty markers/candidates result as the old watcher's error path. + const rootEntries = await readdirSafe(rootPath) + + const candidates: string[] = [] + for (const entry of rootEntries) { + if (!entry.isDirectory() && !entry.isSymbolicLink()) { + continue + } + const entryPath = join(rootPath, entry.name) + if (includeFlat) { + candidates.push(entryPath) + } + if (nestedRepoNames.has(normalizeRuntimePathForComparison(entry.name))) { + gateDirs.push(entryPath) + gateSignatures.push(await dirSignature(entryPath)) + const subEntries = await readdirSafe(entryPath) + for (const sub of subEntries) { + if (sub.isDirectory() || sub.isSymbolicLink()) { + candidates.push(join(entryPath, sub.name)) + } + } + } + } + + await forEachWithConcurrency(candidates, MARKER_PROBE_CONCURRENCY, async (dir) => { + markers.set(dir, await hasGitMarker(dir)) + }) + return { markers, gateDirs, gateSignatures } +} + +function diffBase(prev: BaseSnapshot, next: BaseSnapshot): WorktreeBasePollEvent[] { + const events: WorktreeBasePollEvent[] = [] + for (const [dir, marker] of next.markers) { + if (marker && prev.markers.get(dir) !== true) { + events.push({ type: 'create', path: join(dir, '.git') }) + } + } + for (const dir of prev.markers.keys()) { + if (!next.markers.has(dir)) { + events.push({ type: 'delete', path: dir }) + } + } + return events +} + +export async function startBasePoller( + target: WorktreeBaseWatchTarget, + getRepos: () => ReadonlyMap, + onEvents: (events: WorktreeBasePollEvent[]) => void, + pollIntervalMs: number, + visibility: WorktreePollerWindowVisibility, + options: WorktreeBasePollerOptions +): Promise { + let disposed = false + let ticking = false + let tickCount = 0 + let snapshot = await snapshotBase(target.path, getRepos()) + let timer: ReturnType | null = null + let parkedWhileHidden = false + const pendingMarkerMaxTicks = options.pendingMarkerMaxTicks ?? PENDING_MARKER_MAX_TICKS + // dir → first probe tick; null means backstop scans only + const markerProbeStartedAt = new Map() + for (const [dir, marker] of snapshot.markers) { + if (!marker) { + markerProbeStartedAt.set(dir, 0) + } + } + + const fullScan = async (): Promise => { + options.onFullScan?.() + const next = await snapshotBase(target.path, getRepos()) + await options.onSnapshotTaken?.(tickCount) + if (disposed) { + return + } + const events = diffBase(snapshot, next) + for (const [dir, marker] of next.markers) { + if (marker) { + markerProbeStartedAt.delete(dir) + } else if (!markerProbeStartedAt.has(dir)) { + markerProbeStartedAt.set(dir, tickCount) + } + } + for (const dir of markerProbeStartedAt.keys()) { + if (!next.markers.has(dir)) { + markerProbeStartedAt.delete(dir) + } + } + snapshot = next + if (events.length > 0) { + onEvents(events) + } + } + + const checkPendingMarkers = async (): Promise => { + const events: WorktreeBasePollEvent[] = [] + for (const [dir, firstSeenTick] of markerProbeStartedAt) { + if (firstSeenTick === null) { + continue + } + if (tickCount - firstSeenTick > pendingMarkerMaxTicks) { + markerProbeStartedAt.set(dir, null) + continue + } + options.onPendingMarkerProbe?.(join(dir, '.git')) + if (await hasGitMarker(dir)) { + markerProbeStartedAt.delete(dir) + snapshot.markers.set(dir, true) + events.push({ type: 'create', path: join(dir, '.git') }) + } + } + if (!disposed && events.length > 0) { + onEvents(events) + } + } + + const poll = async (forceFullScan = false): Promise => { + tickCount++ + if (forceFullScan || tickCount % WORKTREE_BASE_BACKSTOP_TICKS === 0) { + await fullScan() + return + } + // Idle fast path: when the dirs whose listings define the candidate set + // are untouched, skip the readdir + per-candidate stat fan-out entirely. + const signatures = await Promise.all(snapshot.gateDirs.map(dirSignature)) + const gateChanged = + signatures.length !== snapshot.gateSignatures.length || + signatures.some((sig, index) => sig !== snapshot.gateSignatures[index]) + if (gateChanged) { + await fullScan() + return + } + if (markerProbeStartedAt.size > 0) { + await checkPendingMarkers() + } + } + + const tick = async (forceFullScan = false): Promise => { + timer = null + if (disposed) { + return + } + if (!visibility.isWindowVisible()) { + parkedWhileHidden = true + return + } + if (ticking) { + return + } + ticking = true + // Why: measure from tick start so the cadence is start-to-start (like the old setInterval), not + // gap-after-completion — otherwise each visible refresh lands a full scan-duration late every tick. + const startedAt = Date.now() + try { + await poll(forceFullScan) + } catch { + // Transient fs error: keep the previous snapshot and retry next tick. + } finally { + ticking = false + } + if (!disposed) { + // Why: clamp to [0, pollIntervalMs]. Date.now() is not monotonic — a backward wall-clock jump (NTP) would + // otherwise make elapsed negative and push the next tick out by the adjustment (suppressing refreshes for + // minutes); the upper clamp caps the wait at one interval, the lower clamp keeps a long scan from going negative. + const nextDelay = Math.max( + 0, + Math.min(pollIntervalMs, pollIntervalMs - (Date.now() - startedAt)) + ) + timer = setTimeout(() => void tick(), nextDelay) + timer.unref?.() + } + } + + const unsubscribeVisibility = visibility.onWindowBecameVisible(() => { + if (disposed || !parkedWhileHidden) { + return + } + parkedWhileHidden = false + // Why: the ordinary dir-signature gate can miss same-granule changes made + // while hidden; resume must diff a fresh full snapshot against the baseline. + void tick(true) + }) + + timer = setTimeout(() => void tick(), pollIntervalMs) + timer.unref?.() + + return { + unsubscribe: async () => { + disposed = true + if (timer) { + clearTimeout(timer) + } + unsubscribeVisibility() + } + } +} diff --git a/src/main/ipc/worktree-base-directory-notifications.ts b/src/main/ipc/worktree-base-directory-notifications.ts index 2ab94667594..c4c69f0f691 100644 --- a/src/main/ipc/worktree-base-directory-notifications.ts +++ b/src/main/ipc/worktree-base-directory-notifications.ts @@ -5,6 +5,12 @@ import { refreshWorktreeHeadIdentities, type WorktreeHeadIdentityRefreshState } from './worktree-head-identity-refresh' +import { + EMPTY_HEAD_IDENTITY_SCOPE, + FULL_HEAD_IDENTITY_SCOPE, + mergeHeadIdentityScopes, + type WorktreeHeadIdentityScope +} from './worktree-head-identity-scope' import { notifyWorktreeGitStatusMetadataChanged } from './worktree-remote' import { notifyWatchedWorktreeCatalogChanged } from './watched-worktree-catalog-notification' @@ -14,6 +20,7 @@ export type WorktreeBaseNotificationWatch = WorktreeBaseWatchTarget & { pendingStructureRepoIds: Set pendingGitStatusRepoIds: Set pendingHeadIdentityRepoIds: Set + pendingHeadIdentityScope: WorktreeHeadIdentityScope headIdentityRefresh: WorktreeHeadIdentityRefreshState disposed: boolean } @@ -24,6 +31,7 @@ export function clearPendingWorktreeBaseNotifications(watch: WorktreeBaseNotific watch.pendingStructureRepoIds.clear() watch.pendingGitStatusRepoIds.clear() watch.pendingHeadIdentityRepoIds.clear() + watch.pendingHeadIdentityScope = EMPTY_HEAD_IDENTITY_SCOPE } export function supportsWorktreeHeadIdentityRefresh(watch: WorktreeBaseNotificationWatch): boolean { @@ -47,6 +55,13 @@ export function scheduleWorktreeBaseNotification( for (const repoId of changes.headIdentityRepoIds ?? []) { watch.pendingHeadIdentityRepoIds.add(repoId) } + // Why: callers that cannot attribute the burst to specific worktrees (watcher + // failure, event overflow) omit the scope entirely; that is a loss of + // knowledge, so it must widen to a full re-read rather than narrow to nothing. + watch.pendingHeadIdentityScope = mergeHeadIdentityScopes( + watch.pendingHeadIdentityScope, + changes.headIdentityScope ?? FULL_HEAD_IDENTITY_SCOPE + ) clearTimeout(watch.notifyTimer ?? undefined) watch.notifyTimer = setTimeout(() => { watch.notifyTimer = null @@ -62,6 +77,7 @@ export function scheduleWorktreeBaseNotification( ) ) const emitHeadIdentities = pendingStructure.length === 0 + const headIdentityScope = watch.pendingHeadIdentityScope clearPendingWorktreeBaseNotifications(watch) for (const repoId of pendingStructure) { notifyWatchedWorktreeCatalogChanged(watch.mainWindow, repoId, watch.connectionId) @@ -73,7 +89,12 @@ export function scheduleWorktreeBaseNotification( supportsWorktreeHeadIdentityRefresh(watch) && (pendingStructure.length > 0 || hasHeadIdentity) ) { - void refreshWorktreeHeadIdentities(watch, watch.headIdentityRefresh, emitHeadIdentities) + void refreshWorktreeHeadIdentities( + watch, + watch.headIdentityRefresh, + emitHeadIdentities, + headIdentityScope + ) } }, WATCH_DEBOUNCE_MS) } diff --git a/src/main/ipc/worktree-base-directory-poller-marker-fanout.test.ts b/src/main/ipc/worktree-base-directory-poller-marker-fanout.test.ts new file mode 100644 index 00000000000..023a8390ccd --- /dev/null +++ b/src/main/ipc/worktree-base-directory-poller-marker-fanout.test.ts @@ -0,0 +1,84 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mkdir, mkdtemp, realpath, rm, writeFile } from 'node:fs/promises' +import type * as NodeFsPromises from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { startWorktreeBaseDirectoryPoller } from './worktree-base-directory-poller' +import type { + WorktreeBaseRepoWatchConfig, + WorktreeBaseWatchTarget +} from './worktree-base-directory-event-filter' + +// Why: the backstop full scan stats a `.git` marker per candidate dir; an +// unbounded fan-out at hundreds of worktrees would queue thousands of `stat` +// calls on libuv's 4-thread pool (#17828). +const { concurrency } = vi.hoisted(() => ({ concurrency: { current: 0, peak: 0 } })) + +vi.mock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + stat: async (...args: Parameters) => { + concurrency.current += 1 + concurrency.peak = Math.max(concurrency.peak, concurrency.current) + try { + return await actual.stat(...args) + } finally { + concurrency.current -= 1 + } + } + } +}) + +function makeTarget(path: string): WorktreeBaseWatchTarget { + const repoConfig: WorktreeBaseRepoWatchConfig = { + repoId: 'repo-1', + repoName: 'project', + nestWorkspaces: false + } + return { + key: `base:local:${path}`, + kind: 'base', + path, + repos: new Map([[repoConfig.repoId, repoConfig]]) + } +} + +describe('worktree base directory poller marker fan-out (#17828)', () => { + const cleanups: (() => Promise)[] = [] + + beforeEach(() => { + concurrency.current = 0 + concurrency.peak = 0 + }) + + afterEach(async () => { + await Promise.all(cleanups.splice(0).map((cleanup) => cleanup())) + }) + + it('bounds concurrent `.git`-marker stats regardless of candidate count', async () => { + const root = await realpath(await mkdtemp(join(tmpdir(), 'orca-base-poller-fanout-'))) + cleanups.push(() => rm(root, { recursive: true, force: true })) + const candidateCount = 200 + for (let i = 0; i < candidateCount; i++) { + const worktree = join(root, `wt-${i}`) + await mkdir(worktree) + await writeFile(join(worktree, '.git'), 'gitdir: elsewhere') + } + + const target = makeTarget(root) + const poller = await startWorktreeBaseDirectoryPoller( + target, + () => target.repos, + () => {}, + { pollIntervalMs: 100_000 } + ) + cleanups.push(() => poller.unsubscribe()) + + // 200 candidates stated unbounded would peak near 200 concurrent `stat` + // calls; bounding the marker probe keeps the peak independent of count — + // while still overlapping requests (not serialized one-at-a-time). + expect(concurrency.peak).toBeGreaterThan(1) + expect(concurrency.peak).toBeLessThan(20) + }) +}) diff --git a/src/main/ipc/worktree-base-directory-poller.ts b/src/main/ipc/worktree-base-directory-poller.ts index 42ee184b811..39c5b5f48c0 100644 --- a/src/main/ipc/worktree-base-directory-poller.ts +++ b/src/main/ipc/worktree-base-directory-poller.ts @@ -1,13 +1,13 @@ -import { readdir, stat } from 'node:fs/promises' -import { join } from 'node:path' -import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path' import { isMainWindowVisible, onMainWindowBecameVisible } from '../window/main-window-visibility' import type { WorktreeBaseRepoWatchConfig, WorktreeBaseWatchTarget } from './worktree-base-directory-event-filter' +import { startBasePoller } from './worktree-base-directory-marker-poller' import { startGitCommonWatch } from './worktree-git-common-watch' +export { WORKTREE_BASE_BACKSTOP_TICKS } from './worktree-base-directory-marker-poller' + export type WorktreeBasePollEvent = { type: 'create' | 'update' | 'delete'; path: string } export type WorktreeBaseSubscription = { unsubscribe: () => Promise } @@ -61,6 +61,8 @@ export type WorktreeBasePollerOptions = { visibility?: WorktreePollerWindowVisibility getGitStatusRefPaths?: () => readonly string[] onWatchError?: (error: Error) => void + /** Called when the watcher child dropped an event batch (git-common narrow watch only). */ + onOverflow?: () => void /** Test hook: called whenever a full snapshot scan runs (vs. a gated skip). */ onFullScan?: () => void /** Test hook: called before a pending `.git` marker stat. */ @@ -81,277 +83,6 @@ export type WorktreeBasePollerOptions = { // Orca's own worktree operations notify the renderer directly. export const WORKTREE_BASE_POLL_INTERVAL_MS = 2_000 -// Why: the mtime gate is an optimization, not a correctness boundary — some -// filesystems have coarse dir timestamps, and pending `.git` markers expire. -// A periodic ungated scan guarantees eventual convergence. -export const WORKTREE_BASE_BACKSTOP_TICKS = 15 - -// Why: a `.git` completion marker lands within moments of its worktree dir -// (git writes it before populating the checkout). Dirs that never get one are -// not worktrees; stop re-statting them after this many ticks and let the -// backstop scan cover the pathological case. -const PENDING_MARKER_MAX_TICKS = 300 - -function statSignature(s: { mtimeMs: number; ctimeMs: number; ino: number }): string { - return `${s.mtimeMs}:${s.ctimeMs}:${s.ino}` -} - -async function dirSignature(path: string): Promise { - try { - return statSignature(await stat(path)) - } catch { - return 'missing' - } -} - -async function hasGitMarker(dir: string): Promise { - try { - await stat(join(dir, '.git')) - return true - } catch { - return false - } -} - -type BaseSnapshot = { - // worktree-candidate dir → whether its `.git` completion marker exists - markers: Map - // dirs whose listing determines the candidate set: the root plus any - // nested repo containers. Their stat signatures gate the next full scan. - gateDirs: string[] - // index-aligned with gateDirs, each sampled *before* that dir's listing - gateSignatures: string[] -} - -// Depth-1 worktree dirs (flat layout), plus depth-2 dirs under each nested -// repo's container, mirroring what worktree-base-directory-event-filter -// matches: `/.git` completion markers and `` deletions. -async function snapshotBase( - rootPath: string, - repos: ReadonlyMap -): Promise { - const markers = new Map() - const gateDirs = [rootPath] - // Why: sampling the signature before the listing makes a write that races the - // scan look stale next tick (one redundant rescan) instead of invisible until - // the backstop, which is up to 15 ticks of missed creates/deletes. - const gateSignatures = [await dirSignature(rootPath)] - const configs = [...repos.values()] - const includeFlat = configs.some((config) => !config.nestWorkspaces) - const nestedRepoNames = new Set( - configs - .filter((config) => config.nestWorkspaces) - .map((config) => normalizeRuntimePathForComparison(config.repoName)) - ) - - let rootEntries - try { - rootEntries = await readdir(rootPath, { withFileTypes: true }) - } catch { - // Root vanished: an empty snapshot diffs into delete events for every - // previously-known worktree dir, matching the old watcher's error path. - return { markers, gateDirs, gateSignatures } - } - - const candidates: string[] = [] - for (const entry of rootEntries) { - if (!entry.isDirectory() && !entry.isSymbolicLink()) { - continue - } - const entryPath = join(rootPath, entry.name) - if (includeFlat) { - candidates.push(entryPath) - } - if (nestedRepoNames.has(normalizeRuntimePathForComparison(entry.name))) { - gateDirs.push(entryPath) - gateSignatures.push(await dirSignature(entryPath)) - let subEntries - try { - subEntries = await readdir(entryPath, { withFileTypes: true }) - } catch { - subEntries = [] - } - for (const sub of subEntries) { - if (sub.isDirectory() || sub.isSymbolicLink()) { - candidates.push(join(entryPath, sub.name)) - } - } - } - } - - for (const dir of candidates) { - markers.set(dir, await hasGitMarker(dir)) - } - return { markers, gateDirs, gateSignatures } -} - -function diffBase(prev: BaseSnapshot, next: BaseSnapshot): WorktreeBasePollEvent[] { - const events: WorktreeBasePollEvent[] = [] - for (const [dir, marker] of next.markers) { - if (marker && prev.markers.get(dir) !== true) { - events.push({ type: 'create', path: join(dir, '.git') }) - } - } - for (const dir of prev.markers.keys()) { - if (!next.markers.has(dir)) { - events.push({ type: 'delete', path: dir }) - } - } - return events -} - -async function startBasePoller( - target: WorktreeBaseWatchTarget, - getRepos: () => ReadonlyMap, - onEvents: (events: WorktreeBasePollEvent[]) => void, - pollIntervalMs: number, - visibility: WorktreePollerWindowVisibility, - options: WorktreeBasePollerOptions -): Promise { - let disposed = false - let ticking = false - let tickCount = 0 - let snapshot = await snapshotBase(target.path, getRepos()) - let timer: ReturnType | null = null - let parkedWhileHidden = false - const pendingMarkerMaxTicks = options.pendingMarkerMaxTicks ?? PENDING_MARKER_MAX_TICKS - // dir → first probe tick; null means backstop scans only - const markerProbeStartedAt = new Map() - for (const [dir, marker] of snapshot.markers) { - if (!marker) { - markerProbeStartedAt.set(dir, 0) - } - } - - const fullScan = async (): Promise => { - options.onFullScan?.() - const next = await snapshotBase(target.path, getRepos()) - await options.onSnapshotTaken?.(tickCount) - if (disposed) { - return - } - const events = diffBase(snapshot, next) - for (const [dir, marker] of next.markers) { - if (marker) { - markerProbeStartedAt.delete(dir) - } else if (!markerProbeStartedAt.has(dir)) { - markerProbeStartedAt.set(dir, tickCount) - } - } - for (const dir of markerProbeStartedAt.keys()) { - if (!next.markers.has(dir)) { - markerProbeStartedAt.delete(dir) - } - } - snapshot = next - if (events.length > 0) { - onEvents(events) - } - } - - const checkPendingMarkers = async (): Promise => { - const events: WorktreeBasePollEvent[] = [] - for (const [dir, firstSeenTick] of markerProbeStartedAt) { - if (firstSeenTick === null) { - continue - } - if (tickCount - firstSeenTick > pendingMarkerMaxTicks) { - markerProbeStartedAt.set(dir, null) - continue - } - options.onPendingMarkerProbe?.(join(dir, '.git')) - if (await hasGitMarker(dir)) { - markerProbeStartedAt.delete(dir) - snapshot.markers.set(dir, true) - events.push({ type: 'create', path: join(dir, '.git') }) - } - } - if (!disposed && events.length > 0) { - onEvents(events) - } - } - - const poll = async (forceFullScan = false): Promise => { - tickCount++ - if (forceFullScan || tickCount % WORKTREE_BASE_BACKSTOP_TICKS === 0) { - await fullScan() - return - } - // Idle fast path: when the dirs whose listings define the candidate set - // are untouched, skip the readdir + per-candidate stat fan-out entirely. - const signatures = await Promise.all(snapshot.gateDirs.map(dirSignature)) - const gateChanged = - signatures.length !== snapshot.gateSignatures.length || - signatures.some((sig, index) => sig !== snapshot.gateSignatures[index]) - if (gateChanged) { - await fullScan() - return - } - if (markerProbeStartedAt.size > 0) { - await checkPendingMarkers() - } - } - - const tick = async (forceFullScan = false): Promise => { - timer = null - if (disposed) { - return - } - if (!visibility.isWindowVisible()) { - parkedWhileHidden = true - return - } - if (ticking) { - return - } - ticking = true - // Why: measure from tick start so the cadence is start-to-start (like the old setInterval), not - // gap-after-completion — otherwise each visible refresh lands a full scan-duration late every tick. - const startedAt = Date.now() - try { - await poll(forceFullScan) - } catch { - // Transient fs error: keep the previous snapshot and retry next tick. - } finally { - ticking = false - } - if (!disposed) { - // Why: clamp to [0, pollIntervalMs]. Date.now() is not monotonic — a backward wall-clock jump (NTP) would - // otherwise make elapsed negative and push the next tick out by the adjustment (suppressing refreshes for - // minutes); the upper clamp caps the wait at one interval, the lower clamp keeps a long scan from going negative. - const nextDelay = Math.max( - 0, - Math.min(pollIntervalMs, pollIntervalMs - (Date.now() - startedAt)) - ) - timer = setTimeout(() => void tick(), nextDelay) - timer.unref?.() - } - } - - const unsubscribeVisibility = visibility.onWindowBecameVisible(() => { - if (disposed || !parkedWhileHidden) { - return - } - parkedWhileHidden = false - // Why: the ordinary dir-signature gate can miss same-granule changes made - // while hidden; resume must diff a fresh full snapshot against the baseline. - void tick(true) - }) - - timer = setTimeout(() => void tick(), pollIntervalMs) - timer.unref?.() - - return { - unsubscribe: async () => { - disposed = true - if (timer) { - clearTimeout(timer) - } - unsubscribeVisibility() - } - } -} - /** Watches the shallow paths a worktree base target cares about and emits * watcher-shaped events. Resolves once the baseline (snapshot or narrow * native subscription) is established. */ @@ -373,7 +104,8 @@ export async function startWorktreeBaseDirectoryPoller( visibility, options.onFullScan, options.getGitStatusRefPaths, - options.onWatchError + options.onWatchError, + options.onOverflow ) } return startBasePoller(target, getRepos, onEvents, pollIntervalMs, visibility, options) diff --git a/src/main/ipc/worktree-base-directory-watch-events.ts b/src/main/ipc/worktree-base-directory-watch-events.ts new file mode 100644 index 00000000000..caed5627b2b --- /dev/null +++ b/src/main/ipc/worktree-base-directory-watch-events.ts @@ -0,0 +1,90 @@ +import { + collectLocalWorktreeBaseChanges, + collectRemoteWorktreeBaseChanges, + hasCollectedWorktreeBaseChanges +} from './worktree-base-directory-change-collector' +import { + scheduleWorktreeBaseNotification, + type WorktreeBaseNotificationWatch +} from './worktree-base-directory-notifications' +import { + invalidateActiveGitStatusRefResolution, + invalidateGitStatusRefResolutionForPaths +} from './worktree-git-status-ref-watch' +import type { WorktreeWatcherFailureRefreshCooldown } from './worktree-watcher-failure-refresh-cooldown' + +export type ActiveWatch = WorktreeBaseNotificationWatch & { + subscription: { unsubscribe: () => Promise } + gitStatusRefPaths: Set + watcherFailureRefresh: WorktreeWatcherFailureRefreshCooldown +} + +export function handleLocalWatchEvents( + watch: ActiveWatch, + error: Error | null, + events: { type: 'create' | 'update' | 'delete'; path: string }[], + getActiveWatches: () => Iterable +): void { + if (watch.disposed || watch.mainWindow.isDestroyed()) { + return + } + if (error) { + console.warn(`[worktree-base-watcher] watcher failed for ${watch.path}:`, error) + invalidateActiveGitStatusRefResolution(watch, getActiveWatches) + if (watch.watcherFailureRefresh.consume()) { + scheduleWorktreeBaseNotification(watch, { structureRepoIds: [...watch.repos.keys()] }) + } + return + } + watch.watcherFailureRefresh.reset() + invalidateGitStatusRefResolutionForPaths( + watch, + events.map((event) => event.path), + getActiveWatches + ) + const changes = collectLocalWorktreeBaseChanges(watch, events) + if (hasCollectedWorktreeBaseChanges(changes)) { + scheduleWorktreeBaseNotification(watch, changes) + } +} + +// Why: after a dropped event batch nothing about the prior state can be +// trusted — widen unconditionally (structural + status + head-identity), +// same shape as the remote overflow branch below, bypassing the watcher-error +// cooldown so a burst of overflows during one bulk op cannot suppress the +// refresh the fleet actually needs. +export function handleWatchOverflow( + watch: ActiveWatch, + getActiveWatches: () => Iterable +): void { + if (watch.disposed || watch.mainWindow.isDestroyed()) { + return + } + invalidateActiveGitStatusRefResolution(watch, getActiveWatches) + scheduleWorktreeBaseNotification(watch, { structureRepoIds: [...watch.repos.keys()] }) +} + +export function handleRemoteWatchEvents( + watch: ActiveWatch, + events: Parameters[1], + getActiveWatches: () => Iterable +): void { + if (watch.disposed || watch.mainWindow.isDestroyed()) { + return + } + invalidateGitStatusRefResolutionForPaths( + watch, + events.flatMap((event) => + event.kind === 'overflow' ? [] : [event.absolutePath, event.oldAbsolutePath] + ), + getActiveWatches + ) + const changes = collectRemoteWorktreeBaseChanges(watch, events) + if (changes.overflow) { + handleWatchOverflow(watch, getActiveWatches) + return + } + if (hasCollectedWorktreeBaseChanges(changes)) { + scheduleWorktreeBaseNotification(watch, changes) + } +} diff --git a/src/main/ipc/worktree-base-directory-watcher.test.ts b/src/main/ipc/worktree-base-directory-watcher.test.ts index 9ce0712c9a0..a23bc901bdc 100644 --- a/src/main/ipc/worktree-base-directory-watcher.test.ts +++ b/src/main/ipc/worktree-base-directory-watcher.test.ts @@ -32,7 +32,14 @@ vi.mock('../providers/ssh-filesystem-dispatch', () => ({ })) vi.mock('./worktree-head-identity-reader', () => ({ - readGitCommonHeadIdentities: vi.fn(async () => []) + readGitCommonHeadIdentities: vi.fn(async () => ({ identities: [], complete: true })), + createWorktreeHeadIdentityCache: () => ({ + entries: new Map(), + unverified: new Set(), + entryNames: null, + primary: null, + primaryUnverified: false + }) })) import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' @@ -42,6 +49,7 @@ import { notifyWorktreesChanged } from './worktree-remote' import { readGitCommonHeadIdentities } from './worktree-head-identity-reader' +import type { WorktreeHeadIdentity } from '../../shared/worktree/types' import { startWorktreeBaseDirectoryPoller } from './worktree-base-directory-poller' import { disposeWorktreeBaseDirectoryWatchers, @@ -90,6 +98,10 @@ function makeWindow(options: { destroyed?: () => boolean } = {}) { } } +function mockHeadIdentities(identities: WorktreeHeadIdentity[]): void { + vi.mocked(readGitCommonHeadIdentities).mockResolvedValue({ identities, complete: true }) +} + function emit(root: string, events: WorktreeBasePollEvent[]): void { const callback = watcherCallbacks.get(root) if (!callback) { @@ -105,7 +117,7 @@ describe('worktree base directory watcher', () => { unsubscribeMocks.clear() pollerOptions.clear() vi.mocked(getSshFilesystemProvider).mockReturnValue(undefined) - vi.mocked(readGitCommonHeadIdentities).mockResolvedValue([]) + mockHeadIdentities([]) vi.mocked(startWorktreeBaseDirectoryPoller).mockImplementation( async (target, _getRepos, onEvents, options) => { const unsubscribe = vi.fn(async () => {}) @@ -392,6 +404,46 @@ describe('worktree base directory watcher', () => { expect(notifyWorktreesChanged).toHaveBeenCalledOnce() }) + it('widens an overflowed local git-common watch to a structural refresh', async () => { + await syncWorktreeBaseDirectoryWatchers(makeStore([makeRepo()]) as never, makeWindow() as never) + const onOverflow = pollerOptions.get(PROJECT_GIT_COMMON_DIR)?.onOverflow + + const request = { + worktreeId: `repo-1::${PROJECT_ROOT}`, + worktreePath: PROJECT_ROOT, + executionHostId: 'local', + branch: 'refs/heads/feature', + upstreamName: 'origin/feature' + } + const resolve = vi.fn(async () => 'refs/remotes/origin/feature') + await setWorktreeGitStatusRefWatch(request, resolve) + + onOverflow?.() + await vi.advanceTimersByTimeAsync(300) + + expect(notifyWorktreesChanged).toHaveBeenCalledWith(expect.anything(), 'repo-1') + // Overflow is definite proof of loss, not a possibly-transient error — it + // invalidates the cached ref resolution unconditionally. + await setWorktreeGitStatusRefWatch(request, resolve) + expect(resolve).toHaveBeenCalledTimes(2) + }) + + it('does not throttle repeated overflow refreshes the way watcher-error refreshes are throttled', async () => { + await syncWorktreeBaseDirectoryWatchers(makeStore([makeRepo()]) as never, makeWindow() as never) + const onOverflow = pollerOptions.get(PROJECT_GIT_COMMON_DIR)?.onOverflow + + onOverflow?.() + await vi.advanceTimersByTimeAsync(300) + onOverflow?.() + await vi.advanceTimersByTimeAsync(300) + + // A watcher-error burst within the 60s cooldown window collapses to one + // refresh (see "throttles repeated structural refreshes from watcher + // failures" above); overflow must not inherit that gate, since a bulk op + // can legitimately overflow more than once before it settles. + expect(notifyWorktreesChanged).toHaveBeenCalledTimes(2) + }) + it('keeps linked HEAD and lock metadata structural', async () => { await syncWorktreeBaseDirectoryWatchers(makeStore([makeRepo()]) as never, makeWindow() as never) @@ -414,7 +466,7 @@ describe('worktree base directory watcher', () => { it('emits head identities for a linked reflog head move without structural fanout', async () => { const linkedWorktree = absolutePath('workspace', 'worktrees', 'project', 'external-5104') - vi.mocked(readGitCommonHeadIdentities).mockResolvedValue([ + mockHeadIdentities([ { worktreePath: linkedWorktree, head: 'aaa111', @@ -425,7 +477,7 @@ describe('worktree base directory watcher', () => { await vi.advanceTimersByTimeAsync(0) // External commit --amend: only logs/HEAD moves, no index write. - vi.mocked(readGitCommonHeadIdentities).mockResolvedValue([ + mockHeadIdentities([ { worktreePath: linkedWorktree, head: 'bbb222', @@ -455,7 +507,7 @@ describe('worktree base directory watcher', () => { it('makes zero head-identity reads on an index-only burst across linked and primary checkouts', async () => { const linkedWorktree = absolutePath('workspace', 'worktrees', 'project', 'external-5104') - vi.mocked(readGitCommonHeadIdentities).mockResolvedValue([ + mockHeadIdentities([ { worktreePath: linkedWorktree, head: 'aaa111', @@ -490,15 +542,11 @@ describe('worktree base directory watcher', () => { }) it('emits head identities for a primary-checkout reflog head move', async () => { - vi.mocked(readGitCommonHeadIdentities).mockResolvedValue([ - { worktreePath: PROJECT_ROOT, head: 'aaa111', branch: 'refs/heads/main' } - ]) + mockHeadIdentities([{ worktreePath: PROJECT_ROOT, head: 'aaa111', branch: 'refs/heads/main' }]) await syncWorktreeBaseDirectoryWatchers(makeStore([makeRepo()]) as never, makeWindow() as never) await vi.advanceTimersByTimeAsync(0) - vi.mocked(readGitCommonHeadIdentities).mockResolvedValue([ - { worktreePath: PROJECT_ROOT, head: 'bbb222', branch: 'refs/heads/main' } - ]) + mockHeadIdentities([{ worktreePath: PROJECT_ROOT, head: 'bbb222', branch: 'refs/heads/main' }]) emit(PROJECT_GIT_COMMON_DIR, [ { type: 'update', path: join(PROJECT_GIT_COMMON_DIR, 'logs', 'HEAD') } ]) @@ -517,7 +565,7 @@ describe('worktree base directory watcher', () => { it('coalesces an index and reflog burst into one head read and one status refresh', async () => { const linkedWorktree = absolutePath('workspace', 'worktrees', 'project', 'external-5104') - vi.mocked(readGitCommonHeadIdentities).mockResolvedValue([ + mockHeadIdentities([ { worktreePath: linkedWorktree, head: 'aaa111', @@ -529,7 +577,7 @@ describe('worktree base directory watcher', () => { vi.mocked(readGitCommonHeadIdentities).mockClear() // reset --soft rewrites the index and appends logs/HEAD in the same burst. - vi.mocked(readGitCommonHeadIdentities).mockResolvedValue([ + mockHeadIdentities([ { worktreePath: linkedWorktree, head: 'bbb222', @@ -557,7 +605,7 @@ describe('worktree base directory watcher', () => { it('debounces successive reflog events into a single head read', async () => { const linkedWorktree = absolutePath('workspace', 'worktrees', 'project', 'external-5104') - vi.mocked(readGitCommonHeadIdentities).mockResolvedValue([ + mockHeadIdentities([ { worktreePath: linkedWorktree, head: 'aaa111', @@ -585,7 +633,7 @@ describe('worktree base directory watcher', () => { it('re-baselines head identities silently on structural notifications', async () => { const linkedWorktree = absolutePath('workspace', 'worktrees', 'project', 'external-5104') - vi.mocked(readGitCommonHeadIdentities).mockResolvedValue([ + mockHeadIdentities([ { worktreePath: linkedWorktree, head: 'aaa111', @@ -596,7 +644,7 @@ describe('worktree base directory watcher', () => { await vi.advanceTimersByTimeAsync(0) // Branch switch: structural path owns the refresh via the full listing. - vi.mocked(readGitCommonHeadIdentities).mockResolvedValue([ + mockHeadIdentities([ { worktreePath: linkedWorktree, head: 'ccc333', @@ -626,6 +674,70 @@ describe('worktree base directory watcher', () => { expect(notifyWorktreeHeadIdentitiesChanged).not.toHaveBeenCalled() }) + it('scopes a linked reflog head read to the worktree the event named', async () => { + await syncWorktreeBaseDirectoryWatchers(makeStore([makeRepo()]) as never, makeWindow() as never) + await vi.advanceTimersByTimeAsync(0) + vi.mocked(readGitCommonHeadIdentities).mockClear() + + emit(PROJECT_GIT_COMMON_DIR, [ + { + type: 'update', + path: join(PROJECT_GIT_COMMON_DIR, 'worktrees', 'external-5104', 'logs', 'HEAD') + } + ]) + await vi.advanceTimersByTimeAsync(300) + await vi.advanceTimersByTimeAsync(0) + + expect(readGitCommonHeadIdentities).toHaveBeenCalledWith( + PROJECT_GIT_COMMON_DIR, + expect.anything(), + { listing: false, primary: false, all: false, entryNames: new Set(['external-5104']) } + ) + }) + + it('re-reads every head identity when the watcher loses events', async () => { + await syncWorktreeBaseDirectoryWatchers(makeStore([makeRepo()]) as never, makeWindow() as never) + await vi.advanceTimersByTimeAsync(0) + vi.mocked(readGitCommonHeadIdentities).mockClear() + + // A watcher failure attributes to no worktree, so nothing may stay cached. + pollerOptions + .get(PROJECT_GIT_COMMON_DIR) + ?.onWatchError?.(new Error('Git common watcher interrupted')) + await vi.advanceTimersByTimeAsync(300) + await vi.advanceTimersByTimeAsync(0) + + expect(readGitCommonHeadIdentities).toHaveBeenCalledWith( + PROJECT_GIT_COMMON_DIR, + expect.anything(), + { + listing: true, + primary: true, + all: true, + entryNames: new Set() + } + ) + }) + + it('reads no head identities for a worktree lock write', async () => { + await syncWorktreeBaseDirectoryWatchers(makeStore([makeRepo()]) as never, makeWindow() as never) + await vi.advanceTimersByTimeAsync(0) + vi.mocked(readGitCommonHeadIdentities).mockClear() + + // `git worktree lock` is structural for the listing but can move no head. + emit(PROJECT_GIT_COMMON_DIR, [ + { + type: 'create', + path: join(PROJECT_GIT_COMMON_DIR, 'worktrees', 'external-5104', 'locked') + } + ]) + await vi.advanceTimersByTimeAsync(300) + await vi.advanceTimersByTimeAsync(0) + + expect(notifyWorktreesChanged).toHaveBeenCalledTimes(1) + expect(readGitCommonHeadIdentities).not.toHaveBeenCalled() + }) + it('never reads head identities for SSH watches', async () => { const remoteCallbacks = new Map void>() const remoteWatch = vi.fn(async (root: string, callback: (events: never[]) => void) => { diff --git a/src/main/ipc/worktree-base-directory-watcher.ts b/src/main/ipc/worktree-base-directory-watcher.ts index 411faae40d4..abb0d51782c 100644 --- a/src/main/ipc/worktree-base-directory-watcher.ts +++ b/src/main/ipc/worktree-base-directory-watcher.ts @@ -3,20 +3,15 @@ import type { Store } from '../persistence' import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' import { createWorktreeHeadIdentityRefreshState, - refreshWorktreeHeadIdentities, - type WorktreeHeadIdentityRefreshState + disposeWorktreeHeadIdentityRefreshState, + refreshWorktreeHeadIdentities } from './worktree-head-identity-refresh' -import { - collectLocalWorktreeBaseChanges, - collectRemoteWorktreeBaseChanges, - hasCollectedWorktreeBaseChanges -} from './worktree-base-directory-change-collector' import { clearPendingWorktreeBaseNotifications, - scheduleWorktreeBaseNotification, supportsWorktreeHeadIdentityRefresh } from './worktree-base-directory-notifications' import type { WorktreeBaseWatchTarget } from './worktree-base-directory-event-filter' +import { EMPTY_HEAD_IDENTITY_SCOPE } from './worktree-head-identity-scope' import { buildWorktreeBaseDirectoryWatchTargets, clearWorktreeBaseDirectoryWatchTargetWarnings @@ -28,25 +23,16 @@ import { import { applyActiveGitStatusRefBinding, clearActiveGitStatusRefBinding, - invalidateActiveGitStatusRefResolution, - invalidateGitStatusRefResolutionForPaths, updateActiveGitStatusRefBinding, type GitStatusRefBindingRequest } from './worktree-git-status-ref-watch' import { WorktreeWatcherFailureRefreshCooldown } from './worktree-watcher-failure-refresh-cooldown' - -type ActiveWatch = WorktreeBaseWatchTarget & { - mainWindow: BrowserWindow - subscription: { unsubscribe: () => Promise } - notifyTimer: ReturnType | null - pendingStructureRepoIds: Set - pendingGitStatusRepoIds: Set - pendingHeadIdentityRepoIds: Set - headIdentityRefresh: WorktreeHeadIdentityRefreshState - gitStatusRefPaths: Set - watcherFailureRefresh: WorktreeWatcherFailureRefreshCooldown - disposed: boolean -} +import { + handleLocalWatchEvents, + handleRemoteWatchEvents, + handleWatchOverflow, + type ActiveWatch +} from './worktree-base-directory-watch-events' const activeWatches = new Map() let syncGeneration = 0 @@ -59,59 +45,6 @@ export function setWorktreeGitStatusRefWatch( return updateActiveGitStatusRefBinding(args, () => activeWatches.values(), resolveUpstreamRef) } -function handleLocalWatchEvents( - watch: ActiveWatch, - error: Error | null, - events: { type: 'create' | 'update' | 'delete'; path: string }[] -): void { - if (watch.disposed || watch.mainWindow.isDestroyed()) { - return - } - if (error) { - console.warn(`[worktree-base-watcher] watcher failed for ${watch.path}:`, error) - invalidateActiveGitStatusRefResolution(watch, () => activeWatches.values()) - if (watch.watcherFailureRefresh.consume()) { - scheduleWorktreeBaseNotification(watch, { structureRepoIds: [...watch.repos.keys()] }) - } - return - } - watch.watcherFailureRefresh.reset() - invalidateGitStatusRefResolutionForPaths( - watch, - events.map((event) => event.path), - () => activeWatches.values() - ) - const changes = collectLocalWorktreeBaseChanges(watch, events) - if (hasCollectedWorktreeBaseChanges(changes)) { - scheduleWorktreeBaseNotification(watch, changes) - } -} - -function handleRemoteWatchEvents( - watch: ActiveWatch, - events: Parameters[1] -): void { - if (watch.disposed || watch.mainWindow.isDestroyed()) { - return - } - invalidateGitStatusRefResolutionForPaths( - watch, - events.flatMap((event) => - event.kind === 'overflow' ? [] : [event.absolutePath, event.oldAbsolutePath] - ), - () => activeWatches.values() - ) - const changes = collectRemoteWorktreeBaseChanges(watch, events) - if (changes.overflow) { - invalidateActiveGitStatusRefResolution(watch, () => activeWatches.values()) - scheduleWorktreeBaseNotification(watch, { structureRepoIds: [...watch.repos.keys()] }) - return - } - if (hasCollectedWorktreeBaseChanges(changes)) { - scheduleWorktreeBaseNotification(watch, changes) - } -} - function createActiveWatch( target: WorktreeBaseWatchTarget, mainWindow: BrowserWindow, @@ -126,6 +59,7 @@ function createActiveWatch( pendingStructureRepoIds: new Set(), pendingGitStatusRepoIds: new Set(), pendingHeadIdentityRepoIds: new Set(), + pendingHeadIdentityScope: EMPTY_HEAD_IDENTITY_SCOPE, headIdentityRefresh: createWorktreeHeadIdentityRefreshState(), gitStatusRefPaths, watcherFailureRefresh: new WorktreeWatcherFailureRefreshCooldown(), @@ -150,7 +84,7 @@ async function subscribeTarget( if (!currentWatch || currentWatch.disposed) { return } - handleRemoteWatchEvents(currentWatch, events) + handleRemoteWatchEvents(currentWatch, events, () => activeWatches.values()) }) activeWatch = createActiveWatch( target, @@ -171,7 +105,7 @@ async function subscribeTarget( (events) => { const currentWatch = activeWatches.get(target.key) ?? activeWatch if (currentWatch && !currentWatch.disposed) { - handleLocalWatchEvents(currentWatch, null, events) + handleLocalWatchEvents(currentWatch, null, events, () => activeWatches.values()) } }, { @@ -182,7 +116,13 @@ async function subscribeTarget( onWatchError: (error) => { const currentWatch = activeWatches.get(target.key) ?? activeWatch if (currentWatch && !currentWatch.disposed) { - handleLocalWatchEvents(currentWatch, error, []) + handleLocalWatchEvents(currentWatch, error, [], () => activeWatches.values()) + } + }, + onOverflow: () => { + const currentWatch = activeWatches.get(target.key) ?? activeWatch + if (currentWatch) { + handleWatchOverflow(currentWatch, () => activeWatches.values()) } } } @@ -233,6 +173,7 @@ async function removeWatch(key: string): Promise { activeWatches.delete(key) watch.disposed = true clearTimeout(watch.notifyTimer ?? undefined) + disposeWorktreeHeadIdentityRefreshState(watch.headIdentityRefresh) clearPendingWorktreeBaseNotifications(watch) await watch.subscription.unsubscribe().catch((error) => { console.warn(`[worktree-base-watcher] failed to unwatch ${watch.path}:`, error) diff --git a/src/main/ipc/worktree-git-common-entry-snapshot.ts b/src/main/ipc/worktree-git-common-entry-snapshot.ts index d14f4ec8a1d..6dad6e0a048 100644 --- a/src/main/ipc/worktree-git-common-entry-snapshot.ts +++ b/src/main/ipc/worktree-git-common-entry-snapshot.ts @@ -39,11 +39,33 @@ export async function snapshotGitCommonEntry( previous: GitCommonEntrySnapshot | undefined, forceFullScan: boolean ): Promise { - // Structural leaves change in place every tick; only index uses the entry-dir gate. + // Git writes HEAD/index/config.worktree/locked via a lock file + rename inside the + // entry dir, so the entry dir's own signature moves on every one of those writes + // (verified against git 2.55: checkout, commit, amend, reset, ref updates, stash, + // worktree lock/unlock, config --worktree, index writes all move it). The one + // in-place exception is `gitdir` (worktree move/repair), which the periodic + // forceFullScan backstop (INDEX_BACKSTOP_TICKS) below re-stats regardless of this + // gate. Gating all of these leaves on the entry-dir signature turns an unchanged + // entry into a single stat per tick instead of stat-ing every leaf every tick. + const nextDirSignature = await gitCommonDirectorySignature(entryPath) + if (nextDirSignature === 'missing') { + return ( + previous ?? { + dirSignature: nextDirSignature, + structuralSignatures: new Map(), + indexSignature: null, + headLogSignature: null + } + ) + } + const shouldRescan = forceFullScan || !previous || previous.dirSignature !== nextDirSignature + if (!shouldRescan) { + return previous + } const structuralSignatures = new Map() - const [nextDirSignature, headLogSignature] = await Promise.all([ - gitCommonDirectorySignature(entryPath), + const [headLogSignature, indexSignature] = await Promise.all([ gitCommonFileSignature(join(entryPath, HEAD_LOG_FILE)), + gitCommonFileSignature(join(entryPath, INDEX_FILE)), Promise.all( STRUCTURAL_METADATA_FILES.map(async (name) => { const signature = await gitCommonFileSignature(join(entryPath, name)) @@ -53,20 +75,6 @@ export async function snapshotGitCommonEntry( }) ) ]) - if (nextDirSignature === 'missing') { - return ( - previous ?? { - dirSignature: nextDirSignature, - structuralSignatures, - indexSignature: null, - headLogSignature - } - ) - } - const shouldReadIndex = forceFullScan || !previous || previous.dirSignature !== nextDirSignature - const indexSignature = shouldReadIndex - ? await gitCommonFileSignature(join(entryPath, INDEX_FILE)) - : previous.indexSignature return { dirSignature: nextDirSignature, structuralSignatures, diff --git a/src/main/ipc/worktree-git-common-narrow-watch.ts b/src/main/ipc/worktree-git-common-narrow-watch.ts index b60ac9877e8..fa7c402c5ec 100644 --- a/src/main/ipc/worktree-git-common-narrow-watch.ts +++ b/src/main/ipc/worktree-git-common-narrow-watch.ts @@ -24,7 +24,12 @@ export async function startGitCommonNarrowWatch( platform: NodeJS.Platform, visibility: WorktreePollerWindowVisibility, onFullScan?: () => void, - onWatchError?: (error: Error) => void + onWatchError?: (error: Error) => void, + // Why: a dropped event batch (>5,000 events, e.g. a fleet-wide bulk op) is a + // harder loss signal than a transient error — nothing about the prior state + // can be trusted, so this bypasses onWatchError's failure cooldown instead + // of reusing it. + onOverflow?: () => void ): Promise { const worktreesDir = join(target.path, 'worktrees') const watcherOptions = platform === 'win32' ? { backend: 'windows' as const } : {} @@ -73,6 +78,11 @@ export async function startGitCommonNarrowWatch( .unsubscribe() .catch(() => {}) .then(() => + // Crash fuse tripped: this poller is now the sole change signal until a + // future existence-poll upgrade (follow-up: #17878). Its own per-entry + // dir-signature gate (worktree-git-common-entry-snapshot.ts) already keeps + // an unchanged entry to a single stat, so a fixed `pollIntervalMs` cadence + // stays cheap at high worktree counts without needing to stretch itself. startGitCommonPolling( target.path, onEvents, @@ -222,6 +232,23 @@ export async function startGitCommonNarrowWatch( onEvents([{ type: 'update', path: worktreesDir }]) } } + }, + // Why: the watcher child drops the whole batch past 5,000 events + // (native FSEvents overflow maps to the same op) instead of reporting + // which paths changed. Unlike a transient error, this is definite + // proof of loss, so it always widens rather than falling back to the + // failure-cooldown-gated onWatchError path. + onOverflow: () => { + if (disposed || !active || generation !== nativeSubscriptionGeneration) { + return + } + if (onOverflow) { + onOverflow() + } else if (onWatchError) { + onWatchError(new Error('Git common watcher overflowed')) + } else { + onEvents([{ type: 'update', path: worktreesDir }]) + } } } ) diff --git a/src/main/ipc/worktree-git-common-polling.test.ts b/src/main/ipc/worktree-git-common-polling.test.ts new file mode 100644 index 00000000000..179b73e2c33 --- /dev/null +++ b/src/main/ipc/worktree-git-common-polling.test.ts @@ -0,0 +1,237 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mkdir, mkdtemp, rename, rm, writeFile } from 'node:fs/promises' +import type * as NodeFsPromises from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join, sep } from 'node:path' +import { startGitCommonPolling } from './worktree-git-common-polling' +import type { + WorktreeBasePollEvent, + WorktreePollerWindowVisibility +} from './worktree-base-directory-poller' + +// Why: measure the fan-out this poller issues per scan (peak concurrent `stat` +// calls, `readdir` call count as a proxy for "a tick ran") without depending on +// real disk timing (#17828). `entryZeroStatCalls` tracks every stat under a +// specific pre-existing entry (its dir plus every leaf), used to prove the +// entry-dir signature gate keeps an unchanged entry to one stat per tick. +const { statDelayMs, readdirCalls, concurrency, entryZeroStatCalls } = vi.hoisted(() => ({ + statDelayMs: { current: 0 }, + readdirCalls: { count: 0 }, + concurrency: { current: 0, peak: 0 }, + entryZeroStatCalls: { count: 0 } +})) + +vi.mock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + readdir: (...args: Parameters) => { + readdirCalls.count += 1 + return actual.readdir(...args) + }, + stat: async (...args: Parameters) => { + concurrency.current += 1 + concurrency.peak = Math.max(concurrency.peak, concurrency.current) + const path = args[0] + const entryZeroSegment = `${sep}wt-0` + if ( + typeof path === 'string' && + (path.endsWith(entryZeroSegment) || path.includes(`${entryZeroSegment}${sep}`)) + ) { + entryZeroStatCalls.count += 1 + } + try { + if (statDelayMs.current > 0) { + await new Promise((resolve) => setTimeout(resolve, statDelayMs.current)) + } + return await actual.stat(...args) + } finally { + concurrency.current -= 1 + } + } + } +}) + +const alwaysVisible: WorktreePollerWindowVisibility = { + isWindowVisible: () => true, + onWindowBecameVisible: () => () => {} +} + +async function makeCommonDir(entryCount: number): Promise { + const root = await mkdtemp(join(tmpdir(), 'git-common-polling-test-')) + for (let i = 0; i < entryCount; i++) { + const entryPath = join(root, 'worktrees', `wt-${i}`) + await mkdir(join(entryPath, 'logs'), { recursive: true }) + await Promise.all([ + writeFile(join(entryPath, 'HEAD'), 'ref: refs/heads/main\n'), + writeFile(join(entryPath, 'gitdir'), `${join(root, `checkout-${i}`, '.git')}\n`), + writeFile(join(entryPath, 'index'), Buffer.from([0])), + writeFile(join(entryPath, 'logs', 'HEAD'), '0000 aaaa\n') + ]) + } + return root +} + +describe('startGitCommonPolling fan-out bounds (#17828)', () => { + const cleanups: (() => Promise)[] = [] + const dirsToRemove: string[] = [] + + beforeEach(() => { + statDelayMs.current = 0 + readdirCalls.count = 0 + concurrency.current = 0 + concurrency.peak = 0 + entryZeroStatCalls.count = 0 + }) + + afterEach(async () => { + await Promise.all(cleanups.splice(0).map((cleanup) => cleanup())) + await Promise.all( + dirsToRemove.splice(0).map((dir) => rm(dir, { recursive: true, force: true })) + ) + vi.useRealTimers() + }) + + it('bounds concurrent per-entry stat fan-out regardless of entry count', async () => { + const commonDir = await makeCommonDir(200) + dirsToRemove.push(commonDir) + const sub = await startGitCommonPolling(commonDir, () => {}, 100_000, alwaysVisible) + cleanups.push(() => sub.unsubscribe()) + // 200 entries x ~6 concurrent structural stats each would peak near 1,200 + // unbounded; bounding to 8 in-flight entries keeps the peak independent of + // entry count instead of scaling with it. + expect(concurrency.peak).toBeLessThan(80) + }) + + it('never overlaps a scan with itself even when ticks fire faster than a scan completes', async () => { + const commonDir = await makeCommonDir(10) + dirsToRemove.push(commonDir) + statDelayMs.current = 20 + const pollIntervalMs = 5 + const sub = await startGitCommonPolling(commonDir, () => {}, pollIntervalMs, alwaysVisible) + cleanups.push(() => sub.unsubscribe()) + readdirCalls.count = 0 + // ~60 would-be 5ms ticks elapse in this window while every stat takes 20ms; + // the ticking guard must serialize scans, not launch overlapping ones. + await new Promise((resolve) => setTimeout(resolve, 300)) + expect(readdirCalls.count).toBeLessThan(10) + }) + + it('costs exactly one stat per tick for an unchanged entry', async () => { + const commonDir = await makeCommonDir(1) + dirsToRemove.push(commonDir) + const pollIntervalMs = 20 + const sub = await startGitCommonPolling(commonDir, () => {}, pollIntervalMs, alwaysVisible) + cleanups.push(() => sub.unsubscribe()) + + // Let the bootstrap snapshot (which always fully reads every entry once) settle. + await new Promise((resolve) => setTimeout(resolve, pollIntervalMs)) + readdirCalls.count = 0 + entryZeroStatCalls.count = 0 + await vi.waitFor( + () => { + expect(readdirCalls.count).toBeGreaterThanOrEqual(5) + }, + { timeout: 2_000 } + ) + // Without the entry-dir signature gate, an unchanged entry still costs ~6 + // stats every tick (HEAD/gitdir/locked/config.worktree/logs/HEAD/index). + // With the gate, only the entry dir itself is stat'd once nothing changed — + // one stat per tick, in lockstep with the readdir tripwire. + expect(entryZeroStatCalls.count).toBeLessThanOrEqual(readdirCalls.count + 1) + expect(entryZeroStatCalls.count).toBeGreaterThanOrEqual(readdirCalls.count - 1) + }) + + it('detects a HEAD rewrite via lock+rename on the next tick', async () => { + const commonDir = await makeCommonDir(1) + dirsToRemove.push(commonDir) + const events: WorktreeBasePollEvent[][] = [] + const pollIntervalMs = 20 + const sub = await startGitCommonPolling( + commonDir, + (batch) => events.push(batch), + pollIntervalMs, + alwaysVisible + ) + cleanups.push(() => sub.unsubscribe()) + // Let the bootstrap snapshot settle before mutating. + await new Promise((resolve) => setTimeout(resolve, pollIntervalMs)) + + const entryDir = join(commonDir, 'worktrees', 'wt-0') + const headPath = join(entryDir, 'HEAD') + const headLockPath = join(entryDir, 'HEAD.lock') + // Every real git ref write goes through a lock file + rename inside the entry + // dir (never an in-place overwrite), which moves the entry dir's own signature. + await writeFile(headLockPath, 'ref: refs/heads/feature\n') + await rename(headLockPath, headPath) + + await vi.waitFor( + () => { + expect(events.flat()).toContainEqual({ type: 'update', path: headPath }) + }, + { timeout: pollIntervalMs * 10 } + ) + }) + + it('detects an in-place gitdir rewrite only once the periodic backstop rescans it', async () => { + const commonDir = await makeCommonDir(1) + dirsToRemove.push(commonDir) + const events: WorktreeBasePollEvent[][] = [] + const pollIntervalMs = 10 + const sub = await startGitCommonPolling( + commonDir, + (batch) => events.push(batch), + pollIntervalMs, + alwaysVisible + ) + cleanups.push(() => sub.unsubscribe()) + // Let the bootstrap snapshot settle before mutating. + await new Promise((resolve) => setTimeout(resolve, pollIntervalMs)) + + const entryDir = join(commonDir, 'worktrees', 'wt-0') + const gitdirPath = join(entryDir, 'gitdir') + // `gitdir` is the one structural leaf git rewrites in place (worktree move/repair), + // so the entry dir's own signature never moves — the periodic ungated backstop + // (INDEX_BACKSTOP_TICKS = 15) is the only thing that catches it. + await writeFile(gitdirPath, `${join(commonDir, 'checkout-moved', '.git')}\n`) + + // Not caught by the next several ticks: the gate stays closed since nothing + // moved the entry dir's own signature. + await new Promise((resolve) => setTimeout(resolve, pollIntervalMs * 5)) + expect(events.flat()).not.toContainEqual({ type: 'update', path: gitdirPath }) + + // Eventually caught regardless of the gate, once tick 15 forces the periodic backstop. + await vi.waitFor( + () => { + expect(events.flat()).toContainEqual({ type: 'update', path: gitdirPath }) + }, + { timeout: pollIntervalMs * 40 } + ) + }) + + it('still detects entry add/remove correctly with bounded concurrency', async () => { + const commonDir = await makeCommonDir(5) + dirsToRemove.push(commonDir) + const events: WorktreeBasePollEvent[][] = [] + const sub = await startGitCommonPolling( + commonDir, + (batch) => events.push(batch), + 20, + alwaysVisible + ) + cleanups.push(() => sub.unsubscribe()) + + const newEntry = join(commonDir, 'worktrees', 'wt-new') + await mkdir(join(newEntry, 'logs'), { recursive: true }) + await writeFile(join(newEntry, 'HEAD'), 'ref: refs/heads/main\n') + + await vi.waitFor(() => { + expect(events.flat()).toContainEqual({ type: 'create', path: newEntry }) + }) + + await rm(newEntry, { recursive: true }) + await vi.waitFor(() => { + expect(events.flat()).toContainEqual({ type: 'delete', path: newEntry }) + }) + }) +}) diff --git a/src/main/ipc/worktree-git-common-polling.ts b/src/main/ipc/worktree-git-common-polling.ts index 0418f4a90fd..4b43835a81f 100644 --- a/src/main/ipc/worktree-git-common-polling.ts +++ b/src/main/ipc/worktree-git-common-polling.ts @@ -1,5 +1,6 @@ import { readdir } from 'node:fs/promises' import { join } from 'node:path' +import { forEachWithConcurrency } from '../../shared/map-with-concurrency' import { PRIMARY_CHECKOUT_METADATA_FILES } from './worktree-git-common-metadata-files' import { diffGitCommon, @@ -23,18 +24,26 @@ import { // same way the base poller's backstop rescan does. const INDEX_BACKSTOP_TICKS = 15 +// Why: an unbounded fan-out across every worktree admin entry queues thousands +// of ops on libuv's 4-thread default pool, starving every other main-process +// fs call for the scan's duration (#17828). 8 mirrors the existing +// head-identity/exact-ref-probe pools — enough to saturate typical local +// disks without monopolizing the pool. Since snapshotGitCommonEntry's own +// entry-dir gate (see worktree-git-common-entry-snapshot.ts) keeps most ticks +// down to 1 stat per unchanged entry, real in-flight is now bounded by this +// limit rather than limit × per-entry stat count. +const GIT_COMMON_SNAPSHOT_CONCURRENCY = 8 + async function snapshotStatusRefSignatures( paths: ReadonlySet ): Promise> { const signatures = new Map() - await Promise.all( - [...paths].map(async (path) => { - const signature = await gitCommonFileSignature(path) - if (signature !== null) { - signatures.set(path, signature) - } - }) - ) + await forEachWithConcurrency([...paths], GIT_COMMON_SNAPSHOT_CONCURRENCY, async (path) => { + const signature = await gitCommonFileSignature(path) + if (signature !== null) { + signatures.set(path, signature) + } + }) return signatures } @@ -91,12 +100,10 @@ async function snapshotGitCommon( } const entries = new Map() - await Promise.all( - entryPaths.map(async (entryPath) => { - const previousEntry = previous?.entries.get(entryPath) - entries.set(entryPath, await snapshotGitCommonEntry(entryPath, previousEntry, forceFullScan)) - }) - ) + await forEachWithConcurrency(entryPaths, GIT_COMMON_SNAPSHOT_CONCURRENCY, async (entryPath) => { + const previousEntry = previous?.entries.get(entryPath) + entries.set(entryPath, await snapshotGitCommonEntry(entryPath, previousEntry, forceFullScan)) + }) // Why: the expensive per-entry `index` read stays gated on each entry's own dir signature; onFullScan // now reflects an ungated index-metadata backstop fan-out (forceFullScan) — the real periodic cost — // rather than the always-run worktrees-dir readdir. diff --git a/src/main/ipc/worktree-git-common-watch.test.ts b/src/main/ipc/worktree-git-common-watch.test.ts index 619133263de..bad700b5445 100644 --- a/src/main/ipc/worktree-git-common-watch.test.ts +++ b/src/main/ipc/worktree-git-common-watch.test.ts @@ -526,6 +526,45 @@ describe('worktree git-common narrow watch (local native platforms)', () => { expect(narrowSubscription().unsubscribe).not.toHaveBeenCalled() }) + it('routes a dropped event batch through the dedicated overflow callback', async () => { + installSubscribeMock() + const commonDir = await makeCommonDir(true) + const received: WorktreeBasePollEvent[][] = [] + const onOverflow = vi.fn() + const watch = await startGitCommonWatch( + makeTarget(commonDir), + (events) => received.push(events), + POLL_MS, + 'darwin', + alwaysVisible, + undefined, + () => [], + undefined, + onOverflow + ) + cleanups.push(() => watch.unsubscribe()) + + narrowSubscription().hooks.onOverflow?.() + + expect(onOverflow).toHaveBeenCalledOnce() + // The dedicated callback owns the refresh; the generic event/error paths + // must not also fire so the caller cannot double-count the same loss. + expect(received).toEqual([]) + expect(narrowSubscription().unsubscribe).not.toHaveBeenCalled() + }) + + it('falls back to a structural change when no overflow callback is wired', async () => { + installSubscribeMock() + const commonDir = await makeCommonDir(true) + const worktreesDir = join(commonDir, 'worktrees') + const received: WorktreeBasePollEvent[][] = [] + await startWatch(commonDir, received) + + narrowSubscription().hooks.onOverflow?.() + + expect(received.flat()).toContainEqual({ type: 'update', path: worktreesDir }) + }) + it('arms via existence polling when the worktrees dir appears later', async () => { installSubscribeMock() const commonDir = await makeCommonDir(false) diff --git a/src/main/ipc/worktree-git-common-watch.ts b/src/main/ipc/worktree-git-common-watch.ts index 9de2c8c3392..d719ca257bb 100644 --- a/src/main/ipc/worktree-git-common-watch.ts +++ b/src/main/ipc/worktree-git-common-watch.ts @@ -31,7 +31,8 @@ export async function startGitCommonWatch( visibility: WorktreePollerWindowVisibility, onFullScan?: () => void, getStatusRefPaths: () => readonly string[] = () => [], - onWatchError?: (error: Error) => void + onWatchError?: (error: Error) => void, + onOverflow?: () => void ): Promise { if (supportsNarrowWatch(platform)) { const [narrowWatch, primaryWatch] = await Promise.all([ @@ -42,7 +43,8 @@ export async function startGitCommonWatch( platform, visibility, onFullScan, - onWatchError + onWatchError, + onOverflow ), startGitCommonPrimaryWatch( target.path, @@ -60,6 +62,8 @@ export async function startGitCommonWatch( } } } + // Why: Electron only ships darwin/linux/win32, all covered by NARROW_WATCH_PLATFORMS + // above, so this branch is defensive dead code in production, not a reachable fallback. return startGitCommonPolling( target.path, onEvents, diff --git a/src/main/ipc/worktree-head-identity-reader-concurrency.test.ts b/src/main/ipc/worktree-head-identity-reader-concurrency.test.ts index 396280f8295..ac94573e5f9 100644 --- a/src/main/ipc/worktree-head-identity-reader-concurrency.test.ts +++ b/src/main/ipc/worktree-head-identity-reader-concurrency.test.ts @@ -40,7 +40,7 @@ describe('readGitCommonHeadIdentities concurrency', () => { }) it('overlaps linked-worktree metadata reads while preserving listing order', async () => { - const identities = await readGitCommonHeadIdentities('/repo/common') + const { identities } = await readGitCommonHeadIdentities('/repo/common') expect(identities).toHaveLength(WORKTREE_COUNT) expect(identities.map((identity) => identity.worktreePath)).toEqual( diff --git a/src/main/ipc/worktree-head-identity-reader-incremental.test.ts b/src/main/ipc/worktree-head-identity-reader-incremental.test.ts new file mode 100644 index 00000000000..5be8d0ed1bd --- /dev/null +++ b/src/main/ipc/worktree-head-identity-reader-incremental.test.ts @@ -0,0 +1,414 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { + createWorktreeHeadIdentityCache, + readGitCommonHeadIdentities, + type WorktreeHeadIdentityCache +} from './worktree-head-identity-reader' +import { + FULL_HEAD_IDENTITY_SCOPE, + headIdentityScopeForEntry, + LISTING_HEAD_IDENTITY_SCOPE, + mergeHeadIdentityScopes, + PRIMARY_HEAD_IDENTITY_SCOPE +} from './worktree-head-identity-scope' + +const readIdentities = async ( + ...args: Parameters +): Promise>['identities']> => + (await readGitCommonHeadIdentities(...args)).identities + +const OID_A = 'a'.repeat(40) +const OID_B = 'b'.repeat(40) +const OID_C = 'c'.repeat(40) +const OID_D = 'd'.repeat(40) + +describe('readGitCommonHeadIdentities (incremental)', () => { + const roots: string[] = [] + + afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) + }) + + async function writeLooseRef(commonDir: string, ref: string, oid: string): Promise { + const refPath = join(commonDir, ...ref.split('/')) + await mkdir(dirname(refPath), { recursive: true }) + await writeFile(refPath, `${oid}\n`) + } + + async function makeCommonDir(): Promise { + const root = await mkdtemp(join(tmpdir(), 'orca-head-incremental-')) + roots.push(root) + const commonDir = join(root, 'checkout', '.git') + await mkdir(commonDir, { recursive: true }) + await writeFile(join(commonDir, 'HEAD'), 'ref: refs/heads/main\n') + await writeLooseRef(commonDir, 'refs/heads/main', OID_A) + return commonDir + } + + async function addLinkedWorktree(commonDir: string, name: string, head: string): Promise { + const entry = join(commonDir, 'worktrees', name) + await mkdir(entry, { recursive: true }) + await writeFile(join(entry, 'HEAD'), `${head}\n`) + const worktreePath = join(dirname(dirname(commonDir)), name) + await mkdir(worktreePath, { recursive: true }) + await writeFile(join(entry, 'gitdir'), `${join(worktreePath, '.git')}\n`) + return worktreePath + } + + function headOf( + identities: { worktreePath: string; head: string }[], + worktreePath: string + ): string | undefined { + return identities.find((identity) => identity.worktreePath === worktreePath)?.head + } + + async function seed(): Promise<{ + commonDir: string + cache: WorktreeHeadIdentityCache + pathA: string + pathB: string + }> { + const commonDir = await makeCommonDir() + await writeLooseRef(commonDir, 'refs/heads/feature-a', OID_B) + await writeLooseRef(commonDir, 'refs/heads/feature-b', OID_C) + const pathA = await addLinkedWorktree(commonDir, 'wt-a', 'ref: refs/heads/feature-a') + const pathB = await addLinkedWorktree(commonDir, 'wt-b', 'ref: refs/heads/feature-b') + const cache = createWorktreeHeadIdentityCache() + // Cold start: no cache and no scope, so every entry is read. + const identities = await readIdentities(commonDir, cache) + expect(identities).toHaveLength(3) + expect(headOf(identities, pathA)).toBe(OID_B) + expect(headOf(identities, pathB)).toBe(OID_C) + return { commonDir, cache, pathA, pathB } + } + + it('reads every entry on cold start with an empty cache', async () => { + const { cache } = await seed() + expect([...cache.entries.keys()].sort()).toEqual(['wt-a', 'wt-b']) + expect(cache.primary?.head).toBe(OID_A) + }) + + it('re-reads only the committing worktree and leaves the rest cached', async () => { + const { commonDir, cache, pathA, pathB } = await seed() + + // A commit in wt-a moves its branch; wt-b's branch also moves on disk but + // no watcher event named it, so the incremental read must not observe it. + await writeLooseRef(commonDir, 'refs/heads/feature-a', OID_D) + await writeLooseRef(commonDir, 'refs/heads/feature-b', OID_D) + + const scoped = await readIdentities(commonDir, cache, headIdentityScopeForEntry('wt-a')) + expect(headOf(scoped, pathA)).toBe(OID_D) + expect(headOf(scoped, pathB)).toBe(OID_C) + + const followUp = await readIdentities(commonDir, cache, headIdentityScopeForEntry('wt-b')) + expect(headOf(followUp, pathB)).toBe(OID_D) + }) + + it('picks up a branch switch through the entry scope', async () => { + const { commonDir, cache, pathA } = await seed() + await writeLooseRef(commonDir, 'refs/heads/other', OID_D) + await writeFile(join(commonDir, 'worktrees', 'wt-a', 'HEAD'), 'ref: refs/heads/other\n') + + const identities = await readIdentities(commonDir, cache, headIdentityScopeForEntry('wt-a')) + + expect(identities).toContainEqual({ + worktreePath: pathA, + head: OID_D, + branch: 'refs/heads/other' + }) + }) + + it('adds an externally created worktree through the listing scope', async () => { + const { commonDir, cache, pathA } = await seed() + await writeLooseRef(commonDir, 'refs/heads/feature-c', OID_D) + const pathC = await addLinkedWorktree(commonDir, 'wt-c', 'ref: refs/heads/feature-c') + // The other entries move on disk with no event of their own and must stay cached. + await writeLooseRef(commonDir, 'refs/heads/feature-a', OID_D) + + const identities = await readIdentities(commonDir, cache, LISTING_HEAD_IDENTITY_SCOPE) + + expect(headOf(identities, pathC)).toBe(OID_D) + expect(headOf(identities, pathA)).toBe(OID_B) + }) + + it('drops an externally removed worktree through the listing scope', async () => { + const { commonDir, cache, pathA, pathB } = await seed() + await rm(join(commonDir, 'worktrees', 'wt-b'), { recursive: true, force: true }) + + const identities = await readIdentities(commonDir, cache, LISTING_HEAD_IDENTITY_SCOPE) + + expect(identities.map((identity) => identity.worktreePath)).toEqual([dirname(commonDir), pathA]) + expect(headOf(identities, pathB)).toBeUndefined() + expect(cache.entries.has('wt-b')).toBe(false) + }) + + it('re-reads an admin entry whose name was removed and immediately reused', async () => { + const { commonDir, cache, pathA } = await seed() + // One debounce window can coalesce `git worktree remove` + `git worktree + // add` onto the same admin dir name, so the listing alone is not enough. + await rm(join(commonDir, 'worktrees', 'wt-a'), { recursive: true, force: true }) + await writeLooseRef(commonDir, 'refs/heads/reused', OID_D) + const reusedPath = await addLinkedWorktree(commonDir, 'wt-a', 'ref: refs/heads/reused') + + const identities = await readIdentities( + commonDir, + cache, + mergeHeadIdentityScopes(LISTING_HEAD_IDENTITY_SCOPE, headIdentityScopeForEntry('wt-a')) + ) + + expect(reusedPath).toBe(pathA) + expect(identities).toContainEqual({ + worktreePath: reusedPath, + head: OID_D, + branch: 'refs/heads/reused' + }) + }) + + it('keeps the memo when the worktrees listing fails for anything but absence', async () => { + const { commonDir, cache, pathA, pathB } = await seed() + // Stand in for EIO/ESTALE/EMFILE: readdir rejects with ENOTDIR, which is + // not evidence that every worktree was removed. + await rm(join(commonDir, 'worktrees'), { recursive: true, force: true }) + await writeFile(join(commonDir, 'worktrees'), 'not a directory\n') + + const identities = await readIdentities(commonDir, cache, LISTING_HEAD_IDENTITY_SCOPE) + + expect(headOf(identities, pathA)).toBe(OID_B) + expect(headOf(identities, pathB)).toBe(OID_C) + // The add/remove that triggered the burst is still unseen, so the next + // refresh must re-enumerate whatever scope it is given. + expect(cache.entryNames).toBeNull() + + await rm(join(commonDir, 'worktrees'), { force: true }) + await writeLooseRef(commonDir, 'refs/heads/feature-c', OID_D) + const pathC = await addLinkedWorktree(commonDir, 'wt-c', 'ref: refs/heads/feature-c') + const recovered = await readIdentities(commonDir, cache, headIdentityScopeForEntry('wt-a')) + expect(headOf(recovered, pathC)).toBe(OID_D) + }) + + it('reports an absent worktrees dir as genuinely empty', async () => { + const { commonDir, cache, pathA } = await seed() + await rm(join(commonDir, 'worktrees'), { recursive: true, force: true }) + + const identities = await readIdentities(commonDir, cache, LISTING_HEAD_IDENTITY_SCOPE) + + expect(headOf(identities, pathA)).toBeUndefined() + expect(cache.entries.size).toBe(0) + }) + + it('needs the full scope, not a narrow one, to survive a packed-refs rewrite', async () => { + const { commonDir, cache, pathA, pathB } = await seed() + // A fetch repacked refs: the loose files are gone and the oids moved, with + // no event under any admin dir. + await rm(join(commonDir, 'refs', 'heads'), { recursive: true, force: true }) + await writeFile( + join(commonDir, 'packed-refs'), + [ + '# pack-refs with: peeled fully-peeled sorted', + `${OID_D} refs/heads/main`, + `${OID_D} refs/heads/feature-a`, + `${OID_D} refs/heads/feature-b`, + '' + ].join('\n') + ) + + // Negative control: this is exactly why `packed-refs` must classify to the + // full scope — any narrower scope misses the repack for unnamed entries. + const narrow = await readIdentities(commonDir, cache, headIdentityScopeForEntry('wt-a')) + expect(headOf(narrow, pathA)).toBe(OID_D) + expect(headOf(narrow, pathB)).toBe(OID_C) + + const identities = await readIdentities(commonDir, cache, FULL_HEAD_IDENTITY_SCOPE) + + expect(headOf(identities, dirname(commonDir))).toBe(OID_D) + expect(headOf(identities, pathA)).toBe(OID_D) + expect(headOf(identities, pathB)).toBe(OID_D) + }) + + it('keeps a worktree whose checkout was deleted behind Orca back', async () => { + const { commonDir, cache, pathA } = await seed() + // Only the checkout is gone; git prunes the admin entry lazily, and the + // structural listing — not this reader — owns the prunable verdict. + await rm(pathA, { recursive: true, force: true }) + + const identities = await readIdentities( + commonDir, + cache, + mergeHeadIdentityScopes(LISTING_HEAD_IDENTITY_SCOPE, headIdentityScopeForEntry('wt-a')) + ) + + expect(headOf(identities, pathA)).toBe(OID_B) + }) + + it('follows a branch shared by several worktrees without re-reading them', async () => { + const commonDir = await makeCommonDir() + await writeLooseRef(commonDir, 'refs/heads/shared', OID_B) + // `git worktree add --force` lets two worktrees hold one branch, and only + // the committing one gets a HEAD reflog append. + const pathA = await addLinkedWorktree(commonDir, 'wt-a', 'ref: refs/heads/shared') + const pathB = await addLinkedWorktree(commonDir, 'wt-b', 'ref: refs/heads/shared') + const cache = createWorktreeHeadIdentityCache() + await readIdentities(commonDir, cache) + + await writeLooseRef(commonDir, 'refs/heads/shared', OID_D) + // Make wt-b unreadable: if the replay re-read it, it would resolve to + // nothing and drop out. Surviving with the new oid proves it was replayed + // from the memo rather than re-read. + await rm(join(commonDir, 'worktrees', 'wt-b', 'gitdir'), { force: true }) + const identities = await readIdentities(commonDir, cache, headIdentityScopeForEntry('wt-a')) + + expect(headOf(identities, pathA)).toBe(OID_D) + expect(headOf(identities, pathB)).toBe(OID_D) + }) + + it('re-enumerates when the scope names an entry the memoized listing lacks', async () => { + const { commonDir, cache } = await seed() + await writeLooseRef(commonDir, 'refs/heads/feature-c', OID_D) + const pathC = await addLinkedWorktree(commonDir, 'wt-c', 'ref: refs/heads/feature-c') + + // An entry-only scope (`worktrees/wt-c/HEAD`) with no listing bit must not + // resolve to zero work just because the memo predates the entry. + const identities = await readIdentities(commonDir, cache, headIdentityScopeForEntry('wt-c')) + + expect(headOf(identities, pathC)).toBe(OID_D) + }) + + it('propagates a primary-checkout head move to a linked worktree on the same branch', async () => { + const commonDir = await makeCommonDir() + const pathA = await addLinkedWorktree(commonDir, 'wt-a', 'ref: refs/heads/main') + const cache = createWorktreeHeadIdentityCache() + await readIdentities(commonDir, cache) + + await writeLooseRef(commonDir, 'refs/heads/main', OID_D) + const identities = await readIdentities(commonDir, cache, PRIMARY_HEAD_IDENTITY_SCOPE) + + expect(headOf(identities, dirname(commonDir))).toBe(OID_D) + expect(headOf(identities, pathA)).toBe(OID_D) + }) + + it('drops entries whose branch stopped resolving instead of serving a stale head', async () => { + const commonDir = await makeCommonDir() + await writeLooseRef(commonDir, 'refs/heads/shared', OID_B) + const pathA = await addLinkedWorktree(commonDir, 'wt-a', 'ref: refs/heads/shared') + const pathB = await addLinkedWorktree(commonDir, 'wt-b', 'ref: refs/heads/shared') + const cache = createWorktreeHeadIdentityCache() + await readIdentities(commonDir, cache) + + await rm(join(commonDir, 'refs', 'heads', 'shared'), { force: true }) + const identities = await readIdentities(commonDir, cache, headIdentityScopeForEntry('wt-a')) + + expect(headOf(identities, pathA)).toBeUndefined() + expect(headOf(identities, pathB)).toBeUndefined() + }) + + it('keeps the last verified identity when an entry read fails transiently', async () => { + const { commonDir, cache, pathA, pathB } = await seed() + // EISDIR stands in for EIO/EACCES/ENFILE: the read fails for a reason that + // is not absence, so the entry is UNKNOWN — never reported as gone. + await rm(join(commonDir, 'worktrees', 'wt-a', 'HEAD')) + await mkdir(join(commonDir, 'worktrees', 'wt-a', 'HEAD')) + + const scoped = await readIdentities(commonDir, cache, headIdentityScopeForEntry('wt-a')) + expect(headOf(scoped, pathA)).toBe(OID_B) + expect(cache.unverified.has('wt-a')).toBe(true) + + // And an unknown never evicts a sibling that merely shares the branch. + expect(headOf(scoped, pathB)).toBe(OID_C) + + // Retried on the very next pass even though nothing names it, and the pass + // is reported incomplete so it cannot pass for a freshness checkpoint. + const stillBroken = await readGitCommonHeadIdentities(commonDir, cache) + expect(stillBroken.complete).toBe(false) + + await rm(join(commonDir, 'worktrees', 'wt-a', 'HEAD'), { recursive: true }) + await writeFile(join(commonDir, 'worktrees', 'wt-a', 'HEAD'), 'ref: refs/heads/feature-a\n') + await writeLooseRef(commonDir, 'refs/heads/feature-a', OID_D) + const recovered = await readGitCommonHeadIdentities( + commonDir, + cache, + PRIMARY_HEAD_IDENTITY_SCOPE + ) + expect(headOf(recovered.identities, pathA)).toBe(OID_D) + expect(recovered.complete).toBe(true) + expect(cache.unverified.size).toBe(0) + }) + + it('does not evict a whole branch when one entry read fails transiently', async () => { + const commonDir = await makeCommonDir() + await writeLooseRef(commonDir, 'refs/heads/shared', OID_B) + const pathA = await addLinkedWorktree(commonDir, 'wt-a', 'ref: refs/heads/shared') + const pathB = await addLinkedWorktree(commonDir, 'wt-b', 'ref: refs/heads/shared') + const cache = createWorktreeHeadIdentityCache() + await readIdentities(commonDir, cache) + + // The shared ref itself becomes unreadable while wt-a is the scoped entry. + await rm(join(commonDir, 'refs', 'heads', 'shared')) + await mkdir(join(commonDir, 'refs', 'heads', 'shared')) + const identities = await readIdentities(commonDir, cache, headIdentityScopeForEntry('wt-a')) + + // An unknown must not be replayed onto siblings as "this branch is gone". + expect(headOf(identities, pathA)).toBe(OID_B) + expect(headOf(identities, pathB)).toBe(OID_B) + }) + + it('never caches a miss, so a transient unreadable entry is retried', async () => { + const commonDir = await makeCommonDir() + const entry = join(commonDir, 'worktrees', 'wt-a') + await mkdir(entry, { recursive: true }) + await writeFile(join(entry, 'HEAD'), 'ref: refs/heads/feature-a\n') + const cache = createWorktreeHeadIdentityCache() + + // No `gitdir` yet (mid `git worktree add`): unresolvable, so nothing is memoized. + expect(await readIdentities(commonDir, cache)).toHaveLength(1) + expect(cache.entries.has('wt-a')).toBe(false) + + await writeLooseRef(commonDir, 'refs/heads/feature-a', OID_B) + const worktreePath = join(dirname(dirname(commonDir)), 'wt-a') + await writeFile(join(entry, 'gitdir'), `${join(worktreePath, '.git')}\n`) + + // A later refresh that never names wt-a still recovers it. + const identities = await readIdentities(commonDir, cache, PRIMARY_HEAD_IDENTITY_SCOPE) + expect(headOf(identities, worktreePath)).toBe(OID_B) + }) + + it('follows a relocated gitdir through the entry scope', async () => { + const { commonDir, cache, pathA } = await seed() + const movedPath = join(dirname(dirname(commonDir)), 'wt-a-moved') + await writeFile(join(commonDir, 'worktrees', 'wt-a', 'gitdir'), `${join(movedPath, '.git')}\n`) + + const identities = await readIdentities(commonDir, cache, headIdentityScopeForEntry('wt-a')) + + expect(headOf(identities, movedPath)).toBe(OID_B) + expect(headOf(identities, pathA)).toBeUndefined() + }) + + it('matches admin entry names across unicode normalization', async () => { + const commonDir = await makeCommonDir() + await writeLooseRef(commonDir, 'refs/heads/accent', OID_B) + // Decomposed on disk (what APFS hands back for a name typed as NFD), and the + // watcher may report either form; the fold has to bridge them. + const decomposed = 'wt-e\u0301' + const composed = decomposed.normalize('NFC') + expect(composed).not.toBe(decomposed) + const path = await addLinkedWorktree(commonDir, decomposed, 'ref: refs/heads/accent') + const cache = createWorktreeHeadIdentityCache() + await readIdentities(commonDir, cache) + + await writeLooseRef(commonDir, 'refs/heads/accent', OID_D) + const identities = await readIdentities(commonDir, cache, headIdentityScopeForEntry(composed)) + + expect(headOf(identities, path)).toBe(OID_D) + }) + + it('matches admin entry names across case folding', async () => { + const { commonDir, cache, pathA } = await seed() + await writeLooseRef(commonDir, 'refs/heads/feature-a', OID_D) + + const identities = await readIdentities(commonDir, cache, headIdentityScopeForEntry('WT-A')) + + expect(headOf(identities, pathA)).toBe(OID_D) + }) +}) diff --git a/src/main/ipc/worktree-head-identity-reader.test.ts b/src/main/ipc/worktree-head-identity-reader.test.ts index c722d171ee0..25bffa4a061 100644 --- a/src/main/ipc/worktree-head-identity-reader.test.ts +++ b/src/main/ipc/worktree-head-identity-reader.test.ts @@ -4,6 +4,11 @@ import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { readGitCommonHeadIdentities } from './worktree-head-identity-reader' +const readIdentities = async ( + ...args: Parameters +): Promise>['identities']> => + (await readGitCommonHeadIdentities(...args)).identities + const OID_A = 'a'.repeat(40) const OID_B = 'b'.repeat(40) const OID_C = 'c'.repeat(40) @@ -49,7 +54,7 @@ describe('readGitCommonHeadIdentities', () => { const linkedPath = join(dirname(commonDir), '..', 'linked-wt') await addLinkedWorktree(commonDir, 'linked-wt', linkedPath, 'ref: refs/heads/feature') - const identities = await readGitCommonHeadIdentities(commonDir) + const identities = await readIdentities(commonDir) expect(identities).toContainEqual({ worktreePath: dirname(commonDir), @@ -71,7 +76,7 @@ describe('readGitCommonHeadIdentities', () => { `# pack-refs with: peeled fully-peeled sorted\n${OID_C} refs/heads/main\n^${OID_A}\n` ) - const identities = await readGitCommonHeadIdentities(commonDir) + const identities = await readIdentities(commonDir) expect(identities).toEqual([ { worktreePath: dirname(commonDir), head: OID_C, branch: 'refs/heads/main' } @@ -82,7 +87,7 @@ describe('readGitCommonHeadIdentities', () => { const commonDir = await makeCommonDir() await writeFile(join(commonDir, 'HEAD'), `${OID_B}\n`) - const identities = await readGitCommonHeadIdentities(commonDir) + const identities = await readIdentities(commonDir) expect(identities).toEqual([{ worktreePath: dirname(commonDir), head: OID_B, branch: null }]) }) @@ -91,7 +96,7 @@ describe('readGitCommonHeadIdentities', () => { const commonDir = await makeCommonDir() await writeFile(join(commonDir, 'HEAD'), 'ref: refs/heads/unborn\n') - expect(await readGitCommonHeadIdentities(commonDir)).toEqual([]) + expect(await readIdentities(commonDir)).toEqual([]) }) it('resolves relative gitdir entries against the metadata dir', async () => { @@ -102,7 +107,7 @@ describe('readGitCommonHeadIdentities', () => { await writeFile(join(entry, 'HEAD'), 'ref: refs/heads/feature\n') await writeFile(join(entry, 'gitdir'), `${join('..', '..', '..', '..', 'rel-wt', '.git')}\n`) - const identities = await readGitCommonHeadIdentities(commonDir) + const identities = await readIdentities(commonDir) expect(identities).toEqual([ { @@ -125,7 +130,7 @@ describe('readGitCommonHeadIdentities', () => { 'refs//heads' ]) { await writeFile(join(commonDir, 'HEAD'), `ref: ${ref}\n`) - expect(await readGitCommonHeadIdentities(commonDir)).toEqual([]) + expect(await readIdentities(commonDir)).toEqual([]) } }) @@ -133,10 +138,10 @@ describe('readGitCommonHeadIdentities', () => { const commonDir = await makeCommonDir() await writeFile(join(commonDir, 'HEAD'), 'ref: refs/heads/main\n') await writeLooseRef(commonDir, 'refs/heads/main', 'not-an-object-id') - expect(await readGitCommonHeadIdentities(commonDir)).toEqual([]) + expect(await readIdentities(commonDir)).toEqual([]) await writeFile(join(commonDir, 'HEAD'), 'this is not a detached oid\n') - expect(await readGitCommonHeadIdentities(commonDir)).toEqual([]) + expect(await readIdentities(commonDir)).toEqual([]) }) it('omits the primary row for non-standard common dir layouts', async () => { @@ -147,6 +152,6 @@ describe('readGitCommonHeadIdentities', () => { await writeFile(join(commonDir, 'HEAD'), 'ref: refs/heads/main\n') await writeLooseRef(commonDir, 'refs/heads/main', OID_A) - expect(await readGitCommonHeadIdentities(commonDir)).toEqual([]) + expect(await readIdentities(commonDir)).toEqual([]) }) }) diff --git a/src/main/ipc/worktree-head-identity-reader.ts b/src/main/ipc/worktree-head-identity-reader.ts index 4ee84a4627f..64459403341 100644 --- a/src/main/ipc/worktree-head-identity-reader.ts +++ b/src/main/ipc/worktree-head-identity-reader.ts @@ -1,8 +1,12 @@ import { readdir, readFile } from 'node:fs/promises' -import type { Dirent } from 'node:fs' import { basename, dirname, isAbsolute, join } from 'node:path' import type { WorktreeHeadIdentity } from '../../shared/worktree/types' import { mapWithConcurrency } from '../../shared/map-with-concurrency' +import { + FULL_HEAD_IDENTITY_SCOPE, + headIdentityEntryKey, + type WorktreeHeadIdentityScope +} from './worktree-head-identity-scope' // Why: the whole point of this reader is replacing `git worktree list` fanout // with bounded metadata-file reads, so head freshness never re-creates the @@ -14,18 +18,65 @@ const MAX_SYMREF_DEPTH = 5 // noticeable on WSL/UNC and network-backed worktrees). const HEAD_IDENTITY_READ_CONCURRENCY = 8 -async function readTrimmedFile(path: string): Promise { +/** Per-common-dir memo so a scoped refresh re-reads only the entries a watcher + * burst could have moved. Misses are never cached: an unresolvable read may be + * a transient fs error, so it must be retried rather than remembered. */ +export type WorktreeHeadIdentityCache = { + /** admin entry dir name → last resolved identity. */ + entries: Map + /** Entries whose last read failed for a reason other than absence. The memo + * keeps their last verified identity; the next pass must re-read them. */ + unverified: Set + /** last `worktrees/` listing, in readdir order; null before first enumeration. */ + entryNames: string[] | null + primary: WorktreeHeadIdentity | null + primaryUnverified: boolean +} + +export function createWorktreeHeadIdentityCache(): WorktreeHeadIdentityCache { + return { + entries: new Map(), + unverified: new Set(), + entryNames: null, + primary: null, + primaryUnverified: false + } +} + +export type GitCommonHeadIdentityRead = { + identities: WorktreeHeadIdentity[] + /** False when this pass did not fully observe the repo — `worktrees/` could + * not be enumerated, or an entry's metadata could not be read. Callers that + * treat a full read as a freshness checkpoint must not do so on false. */ + complete: boolean +} + +/** ref → oid resolved during one pass; null means the ref no longer resolves. */ +type ResolvedRefOids = Map + +// Why: a read that failed for any reason other than absence is an UNKNOWN, not +// an absence — the same distinction AGENTS.md draws for the SSH verdict +// vocabulary. Collapsing the two evicts identities Orca still knows and turns a +// single EMFILE into a full re-read of every worktree on the next pass. +const UNREADABLE = Symbol('unreadable') +type Unreadable = typeof UNREADABLE + +async function readTrimmedFile(path: string): Promise { try { return (await readFile(path, 'utf8')).trim() - } catch { - return null + } catch (error) { + return (error as NodeJS.ErrnoException).code === 'ENOENT' ? null : UNREADABLE } } // packed-refs lines are ` `; `#` headers and `^` peel lines skipped. -async function readPackedRefs(commonDirPath: string): Promise> { +async function readPackedRefs(commonDirPath: string): Promise | Unreadable> { const refs = new Map() const content = await readTrimmedFile(join(commonDirPath, 'packed-refs')) + if (content === UNREADABLE) { + return UNREADABLE + } + // No packed-refs file at all is a fact: every ref is loose. if (content === null) { return refs } @@ -63,8 +114,8 @@ function asObjectId(value: string | null | undefined): string | null { async function resolveRefToOid( commonDirPath: string, ref: string, - packedRefs: () => Promise> -): Promise { + packedRefs: () => Promise | Unreadable> +): Promise { let current = ref for (let depth = 0; depth < MAX_SYMREF_DEPTH; depth++) { if (!isSafeRefName(current)) { @@ -72,8 +123,12 @@ async function resolveRefToOid( } // Branch refs are shared repo state, so loose files live in the common dir. const loose = await readTrimmedFile(join(commonDirPath, ...current.split('/'))) + if (loose === UNREADABLE) { + return UNREADABLE + } if (loose === null) { - return asObjectId((await packedRefs()).get(current)) + const packed = await packedRefs() + return packed === UNREADABLE ? UNREADABLE : asObjectId(packed.get(current)) } if (loose.startsWith('ref: ')) { current = loose.slice('ref: '.length).trim() @@ -88,15 +143,25 @@ async function readHeadIdentity( commonDirPath: string, headFilePath: string, worktreePath: string, - packedRefs: () => Promise> -): Promise { + packedRefs: () => Promise | Unreadable>, + resolved: ResolvedRefOids +): Promise { const head = await readTrimmedFile(headFilePath) + if (head === UNREADABLE) { + return UNREADABLE + } if (!head) { return null } if (head.startsWith('ref: ')) { const ref = head.slice('ref: '.length).trim() const oid = await resolveRefToOid(commonDirPath, ref, packedRefs) + // Only definite outcomes are replayed onto siblings; an unknown must not + // evict every other worktree that shares this branch. + if (oid === UNREADABLE) { + return UNREADABLE + } + resolved.set(ref, oid) // Unborn branches (no commit yet) stay covered by the structural listing. if (!oid) { return null @@ -107,68 +172,205 @@ async function readHeadIdentity( return detachedOid ? { worktreePath, head: detachedOid, branch: null } : null } +async function readLinkedEntryIdentity( + commonDirPath: string, + entryName: string, + packedRefs: () => Promise | Unreadable>, + resolved: ResolvedRefOids +): Promise { + const entryPath = join(commonDirPath, 'worktrees', entryName) + const gitdirContent = await readTrimmedFile(join(entryPath, 'gitdir')) + if (gitdirContent === UNREADABLE) { + return UNREADABLE + } + if (!gitdirContent) { + return null + } + // `gitdir` holds `/.git`, absolute or (with relative-path + // worktrees) relative to the entry dir. + const gitdirAbsolute = isAbsolute(gitdirContent) ? gitdirContent : join(entryPath, gitdirContent) + return readHeadIdentity( + commonDirPath, + join(entryPath, 'HEAD'), + dirname(gitdirAbsolute), + packedRefs, + resolved + ) +} + +// Why: mirrors worktree-git-common-polling — a TRANSIENT readdir failure +// (EIO/ESTALE/EMFILE, network hiccup) must not masquerade as "every worktree +// removed" and drop the whole memo. Only a genuinely absent dir is empty. +async function listLinkedEntryNames(commonDirPath: string): Promise { + try { + const entries = await readdir(join(commonDirPath, 'worktrees'), { withFileTypes: true }) + return entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name) + } catch (error) { + return (error as NodeJS.ErrnoException).code === 'ENOENT' ? [] : null + } +} + +function knowsEveryScopedEntry( + entryKeys: readonly string[], + scope: WorktreeHeadIdentityScope +): boolean { + if (scope.entryNames.size === 0) { + return true + } + const known = new Set(entryKeys) + return [...scope.entryNames].every((key) => known.has(key)) +} + +// Why: `git worktree add --force` lets several worktrees share one branch, and +// only the committing worktree's HEAD reflog is appended. Replaying every ref +// resolved this pass onto the cached entries that point at it keeps the others +// current without re-reading their metadata. +// A ref that stopped resolving evicts every cached row on it, including rows +// this pass never read: if the ref really is gone their oids are stale, and we +// cannot tell that from a transient miss. Evicting costs a re-read next pass; +// keeping would serve a head we can no longer justify. +function retargetCachedIdentity( + identity: WorktreeHeadIdentity, + resolved: ResolvedRefOids +): WorktreeHeadIdentity | null { + if (identity.branch === null || !resolved.has(identity.branch)) { + return identity + } + const oid = resolved.get(identity.branch) ?? null + return oid === null ? null : { ...identity, head: oid } +} + +function applyResolvedRefOids(cache: WorktreeHeadIdentityCache, resolved: ResolvedRefOids): void { + if (resolved.size === 0) { + return + } + for (const [name, identity] of cache.entries) { + const next = retargetCachedIdentity(identity, resolved) + if (next === null) { + cache.entries.delete(name) + } else if (next !== identity) { + cache.entries.set(name, next) + } + } + if (cache.primary) { + cache.primary = retargetCachedIdentity(cache.primary, resolved) + } +} + /** Reads head/branch for the primary checkout and every linked worktree of a * Git common dir using only metadata-file reads (HEAD, gitdir, loose refs, * packed-refs) — no Git subprocess. Unresolvable entries are skipped so - * callers never overwrite store state with partial reads. */ + * callers never overwrite store state with partial reads. + * + * Pass a `cache` plus a narrowed `scope` to re-read only the entries a watcher + * burst could have moved; the defaults re-read everything. */ export async function readGitCommonHeadIdentities( - commonDirPath: string -): Promise { - let packedRefsPromise: Promise> | null = null - const packedRefs = (): Promise> => + commonDirPath: string, + cache: WorktreeHeadIdentityCache = createWorktreeHeadIdentityCache(), + scope: WorktreeHeadIdentityScope = FULL_HEAD_IDENTITY_SCOPE +): Promise { + let packedRefsPromise: Promise | Unreadable> | null = null + const packedRefs = (): Promise | Unreadable> => (packedRefsPromise ??= readPackedRefs(commonDirPath)) + const resolved: ResolvedRefOids = new Map() - const identities: WorktreeHeadIdentity[] = [] // Only the standard `/.git` layout maps a common dir back to its // primary checkout path; bare/custom GIT_DIR layouts have no primary row. - if (basename(commonDirPath) === '.git') { + if (basename(commonDirPath) !== '.git') { + cache.primary = null + } else if (scope.all || scope.primary || cache.primary === null || cache.primaryUnverified) { const primary = await readHeadIdentity( commonDirPath, join(commonDirPath, 'HEAD'), dirname(commonDirPath), - packedRefs + packedRefs, + resolved ) - if (primary) { - identities.push(primary) + cache.primaryUnverified = primary === UNREADABLE + if (primary !== UNREADABLE) { + cache.primary = primary } } - let entries: Dirent[] - try { - entries = await readdir(join(commonDirPath, 'worktrees'), { withFileTypes: true }) - } catch { - return identities - } - - const linkedEntries = entries.filter((entry) => entry.isDirectory()) - // mapWithConcurrency retains input order, so publishing identities stays - // deterministic while independent worktree metadata reads overlap. - const linkedIdentities = await mapWithConcurrency( - linkedEntries, - HEAD_IDENTITY_READ_CONCURRENCY, - async (entry) => { - const entryPath = join(commonDirPath, 'worktrees', entry.name) - const gitdirContent = await readTrimmedFile(join(entryPath, 'gitdir')) - if (!gitdirContent) { - return null + let entryNames = cache.entryNames + let listingStale = false + let relisted = false + const relist = async (): Promise => { + relisted = true + const listing = await listLinkedEntryNames(commonDirPath) + if (listing === null) { + listingStale = true + return + } + listingStale = false + entryNames = listing + const present = new Set(listing) + for (const name of cache.entries.keys()) { + if (!present.has(name)) { + cache.entries.delete(name) } - // `gitdir` holds `/.git`, absolute or (with relative-path - // worktrees) relative to the entry dir. - const gitdirAbsolute = isAbsolute(gitdirContent) - ? gitdirContent - : join(entryPath, gitdirContent) - return readHeadIdentity( - commonDirPath, - join(entryPath, 'HEAD'), - dirname(gitdirAbsolute), - packedRefs - ) } + } + if (entryNames === null || scope.all || scope.listing) { + await relist() + } + if (entryNames === null) { + // Unreadable on the very first pass: report only the primary and leave the + // memo unset so the next refresh re-enumerates. + return { identities: cache.primary ? [cache.primary] : [], complete: false } + } + + let entryKeys = entryNames.map(headIdentityEntryKey) + // Why: a scope naming an entry the memoized listing does not know means the + // listing is behind, not that the entry may be skipped. Never let a named + // entry resolve to zero work. + if (!relisted && !knowsEveryScopedEntry(entryKeys, scope)) { + await relist() + entryKeys = entryNames.map(headIdentityEntryKey) + } + + const staleNames = entryNames.filter( + (name, index) => + scope.all || + !cache.entries.has(name) || + // Retried promptly: an unknown from last pass is not evidence of anything. + cache.unverified.has(name) || + scope.entryNames.has(entryKeys[index]) ) - for (const identity of linkedIdentities) { + // Bounded fan-out so a burst cannot flood the libuv threadpool; publication + // order comes from `entryNames` below, not from completion order. + const reads = await mapWithConcurrency(staleNames, HEAD_IDENTITY_READ_CONCURRENCY, (name) => + readLinkedEntryIdentity(commonDirPath, name, packedRefs, resolved) + ) + staleNames.forEach((name, index) => { + const identity = reads[index] + if (identity === UNREADABLE) { + // Unknown, not absent: keep the last verified identity and retry next pass. + cache.unverified.add(name) + return + } + cache.unverified.delete(name) + if (identity) { + cache.entries.set(name, identity) + } else { + cache.entries.delete(name) + } + }) + applyResolvedRefOids(cache, resolved) + + // A failed listing means the add/remove that triggered this burst is not in + // the candidate set yet, so forget the listing rather than wait for another + // listing event to arrive: the next refresh re-enumerates whatever its scope. + cache.entryNames = listingStale ? null : entryNames + const identities: WorktreeHeadIdentity[] = cache.primary ? [cache.primary] : [] + for (const name of entryNames) { + const identity = cache.entries.get(name) if (identity) { identities.push(identity) } } - return identities + return { + identities, + complete: !listingStale && cache.unverified.size === 0 && !cache.primaryUnverified + } } diff --git a/src/main/ipc/worktree-head-identity-refresh.test.ts b/src/main/ipc/worktree-head-identity-refresh.test.ts new file mode 100644 index 00000000000..e0902d4e6ed --- /dev/null +++ b/src/main/ipc/worktree-head-identity-refresh.test.ts @@ -0,0 +1,410 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { WorktreeHeadIdentity } from '../../shared/worktree/types' + +vi.mock('./worktree-remote', () => ({ + notifyWorktreeHeadIdentitiesChanged: vi.fn() +})) + +vi.mock('./worktree-head-identity-reader', () => ({ + readGitCommonHeadIdentities: vi.fn(async () => ({ + identities: [] as WorktreeHeadIdentity[], + complete: true + })), + createWorktreeHeadIdentityCache: vi.fn(() => ({ + entries: new Map(), + unverified: new Set(), + entryNames: null, + primary: null, + primaryUnverified: false + })) +})) + +import { notifyWorktreeHeadIdentitiesChanged } from './worktree-remote' +import { readGitCommonHeadIdentities } from './worktree-head-identity-reader' +import { + createWorktreeHeadIdentityRefreshState, + disposeWorktreeHeadIdentityRefreshState, + HEAD_IDENTITY_FULL_REBASELINE_INTERVAL_MS, + refreshWorktreeHeadIdentities +} from './worktree-head-identity-refresh' +import { + EMPTY_HEAD_IDENTITY_SCOPE, + FULL_HEAD_IDENTITY_SCOPE, + headIdentityScopeForEntry, + PRIMARY_HEAD_IDENTITY_SCOPE +} from './worktree-head-identity-scope' + +const COMMON_DIR = '/repos/project/.git' +const WT_A = '/repos/wt-a' + +const windowState = { destroyed: false } + +function makeHost(): Parameters[0] { + return { + path: COMMON_DIR, + repos: new Map([['repo-1', {}]]), + mainWindow: { isDestroyed: () => windowState.destroyed } as never, + disposed: false + } +} + +function identity(head: string): WorktreeHeadIdentity { + return { worktreePath: WT_A, head, branch: 'refs/heads/feature' } +} + +function mockRead(identities: WorktreeHeadIdentity[] = [], complete = true): void { + vi.mocked(readGitCommonHeadIdentities).mockResolvedValue({ identities, complete }) +} + +// Advance only the clock, so promotion-on-the-next-event is exercised without +// the one-shot catch-up timer firing and muddling the assertion. +function skipInterval(): void { + vi.setSystemTime(Date.now() + HEAD_IDENTITY_FULL_REBASELINE_INTERVAL_MS) +} + +function lastScope(): unknown { + return vi.mocked(readGitCommonHeadIdentities).mock.calls.at(-1)?.[2] +} + +describe('refreshWorktreeHeadIdentities', () => { + beforeEach(() => { + windowState.destroyed = false + vi.useFakeTimers() + vi.mocked(readGitCommonHeadIdentities).mockReset() + mockRead() + vi.mocked(notifyWorktreeHeadIdentitiesChanged).mockClear() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('reads everything on cold start and does not emit off a missing baseline', async () => { + const state = createWorktreeHeadIdentityRefreshState() + mockRead([identity('aaa')]) + + await refreshWorktreeHeadIdentities(makeHost(), state, true, headIdentityScopeForEntry('wt-a')) + + // A scoped first call still cannot trust an empty memo. + expect(lastScope()).toEqual(FULL_HEAD_IDENTITY_SCOPE) + expect(notifyWorktreeHeadIdentitiesChanged).not.toHaveBeenCalled() + }) + + it('forwards a narrowed scope once a baseline exists', async () => { + const host = makeHost() + const state = createWorktreeHeadIdentityRefreshState() + mockRead([identity('aaa')]) + await refreshWorktreeHeadIdentities(host, state, false) + + mockRead([identity('bbb')]) + await refreshWorktreeHeadIdentities(host, state, true, headIdentityScopeForEntry('wt-a')) + + expect(lastScope()).toEqual(headIdentityScopeForEntry('wt-a')) + expect(notifyWorktreeHeadIdentitiesChanged).toHaveBeenCalledWith(expect.anything(), 'repo-1', [ + identity('bbb') + ]) + }) + + it('reads nothing when the burst provably cannot move a head', async () => { + const host = makeHost() + const state = createWorktreeHeadIdentityRefreshState() + await refreshWorktreeHeadIdentities(host, state, false) + vi.mocked(readGitCommonHeadIdentities).mockClear() + + // A `locked` / `config.worktree` write classifies to the empty scope. + await refreshWorktreeHeadIdentities(host, state, true, EMPTY_HEAD_IDENTITY_SCOPE) + + expect(readGitCommonHeadIdentities).not.toHaveBeenCalled() + }) + + it('promotes one refresh per interval back to a full re-read', async () => { + const host = makeHost() + const state = createWorktreeHeadIdentityRefreshState() + await refreshWorktreeHeadIdentities(host, state, false) + + await refreshWorktreeHeadIdentities(host, state, true, PRIMARY_HEAD_IDENTITY_SCOPE) + expect(lastScope()).toEqual(PRIMARY_HEAD_IDENTITY_SCOPE) + + // A ref can move with no event under any admin dir (`git update-ref` from a + // sibling worktree), so the blind window has to be bounded. + skipInterval() + await refreshWorktreeHeadIdentities(host, state, true, PRIMARY_HEAD_IDENTITY_SCOPE) + expect(lastScope()).toEqual(FULL_HEAD_IDENTITY_SCOPE) + + await refreshWorktreeHeadIdentities(host, state, true, PRIMARY_HEAD_IDENTITY_SCOPE) + expect(lastScope()).toEqual(PRIMARY_HEAD_IDENTITY_SCOPE) + }) + + it('still takes the periodic re-baseline when only empty-scope events arrive', async () => { + const host = makeHost() + const state = createWorktreeHeadIdentityRefreshState() + await refreshWorktreeHeadIdentities(host, state, false) + vi.mocked(readGitCommonHeadIdentities).mockClear() + + // `git worktree lock`/`unlock` and sparse toggles classify to the empty + // scope. They must not be able to starve the re-baseline that bounds the + // window where a ref moved with no event under any admin dir. + skipInterval() + mockRead([identity('bbb')]) + // An empty scope only ever reaches the refresh from a structural burst, so + // the reachable pairing is `emit: false`: the promotion's job here is + // baseline/cache hygiene, and the structural catalog notification that runs + // in the same flush is what publishes the head. + await refreshWorktreeHeadIdentities(host, state, false, EMPTY_HEAD_IDENTITY_SCOPE) + + expect(lastScope()).toEqual(FULL_HEAD_IDENTITY_SCOPE) + expect(notifyWorktreeHeadIdentitiesChanged).not.toHaveBeenCalled() + + // Re-baselined, so the next narrow burst diffs against the fresh head + // instead of re-reporting a move the catalog already published. + await refreshWorktreeHeadIdentities(host, state, true, headIdentityScopeForEntry('wt-a')) + expect(notifyWorktreeHeadIdentitiesChanged).not.toHaveBeenCalled() + + // The promotion also re-arms the interval, so the next empty burst is free. + vi.mocked(readGitCommonHeadIdentities).mockClear() + await refreshWorktreeHeadIdentities(host, state, false, EMPTY_HEAD_IDENTITY_SCOPE) + expect(readGitCommonHeadIdentities).not.toHaveBeenCalled() + }) + + it('does not arm the freshness clock on a full read that could not enumerate', async () => { + const host = makeHost() + const state = createWorktreeHeadIdentityRefreshState() + // A full read whose `worktrees/` listing failed has not seen entries added + // since the last good listing, so it is not a freshness checkpoint. + mockRead([identity('aaa')], false) + await refreshWorktreeHeadIdentities(host, state, false) + mockRead([identity('aaa')]) + + await refreshWorktreeHeadIdentities(host, state, true, headIdentityScopeForEntry('wt-a')) + expect(lastScope()).toEqual(FULL_HEAD_IDENTITY_SCOPE) + + // That one enumerated, so the clock arms and the next narrow burst stays narrow. + await refreshWorktreeHeadIdentities(host, state, true, headIdentityScopeForEntry('wt-a')) + expect(lastScope()).toEqual(headIdentityScopeForEntry('wt-a')) + }) + + it('merges the scopes of refreshes queued behind an in-flight read', async () => { + const host = makeHost() + const state = createWorktreeHeadIdentityRefreshState() + await refreshWorktreeHeadIdentities(host, state, false) + + let release: () => void = () => {} + vi.mocked(readGitCommonHeadIdentities).mockImplementationOnce( + () => + new Promise((resolve) => { + release = () => resolve({ identities: [], complete: true }) + }) + ) + const inFlight = refreshWorktreeHeadIdentities( + host, + state, + true, + headIdentityScopeForEntry('wt-a') + ) + await refreshWorktreeHeadIdentities(host, state, true, headIdentityScopeForEntry('wt-b')) + await refreshWorktreeHeadIdentities(host, state, true, PRIMARY_HEAD_IDENTITY_SCOPE) + release() + await inFlight + await vi.advanceTimersByTimeAsync(0) + + expect(lastScope()).toEqual({ + listing: false, + primary: true, + all: false, + entryNames: new Set(['wt-b']) + }) + }) + + it('re-reads everything after a failed read rather than trusting a partial memo', async () => { + const host = makeHost() + const state = createWorktreeHeadIdentityRefreshState() + await refreshWorktreeHeadIdentities(host, state, false) + vi.spyOn(console, 'warn').mockImplementation(() => {}) + vi.mocked(readGitCommonHeadIdentities).mockRejectedValueOnce(new Error('EIO')) + + await refreshWorktreeHeadIdentities(host, state, true, headIdentityScopeForEntry('wt-a')) + await refreshWorktreeHeadIdentities(host, state, true, headIdentityScopeForEntry('wt-a')) + + expect(lastScope()).toEqual(FULL_HEAD_IDENTITY_SCOPE) + }) + + it('keeps the baseline when a notify throws so the move is retried', async () => { + const host = makeHost() + host.repos = new Map([ + ['repo-1', {}], + ['repo-2', {}] + ]) + const state = createWorktreeHeadIdentityRefreshState() + mockRead([identity('aaa')]) + await refreshWorktreeHeadIdentities(host, state, false) + + // A send into destroyed chrome throws part-way through the repo loop. + vi.spyOn(console, 'warn').mockImplementation(() => {}) + mockRead([identity('bbb')]) + vi.mocked(notifyWorktreeHeadIdentitiesChanged).mockImplementationOnce(() => { + throw new Error('webContents destroyed') + }) + await refreshWorktreeHeadIdentities(host, state, true, headIdentityScopeForEntry('wt-a')) + + // The baseline must still hold `aaa`, so the next refresh re-reports `bbb` + // rather than diffing it away as already published. + vi.mocked(notifyWorktreeHeadIdentitiesChanged).mockClear() + await refreshWorktreeHeadIdentities(host, state, true, headIdentityScopeForEntry('wt-a')) + expect(notifyWorktreeHeadIdentitiesChanged).toHaveBeenCalledWith(expect.anything(), 'repo-1', [ + identity('bbb') + ]) + expect(notifyWorktreeHeadIdentitiesChanged).toHaveBeenCalledWith(expect.anything(), 'repo-2', [ + identity('bbb') + ]) + }) + + it('folds a queued scope back in when its re-run met a destroyed window', async () => { + const host = makeHost() + const state = createWorktreeHeadIdentityRefreshState() + await refreshWorktreeHeadIdentities(host, state, false) + + let release: () => void = () => {} + vi.mocked(readGitCommonHeadIdentities).mockImplementationOnce( + () => + new Promise((resolve) => { + release = () => resolve({ identities: [], complete: true }) + }) + ) + const inFlight = refreshWorktreeHeadIdentities( + host, + state, + true, + headIdentityScopeForEntry('wt-a') + ) + await refreshWorktreeHeadIdentities(host, state, true, headIdentityScopeForEntry('wt-b')) + // macOS recreates the window while the watch lives on: the queued re-run + // returns at the teardown guard and must not lose the scope with it. + windowState.destroyed = true + release() + await inFlight + await vi.advanceTimersByTimeAsync(0) + + windowState.destroyed = false + vi.mocked(readGitCommonHeadIdentities).mockClear() + await refreshWorktreeHeadIdentities(host, state, true, headIdentityScopeForEntry('wt-c')) + + expect(lastScope()).toEqual({ + listing: false, + primary: false, + all: false, + entryNames: new Set(['wt-b', 'wt-c']) + }) + }) + + it('does not treat a read discarded by teardown as a freshness checkpoint', async () => { + const host = makeHost() + const state = createWorktreeHeadIdentityRefreshState() + await refreshWorktreeHeadIdentities(host, state, false) + skipInterval() + + vi.mocked(readGitCommonHeadIdentities).mockImplementationOnce(async () => { + windowState.destroyed = true + return { identities: [identity('bbb')], complete: true } + }) + await refreshWorktreeHeadIdentities(host, state, true, headIdentityScopeForEntry('wt-a')) + + windowState.destroyed = false + await refreshWorktreeHeadIdentities(host, state, true, headIdentityScopeForEntry('wt-a')) + expect(lastScope()).toEqual(FULL_HEAD_IDENTITY_SCOPE) + }) + + it('carries forward baseline rows an incomplete listing could not observe', async () => { + const host = makeHost() + const state = createWorktreeHeadIdentityRefreshState() + const other = { worktreePath: '/repos/wt-b', head: 'ccc', branch: 'refs/heads/other' } + mockRead([identity('aaa'), other]) + await refreshWorktreeHeadIdentities(host, state, false) + + // Enumeration failed, so wt-b is missing from this pass entirely. + mockRead([identity('aaa')], false) + await refreshWorktreeHeadIdentities(host, state, true, headIdentityScopeForEntry('wt-a')) + expect(notifyWorktreeHeadIdentitiesChanged).not.toHaveBeenCalled() + + // Listing recovers with wt-b unchanged: it must not be reported as moved. + mockRead([identity('aaa'), other]) + await refreshWorktreeHeadIdentities(host, state, true, headIdentityScopeForEntry('wt-a')) + expect(notifyWorktreeHeadIdentitiesChanged).not.toHaveBeenCalled() + }) + + it('catches up with no further events after a scoped refresh', async () => { + const host = makeHost() + const state = createWorktreeHeadIdentityRefreshState() + mockRead([identity('aaa')]) + await refreshWorktreeHeadIdentities(host, state, false) + + // A scoped pass leaves any drift it could not see unbounded, so it arms a + // one-shot catch-up rather than waiting for an event that may never come. + await refreshWorktreeHeadIdentities(host, state, true, headIdentityScopeForEntry('wt-a')) + expect(lastScope()).toEqual(headIdentityScopeForEntry('wt-a')) + + // External `git update-ref` moved the head with no watched write, then total + // silence: no burst, no debounce flush, nothing. + mockRead([identity('bbb')]) + await vi.advanceTimersByTimeAsync(HEAD_IDENTITY_FULL_REBASELINE_INTERVAL_MS) + + expect(lastScope()).toEqual(FULL_HEAD_IDENTITY_SCOPE) + expect(notifyWorktreeHeadIdentitiesChanged).toHaveBeenCalledWith(expect.anything(), 'repo-1', [ + identity('bbb') + ]) + disposeWorktreeHeadIdentityRefreshState(state) + }) + + it('schedules nothing while idle, so a quiet repo costs no background reads', async () => { + const host = makeHost() + const state = createWorktreeHeadIdentityRefreshState() + mockRead([identity('aaa')]) + // Cold start is a full pass: it disarms rather than arming, because nothing + // is outstanding after a full read. + await refreshWorktreeHeadIdentities(host, state, false) + expect(state.rebaselineTimer).toBeNull() + + vi.mocked(readGitCommonHeadIdentities).mockClear() + await vi.advanceTimersByTimeAsync(HEAD_IDENTITY_FULL_REBASELINE_INTERVAL_MS * 5) + expect(readGitCommonHeadIdentities).not.toHaveBeenCalled() + + // Re-baseline so the scoped pass below stays scoped, then check that the + // catch-up it arms disarms once it has run: never a recurring poll. + await refreshWorktreeHeadIdentities(host, state, false, FULL_HEAD_IDENTITY_SCOPE) + await refreshWorktreeHeadIdentities(host, state, true, headIdentityScopeForEntry('wt-a')) + expect(state.rebaselineTimer).not.toBeNull() + await vi.advanceTimersByTimeAsync(HEAD_IDENTITY_FULL_REBASELINE_INTERVAL_MS) + expect(state.rebaselineTimer).toBeNull() + + vi.mocked(readGitCommonHeadIdentities).mockClear() + await vi.advanceTimersByTimeAsync(HEAD_IDENTITY_FULL_REBASELINE_INTERVAL_MS * 5) + expect(readGitCommonHeadIdentities).not.toHaveBeenCalled() + }) + + it('stops the catch-up when the watch is disposed', async () => { + const host = makeHost() + const state = createWorktreeHeadIdentityRefreshState() + mockRead([identity('aaa')]) + await refreshWorktreeHeadIdentities(host, state, false) + await refreshWorktreeHeadIdentities(host, state, true, headIdentityScopeForEntry('wt-a')) + + disposeWorktreeHeadIdentityRefreshState(state) + vi.mocked(readGitCommonHeadIdentities).mockClear() + await vi.advanceTimersByTimeAsync(HEAD_IDENTITY_FULL_REBASELINE_INTERVAL_MS * 2) + + expect(readGitCommonHeadIdentities).not.toHaveBeenCalled() + }) + + it('never emits for a window torn down mid-read', async () => { + const host = makeHost() + const state = createWorktreeHeadIdentityRefreshState() + await refreshWorktreeHeadIdentities(host, state, false) + + vi.mocked(readGitCommonHeadIdentities).mockImplementationOnce(async () => { + host.disposed = true + return { identities: [identity('bbb')], complete: true } + }) + await refreshWorktreeHeadIdentities(host, state, true, headIdentityScopeForEntry('wt-a')) + + expect(notifyWorktreeHeadIdentitiesChanged).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/ipc/worktree-head-identity-refresh.ts b/src/main/ipc/worktree-head-identity-refresh.ts index a5c1e7d846f..89921c8c1af 100644 --- a/src/main/ipc/worktree-head-identity-refresh.ts +++ b/src/main/ipc/worktree-head-identity-refresh.ts @@ -1,6 +1,17 @@ import type { BrowserWindow } from 'electron' import { notifyWorktreeHeadIdentitiesChanged } from './worktree-remote' -import { readGitCommonHeadIdentities } from './worktree-head-identity-reader' +import { + createWorktreeHeadIdentityCache, + readGitCommonHeadIdentities, + type WorktreeHeadIdentityCache +} from './worktree-head-identity-reader' +import { + EMPTY_HEAD_IDENTITY_SCOPE, + FULL_HEAD_IDENTITY_SCOPE, + isEmptyHeadIdentityScope, + mergeHeadIdentityScopes, + type WorktreeHeadIdentityScope +} from './worktree-head-identity-scope' type HeadIdentityWatchHost = { path: string @@ -12,68 +23,195 @@ type HeadIdentityWatchHost = { export type WorktreeHeadIdentityRefreshState = { /** worktreePath → `${head} ${branch}` from the last metadata-file read. */ baseline: Map | null + cache: WorktreeHeadIdentityCache + lastFullReadAtMs: number inFlight: boolean - queued: boolean + queuedScope: WorktreeHeadIdentityScope | null queuedEmit: boolean + /** One-shot catch-up armed only by a scoped pass; see `scheduleRebaseline`. */ + rebaselineTimer: ReturnType | null } +// Why: a ref can move with no event under any admin dir — `git update-ref +// refs/heads/x` from a sibling worktree appends no HEAD reflog for the worktree +// that has `x` checked out (verified on git 2.44). Scoped refreshes cannot see +// that, so a full re-read is forced this long after the last one, whether or +// not another event arrives. This is also what bounds the blast radius of any +// invalidation bug in the scoping itself. +export const HEAD_IDENTITY_FULL_REBASELINE_INTERVAL_MS = 60_000 + export function createWorktreeHeadIdentityRefreshState(): WorktreeHeadIdentityRefreshState { - return { baseline: null, inFlight: false, queued: false, queuedEmit: false } + return { + baseline: null, + cache: createWorktreeHeadIdentityCache(), + lastFullReadAtMs: 0, + inFlight: false, + queuedScope: null, + queuedEmit: false, + rebaselineTimer: null + } +} + +export function disposeWorktreeHeadIdentityRefreshState( + state: WorktreeHeadIdentityRefreshState +): void { + clearTimeout(state.rebaselineTimer ?? undefined) + state.rebaselineTimer = null +} + +/** Arms the one-shot catch-up that turns "stale until some later event happens + * to arrive" into "stale at most one interval". Only a scoped pass arms it, so + * the timer exists only after an event: an idle repo schedules nothing, and a + * full pass disarms because nothing is outstanding after one. */ +function scheduleRebaseline( + host: HeadIdentityWatchHost, + state: WorktreeHeadIdentityRefreshState +): void { + disposeWorktreeHeadIdentityRefreshState(state) + if (host.disposed || host.mainWindow.isDestroyed()) { + return + } + const dueInMs = Math.max( + 0, + state.lastFullReadAtMs + HEAD_IDENTITY_FULL_REBASELINE_INTERVAL_MS - Date.now() + ) + const timer = setTimeout(() => { + state.rebaselineTimer = null + // `emit: true`: unlike a structural burst, nothing else runs alongside this + // to correct the drift, so a silent re-baseline would bury it forever. + void refreshWorktreeHeadIdentities(host, state, true, FULL_HEAD_IDENTITY_SCOPE) + }, dueInMs) + // Never hold the process open for a freshness backstop. + timer.unref?.() + state.rebaselineTimer = timer } function headIdentitySignature(identity: { head: string; branch: string | null }): string { return `${identity.head} ${identity.branch ?? ''}` } +function resolveScope( + state: WorktreeHeadIdentityRefreshState, + scope: WorktreeHeadIdentityScope +): WorktreeHeadIdentityScope { + if (scope.all || state.baseline === null) { + return FULL_HEAD_IDENTITY_SCOPE + } + return Date.now() - state.lastFullReadAtMs >= HEAD_IDENTITY_FULL_REBASELINE_INTERVAL_MS + ? FULL_HEAD_IDENTITY_SCOPE + : scope +} + /** Diffs metadata-file head reads against the previous baseline and notifies * only actual head moves, so status-only churn (index rewrites from external * `git status`) stays silent and never re-enters structural fanout. Passing * `emit: false` re-baselines without notifying — structural ticks already - * run the authoritative worktree listing. */ + * run the authoritative worktree listing. + * + * `scope` narrows the read to the worktrees a watcher burst could have moved; + * omitting it (watcher errors, event overflow, cold start) re-reads everything. */ export async function refreshWorktreeHeadIdentities( host: HeadIdentityWatchHost, state: WorktreeHeadIdentityRefreshState, - emit: boolean + emit: boolean, + scope: WorktreeHeadIdentityScope = FULL_HEAD_IDENTITY_SCOPE ): Promise { if (host.disposed || host.mainWindow.isDestroyed()) { return } if (state.inFlight) { - state.queued = true + state.queuedScope = mergeHeadIdentityScopes( + state.queuedScope ?? EMPTY_HEAD_IDENTITY_SCOPE, + scope + ) state.queuedEmit ||= emit return } + // Why: the queued re-run below can be handed to a window that was destroyed + // mid-read (macOS recreates it while the watch lives on), and that call + // returns at the guard above. Fold anything still queued into this request so + // a stranded scope is never dropped on the floor. + const requestedScope = state.queuedScope + ? mergeHeadIdentityScopes(state.queuedScope, scope) + : scope + const requestedEmit = emit || state.queuedEmit + state.queuedScope = null + state.queuedEmit = false + // Resolve BEFORE the skip: an empty scope is still an opportunity to take the + // periodic re-baseline, and a repo whose only churn is `git worktree + // lock`/`unlock` or a sparse toggle must not be able to starve it forever. + const effectiveScope = resolveScope(state, requestedScope) + // Nothing the burst touched can move a head (a `locked` or `config.worktree` + // write) and no re-baseline is due: read nothing. + // + // Load-bearing invariant behind the promotion above: an empty scope only ever + // reaches here from `structuralChange(repoIds, EMPTY)`, which populates + // `pendingStructure` for EVERY repo on this watch (`allRepoIds`), which forces + // `emit: false`. So a promoted re-baseline triggered by an empty-scope burst + // corrects the baseline silently and publishes nothing — safe precisely + // because the same flush already sent that watch's repos a catalog + // notification, and the renderer's authoritative listing carries the head. + if (state.baseline !== null && isEmptyHeadIdentityScope(effectiveScope)) { + return + } state.inFlight = true try { - const identities = await readGitCommonHeadIdentities(host.path) + const { identities, complete } = await readGitCommonHeadIdentities( + host.path, + state.cache, + effectiveScope + ) if (host.disposed || host.mainWindow.isDestroyed()) { return } + // After the teardown check, so a read whose result is discarded cannot pass + // for a checkpoint. A read that could not enumerate `worktrees/`, or that hit + // an unreadable entry, has not observed the whole repo — not one either. + if (effectiveScope.all && complete) { + state.lastFullReadAtMs = Date.now() + } const baseline = state.baseline - state.baseline = new Map( - identities.map((identity) => [identity.worktreePath, headIdentitySignature(identity)]) - ) - if (!baseline || !emit) { - return + // Rows this pass could not observe are carried forward: dropping them would + // make the next successful listing report every linked worktree as changed. + const nextBaseline = complete ? new Map() : new Map(baseline ?? []) + for (const identity of identities) { + nextBaseline.set(identity.worktreePath, headIdentitySignature(identity)) } - const changed = identities.filter( - (identity) => baseline.get(identity.worktreePath) !== headIdentitySignature(identity) - ) - if (changed.length === 0) { - return - } - for (const repoId of host.repos.keys()) { + // Why `emit: false` is safe to publish nothing: the only classification that + // reaches here with a head-moving scope and no emit is a structural burst, + // and structural bursts notify the worktree catalog for every repo on this + // watch — the renderer's authoritative listing carries the head. So a silent + // pass is always paired with a stronger refresh, never a dropped update. + const changed = + baseline && requestedEmit + ? identities.filter( + (identity) => baseline.get(identity.worktreePath) !== headIdentitySignature(identity) + ) + : [] + for (const repoId of changed.length > 0 ? host.repos.keys() : []) { notifyWorktreeHeadIdentitiesChanged(host.mainWindow, repoId, changed) } + // Last, so a send that throws part-way leaves the old baseline and the next + // refresh re-diffs instead of silently dropping the move. + state.baseline = nextBaseline } catch (error) { console.warn(`[worktree-base-watcher] head identity read failed for ${host.path}:`, error) + // A failed read leaves the cache in an unknown state; force the next + // refresh to re-read every entry rather than trust a partial memo. + state.cache = createWorktreeHeadIdentityCache() + state.lastFullReadAtMs = 0 } finally { state.inFlight = false - if (state.queued && !host.disposed) { - const queuedEmit = state.queuedEmit - state.queued = false - state.queuedEmit = false - void refreshWorktreeHeadIdentities(host, state, queuedEmit) + if (state.queuedScope && !host.disposed) { + // Leave the queue armed: if this call cannot proceed (destroyed window), + // the next refresh folds it back in at the entry above. + void refreshWorktreeHeadIdentities(host, state, state.queuedEmit, state.queuedScope) + } else if (effectiveScope.all) { + // A full pass just ran (or failed while running full — re-arming there + // would spin on a persistent fs error). Nothing is outstanding. + disposeWorktreeHeadIdentityRefreshState(state) + } else { + scheduleRebaseline(host, state) } } } diff --git a/src/main/ipc/worktree-head-identity-scope.ts b/src/main/ipc/worktree-head-identity-scope.ts new file mode 100644 index 00000000000..801871f2353 --- /dev/null +++ b/src/main/ipc/worktree-head-identity-scope.ts @@ -0,0 +1,90 @@ +import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path' + +// Which slice of a Git common dir's head identities a watcher burst can have +// moved. Absence of a scope always means "unknown" and must resolve to the full +// scope — only an explicit, provable narrowing may skip a metadata read. + +export type WorktreeHeadIdentityScope = { + /** Re-enumerate `worktrees/`: an admin entry may have appeared or vanished. */ + listing: boolean + /** Re-read the primary checkout's HEAD. */ + primary: boolean + /** Re-read every entry: a global signal (packed-refs rewrite, lost events). */ + all: boolean + /** Admin entry dir keys (see `headIdentityEntryKey`) whose head may have moved. */ + entryNames: ReadonlySet +} + +const NO_ENTRY_NAMES: ReadonlySet = new Set() + +export const EMPTY_HEAD_IDENTITY_SCOPE: WorktreeHeadIdentityScope = Object.freeze({ + listing: false, + primary: false, + all: false, + entryNames: NO_ENTRY_NAMES +}) + +export const FULL_HEAD_IDENTITY_SCOPE: WorktreeHeadIdentityScope = Object.freeze({ + listing: true, + primary: true, + all: true, + entryNames: NO_ENTRY_NAMES +}) + +export const PRIMARY_HEAD_IDENTITY_SCOPE: WorktreeHeadIdentityScope = Object.freeze({ + listing: false, + primary: true, + all: false, + entryNames: NO_ENTRY_NAMES +}) + +export const LISTING_HEAD_IDENTITY_SCOPE: WorktreeHeadIdentityScope = Object.freeze({ + listing: true, + primary: false, + all: false, + entryNames: NO_ENTRY_NAMES +}) + +// Why: the watcher reports the admin dir name the OS gave it while the reader +// uses its own `readdir` name. Fold NFC/NFD and case so the two always agree — +// over-matching only costs one redundant read, under-matching loses an update. +export function headIdentityEntryKey(name: string): string { + return normalizeRuntimePathForComparison(name).toLowerCase() +} + +export function headIdentityScopeForEntry(name: string): WorktreeHeadIdentityScope { + return { + listing: false, + primary: false, + all: false, + entryNames: new Set([headIdentityEntryKey(name)]) + } +} + +export function mergeHeadIdentityScopes( + first: WorktreeHeadIdentityScope, + second: WorktreeHeadIdentityScope +): WorktreeHeadIdentityScope { + if (first.all) { + return first + } + if (second.all) { + return second + } + if (second.entryNames.size === 0 && !second.listing && !second.primary) { + return first + } + if (first.entryNames.size === 0 && !first.listing && !first.primary) { + return second + } + return { + listing: first.listing || second.listing, + primary: first.primary || second.primary, + all: false, + entryNames: new Set([...first.entryNames, ...second.entryNames]) + } +} + +export function isEmptyHeadIdentityScope(scope: WorktreeHeadIdentityScope): boolean { + return !scope.all && !scope.listing && !scope.primary && scope.entryNames.size === 0 +} diff --git a/src/main/ipc/worktree-push-target-cleanup.test.ts b/src/main/ipc/worktree-push-target-cleanup.test.ts index 577235c86dd..0b736acd482 100644 --- a/src/main/ipc/worktree-push-target-cleanup.test.ts +++ b/src/main/ipc/worktree-push-target-cleanup.test.ts @@ -4,6 +4,8 @@ import type { WorktreeMeta } from '../../shared/worktree/meta-types' import type { GitPushTarget } from '../../shared/worktree/types' import { cleanupUnusedWorktreePushTargetRemoteWithExec, + findWorktreeMetaReferencingRemote, + hasBranchConfigUsingRemote, sameGitHubRemoteUrl, type GitRemoteExec, type WorktreePushTargetStore @@ -253,6 +255,70 @@ describe('cleanupUnusedWorktreePushTargetRemoteWithExec', () => { }) }) +describe('hasBranchConfigUsingRemote', () => { + it('requireExistingBranch: false (default) protects on config alone, even if the branch is gone', async () => { + const exec = makeExec({ branchConfig: `branch.contributor/fix.remote ${FORK_REMOTE}` }) + await expect(hasBranchConfigUsingRemote(exec, REPO_PATH, forkTarget())).resolves.toBe(true) + }) + + it('requireExistingBranch: true only protects when the referencing branch still exists', async () => { + const exec = vi.fn(async (args: string[]) => { + if (args[0] === 'config') { + return { stdout: `branch.contributor/fix.remote ${FORK_REMOTE}`, stderr: '' } + } + if (args[0] === 'for-each-ref') { + return { stdout: 'main\ncontributor/fix\n', stderr: '' } + } + return { stdout: '', stderr: '' } + }) + await expect( + hasBranchConfigUsingRemote(exec, REPO_PATH, forkTarget(), { requireExistingBranch: true }) + ).resolves.toBe(true) + }) + + it('requireExistingBranch: true does not protect on a stale config entry from a deleted branch', async () => { + const exec = vi.fn(async (args: string[]) => { + if (args[0] === 'config') { + return { stdout: `branch.contributor/fix.remote ${FORK_REMOTE}`, stderr: '' } + } + if (args[0] === 'for-each-ref') { + return { stdout: 'main\n', stderr: '' } // contributor/fix no longer exists + } + return { stdout: '', stderr: '' } + }) + await expect( + hasBranchConfigUsingRemote(exec, REPO_PATH, forkTarget(), { requireExistingBranch: true }) + ).resolves.toBe(false) + }) + + it('extracts branch names containing dots correctly', async () => { + const exec = vi.fn(async (args: string[]) => { + if (args[0] === 'config') { + return { stdout: `branch.release/1.2.3.remote ${FORK_REMOTE}`, stderr: '' } + } + if (args[0] === 'for-each-ref') { + return { stdout: 'release/1.2.3\n', stderr: '' } + } + return { stdout: '', stderr: '' } + }) + await expect( + hasBranchConfigUsingRemote(exec, REPO_PATH, forkTarget(), { requireExistingBranch: true }) + ).resolves.toBe(true) + }) +}) + +describe('findWorktreeMetaReferencingRemote', () => { + it('scopes matches to the given repo id and excludes worktrees without a pushTarget', () => { + const store = storeOf({ + 'repo-1::/wt/a': forkTarget(), + 'repo-1::/wt/b': undefined, + 'repo-2::/wt/c': forkTarget() + }) + const matches = findWorktreeMetaReferencingRemote(store, 'repo-1', forkTarget()) + expect(matches.map((match) => match.worktreeId)).toEqual(['repo-1::/wt/a']) + }) +}) + describe('sameGitHubRemoteUrl', () => { it('matches SSH and HTTPS forms of the same GitHub fork', () => { expect( diff --git a/src/main/ipc/worktree-push-target-cleanup.ts b/src/main/ipc/worktree-push-target-cleanup.ts index 2bda01c5a89..9bf29f718b2 100644 --- a/src/main/ipc/worktree-push-target-cleanup.ts +++ b/src/main/ipc/worktree-push-target-cleanup.ts @@ -1,9 +1,12 @@ // Why: fork-PR worktrees can add a contributor's fork as a git remote. When such // a worktree is deleted we prune that remote, but only when it's truly unused. // This module holds that decision logic behind an injectable `execGit` boundary so -// the multi-fork cleanup matrix is unit-testable without a real repo. +// the multi-fork cleanup matrix is unit-testable without a real repo. The same +// predicates back the periodic sweep in `worktree-push-target-reconciliation.ts`, +// which inverts them over every `pr-*` remote instead of one removed worktree. import type { Store } from '../persistence' +import type { WorktreeMeta } from '../../shared/worktree/meta-types' import type { GitPushTarget } from '../../shared/worktree/types' import { parseGitHubOwnerRepo } from '../github/gh-utils' import { getRepoIdFromWorktreeId } from '../../shared/worktree/id' @@ -29,61 +32,113 @@ export function sameGitHubRemoteUrl(left: string, right: string): boolean { ) } +/** A worktree metadata entry, in the same repo as `target`, whose pushTarget references it. */ +export type WorktreeMetaReferencingRemote = { worktreeId: string; meta: WorktreeMeta } + +// Exported so the reconciliation sweep can inspect *which* worktrees reference a remote +// (to check liveness/provenance) instead of only the single-target yes/no this file needs. +export function findWorktreeMetaReferencingRemote( + store: WorktreePushTargetStore, + repoId: string, + target: Pick +): WorktreeMetaReferencingRemote[] { + return Object.entries(store.getAllWorktreeMeta()) + .filter(([worktreeId, meta]) => { + // Why: git remotes are repo-local; matching metadata from another repo + // must not pin this repo's fork remote forever. + if (getRepoIdFromWorktreeId(worktreeId) !== repoId || !meta.pushTarget) { + return false + } + const otherRemoteUrl = meta.pushTarget.remoteUrl + const targetRemoteUrl = target.remoteUrl + return ( + meta.pushTarget.remoteName === target.remoteName || + (typeof otherRemoteUrl === 'string' && + typeof targetRemoteUrl === 'string' && + sameGitHubRemoteUrl(otherRemoteUrl, targetRemoteUrl)) + ) + }) + .map(([worktreeId, meta]) => ({ worktreeId, meta })) +} + function isPushTargetUsedByAnotherWorktree( store: WorktreePushTargetStore, removedWorktreeId: string, target: GitPushTarget ): boolean { const removedRepoId = getRepoIdFromWorktreeId(removedWorktreeId) - return Object.entries(store.getAllWorktreeMeta()).some(([worktreeId, meta]) => { - // Why: git remotes are repo-local; matching metadata from another repo - // must not pin this repo's fork remote forever. - const belongsToSameRepo = getRepoIdFromWorktreeId(worktreeId) === removedRepoId - if (worktreeId === removedWorktreeId || !belongsToSameRepo || !meta.pushTarget) { - return false - } - const otherRemoteUrl = meta.pushTarget.remoteUrl - const targetRemoteUrl = target.remoteUrl - return ( - meta.pushTarget.remoteName === target.remoteName || - (typeof otherRemoteUrl === 'string' && - typeof targetRemoteUrl === 'string' && - sameGitHubRemoteUrl(otherRemoteUrl, targetRemoteUrl)) - ) - }) + return findWorktreeMetaReferencingRemote(store, removedRepoId, target).some( + ({ worktreeId }) => worktreeId !== removedWorktreeId + ) } -async function hasBranchConfigUsingRemote( +export type BranchConfigMatch = { branchName: string } + +// Exported for the sweep, which additionally verifies each matched branch still exists +// before treating it as a reason to keep the remote (`requireExistingBranch`). +export async function hasBranchConfigUsingRemote( execGit: GitRemoteExec, repoPath: string, - target: GitPushTarget + target: Pick, + options: { requireExistingBranch?: boolean } = {} +): Promise { + let stdout: string + try { + ;({ stdout } = await execGit( + ['config', '--get-regexp', '^branch\\..*\\.(remote|pushRemote)$'], + repoPath + )) + } catch { + return false + } + const matches: BranchConfigMatch[] = [] + // Why: git config output can be large; avoid materializing line/split arrays here. + for (const line of iterateProcessOutputLines(stdout)) { + const parsed = parseBranchRemoteConfigLine(line) + if (parsed && (parsed.value === target.remoteName || parsed.value === target.remoteUrl)) { + matches.push({ branchName: parsed.branchName }) + } + } + if (matches.length === 0) { + return false + } + if (!options.requireExistingBranch) { + return true + } + return branchesExist( + execGit, + repoPath, + matches.map((match) => match.branchName) + ) +} + +async function branchesExist( + execGit: GitRemoteExec, + repoPath: string, + branchNames: string[] ): Promise { try { const { stdout } = await execGit( - ['config', '--get-regexp', '^branch\\..*\\.(remote|pushRemote)$'], + ['for-each-ref', '--format=%(refname:short)', 'refs/heads/'], repoPath ) - // Why: git config output can be large; avoid materializing line/split arrays here. - for (const line of iterateProcessOutputLines(stdout)) { - const value = readBranchRemoteConfigValue(line) - if (value === target.remoteName || value === target.remoteUrl) { - return true - } - } - return false + const existingBranches = new Set(iterateProcessOutputLines(stdout)) + return branchNames.some((branchName) => existingBranches.has(branchName)) } catch { return false } } -function readBranchRemoteConfigValue(line: string): string | null { +function parseBranchRemoteConfigLine(line: string): { branchName: string; value: string } | null { let index = 0 while (index < line.length && isBranchConfigSeparator(line.charCodeAt(index))) { index += 1 } + const keyStart = index while (index < line.length && !isBranchConfigSeparator(line.charCodeAt(index))) { index += 1 } + const key = line.slice(keyStart, index) while (index < line.length && isBranchConfigSeparator(line.charCodeAt(index))) { index += 1 } @@ -96,7 +151,30 @@ function readBranchRemoteConfigValue(line: string): string | null { while (valueEnd > valueStart && isBranchConfigSeparator(line.charCodeAt(valueEnd - 1))) { valueEnd -= 1 } - return valueStart < valueEnd ? line.slice(valueStart, valueEnd) : null + if (valueStart >= valueEnd) { + return null + } + const branchName = extractBranchNameFromConfigKey(key) + return branchName ? { branchName, value: line.slice(valueStart, valueEnd) } : null +} + +// `branch..remote` / `branch..pushRemote`; `` may itself contain dots +// (e.g. `release/1.2.3`), so only the known trailing suffix is stripped. +function extractBranchNameFromConfigKey(key: string): string | null { + const prefix = 'branch.' + if (!key.startsWith(prefix)) { + return null + } + const rest = key.slice(prefix.length) + const lastDot = rest.lastIndexOf('.') + if (lastDot <= 0) { + return null + } + const suffix = rest.slice(lastDot + 1) + if (suffix !== 'remote' && suffix !== 'pushRemote') { + return null + } + return rest.slice(0, lastDot) } function isBranchConfigSeparator(code: number): boolean { diff --git a/src/main/ipc/worktree-push-target-reconciliation-real-git.test.ts b/src/main/ipc/worktree-push-target-reconciliation-real-git.test.ts new file mode 100644 index 00000000000..3c226b3475f --- /dev/null +++ b/src/main/ipc/worktree-push-target-reconciliation-real-git.test.ts @@ -0,0 +1,173 @@ +// Real-binary coverage for the pr-* remote reconciliation sweep (#17828): the mocked-exec suite +// proves the decision matrix, but not that `git remote -v`, `git config --get-regexp`, and +// `git for-each-ref` are parsed correctly against real Git output. +import { execFile } from 'node:child_process' +import { mkdir, mkdtemp, realpath, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { promisify } from 'node:util' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import type { WorktreeMeta } from '../../shared/worktree/meta-types' +import type { GitPushTarget } from '../../shared/worktree/types' +import type { GitRemoteExec, WorktreePushTargetStore } from './worktree-push-target-cleanup' +import { reconcileOrphanedPrRemotesWithExec } from './worktree-push-target-reconciliation' + +const execFileAsync = promisify(execFile) + +const REPO_ID = 'repo-1' +const FORK_REMOTE = 'pr-contributor-orca' + +let scratchDir = '' +let repoPath = '' +let forkPath = '' + +async function git(args: string[], cwd: string): Promise { + const { stdout } = await execFileAsync('git', args, { cwd }) + return stdout +} + +const execGit: GitRemoteExec = (args, cwd) => execFileAsync('git', args, { cwd }) + +function worktreeId(suffix: string): string { + return `${REPO_ID}::${suffix}` +} + +function forkTarget(overrides: Partial = {}): GitPushTarget { + return { + remoteName: FORK_REMOTE, + branchName: 'contributor/fix', + remoteUrl: forkPath, + remoteCreated: true, + ...overrides + } +} + +function storeOf(entries: Record): WorktreePushTargetStore { + const meta: Record = {} + for (const [id, pushTarget] of Object.entries(entries)) { + meta[id] = { pushTarget } as unknown as WorktreeMeta + } + return { getAllWorktreeMeta: () => meta } +} + +beforeEach(async () => { + // realpath: macOS hands out /var/... temp paths while Git reports /private/var/... + scratchDir = await realpath(await mkdtemp(join(tmpdir(), 'orca-pr-remote-reconcile-'))) + repoPath = join(scratchDir, 'repo') + forkPath = join(scratchDir, 'fork') + await mkdir(repoPath, { recursive: true }) + await git(['init', '-q'], repoPath) + await git(['config', 'user.name', 'Orca Test'], repoPath) + await git(['config', 'user.email', 'orca@example.test'], repoPath) + await git(['config', 'commit.gpgSign', 'false'], repoPath) + await git(['config', 'core.hooksPath', '.git/no-hooks'], repoPath) + await writeFile(join(repoPath, 'seed.txt'), 'seed\n') + await git(['add', '-A'], repoPath) + await git(['commit', '-qm', 'seed'], repoPath) + + // A second local "fork" repo the pr-* remote points at, so `remote add`/fetch behave normally. + await git(['clone', '-q', repoPath, forkPath], scratchDir) + await git(['config', 'user.name', 'Orca Test'], forkPath) + await git(['config', 'user.email', 'orca@example.test'], forkPath) + await git(['config', 'commit.gpgSign', 'false'], forkPath) + await git(['config', 'core.hooksPath', '.git/no-hooks'], forkPath) + await git(['checkout', '-qb', 'contributor/fix'], forkPath) + await writeFile(join(forkPath, 'fork.txt'), 'fork change\n') + await git(['add', '-A'], forkPath) + await git(['commit', '-qm', 'fork change'], forkPath) + + await git(['remote', 'add', FORK_REMOTE, forkPath], repoPath) + await git( + [ + 'fetch', + FORK_REMOTE, + `+refs/heads/contributor/fix:refs/remotes/${FORK_REMOTE}/contributor/fix` + ], + repoPath + ) +}) + +afterEach(async () => { + await rm(scratchDir, { recursive: true, force: true }) +}) + +describe('reconcileOrphanedPrRemotesWithExec against the real Git binary', () => { + it('leaves a user-created remote alone: naming/URL shape is not proof of provenance', async () => { + const reclaimed = await reconcileOrphanedPrRemotesWithExec( + repoPath, + REPO_ID, + storeOf({}), // no worktree metadata anywhere claims this remote + execGit, + [] + ) + expect(reclaimed).toEqual([]) + await expect(git(['remote'], repoPath)).resolves.toContain(FORK_REMOTE) + }) + + it('leaves the remote alone while a live worktree still references it', async () => { + const worktreePath = join(scratchDir, 'wt-live') + await git(['worktree', 'add', '-q', worktreePath, '-b', 'contributor/fix-local'], repoPath) + + const reclaimed = await reconcileOrphanedPrRemotesWithExec( + repoPath, + REPO_ID, + storeOf({ [worktreeId(worktreePath)]: forkTarget() }), + execGit, + [worktreePath] + ) + expect(reclaimed).toEqual([]) + await expect(git(['remote'], repoPath)).resolves.toContain(FORK_REMOTE) + }) + + it('leaves the remote alone while its branch still exists (preserve-on-delete kept alive)', async () => { + await git(['branch', 'contributor/fix', `${FORK_REMOTE}/contributor/fix`], repoPath) + await git(['config', `branch.contributor/fix.remote`, FORK_REMOTE], repoPath) + + const reclaimed = await reconcileOrphanedPrRemotesWithExec( + repoPath, + REPO_ID, + storeOf({ [worktreeId(join(scratchDir, 'wt-gone'))]: forkTarget() }), + execGit, + [] // the worktree that created it is gone, but the branch it preserved is not + ) + expect(reclaimed).toEqual([]) + await expect(git(['remote'], repoPath)).resolves.toContain(FORK_REMOTE) + }) + + it('reclaims the remote once the branch that pinned it is deleted (path 2)', async () => { + await git(['branch', 'contributor/fix', `${FORK_REMOTE}/contributor/fix`], repoPath) + await git(['config', `branch.contributor/fix.remote`, FORK_REMOTE], repoPath) + // Delete only the ref, leaving the config behind, exactly as `update-ref -d` alone would -- + // proving the sweep checks branch existence rather than trusting stale config. + await rm(join(repoPath, '.git', 'refs', 'heads', 'contributor', 'fix')) + + const reclaimed = await reconcileOrphanedPrRemotesWithExec( + repoPath, + REPO_ID, + storeOf({ [worktreeId(join(scratchDir, 'wt-gone'))]: forkTarget() }), + execGit, + [] + ) + expect(reclaimed).toEqual([FORK_REMOTE]) + await expect(git(['remote'], repoPath)).resolves.not.toContain(FORK_REMOTE) + }) + + it('reclaims a remote orphaned by a worktree removed outside Orca (path 3)', async () => { + const worktreePath = join(scratchDir, 'wt-externally-removed') + await git(['worktree', 'add', '-q', worktreePath, '-b', 'contributor/fix-local-2'], repoPath) + // Simulate a plain `git worktree remove` the user ran outside Orca: Orca's metadata for + // that worktree is still sitting in the store (nothing told it to clean up), but the + // worktree itself is gone. + await git(['worktree', 'remove', '--force', worktreePath], repoPath) + + const reclaimed = await reconcileOrphanedPrRemotesWithExec( + repoPath, + REPO_ID, + storeOf({ [worktreeId(worktreePath)]: forkTarget() }), + execGit, + [] // listWorktrees no longer reports it + ) + expect(reclaimed).toEqual([FORK_REMOTE]) + await expect(git(['remote'], repoPath)).resolves.not.toContain(FORK_REMOTE) + }) +}) diff --git a/src/main/ipc/worktree-push-target-reconciliation.test.ts b/src/main/ipc/worktree-push-target-reconciliation.test.ts new file mode 100644 index 00000000000..ed29809a151 --- /dev/null +++ b/src/main/ipc/worktree-push-target-reconciliation.test.ts @@ -0,0 +1,262 @@ +import { describe, expect, it, vi, type Mock } from 'vitest' +import { validateGitExecArgs } from '../../relay/git-exec-validator' +import type { WorktreeMeta } from '../../shared/worktree/meta-types' +import type { GitPushTarget } from '../../shared/worktree/types' +import type { GitRemoteExec, WorktreePushTargetStore } from './worktree-push-target-cleanup' +import { + _resetPrRemoteReconciliationRateLimitForTests, + isOrcaGeneratedPrRemoteName, + reconcileOrphanedPrRemotesWithExec +} from './worktree-push-target-reconciliation' + +type ExecMock = Mock + +const REPO_PATH = '/repo-root' +const REPO_ID = 'repo-1' +const FORK_URL = 'git@github.com:contributor/orca.git' +const FORK_REMOTE = 'pr-contributor-orca' + +function worktreeId(suffix: string): string { + return `${REPO_ID}::${suffix}` +} + +function forkTarget(overrides: Partial = {}): GitPushTarget { + return { + remoteName: FORK_REMOTE, + branchName: 'contributor/fix', + remoteUrl: FORK_URL, + remoteCreated: true, + ...overrides + } +} + +function metaWith(pushTarget: GitPushTarget | undefined): WorktreeMeta { + return { pushTarget } as unknown as WorktreeMeta +} + +function storeOf(entries: Record): WorktreePushTargetStore { + const meta: Record = {} + for (const [id, pushTarget] of Object.entries(entries)) { + meta[id] = metaWith(pushTarget) + } + return { getAllWorktreeMeta: () => meta } +} + +type ExecScript = { + remotes?: string + branchConfig?: string + localBranches?: string +} + +function makeExec(script: ExecScript = {}): ExecMock { + const { remotes = '', branchConfig = '', localBranches = '' } = script + return vi.fn(async (args: string[]) => { + if (args[0] === 'remote' && args[1] === '-v') { + return { stdout: remotes, stderr: '' } + } + if (args[0] === 'config') { + return { stdout: branchConfig, stderr: '' } + } + if (args[0] === 'for-each-ref') { + return { stdout: localBranches, stderr: '' } + } + if (args[0] === 'remote' && args[1] === 'remove') { + return { stdout: '', stderr: '' } + } + return { stdout: '', stderr: '' } + }) +} + +function remoteLines(entries: { name: string; url: string }[]): string { + return entries + .flatMap(({ name, url }) => [`${name}\t${url} (fetch)`, `${name}\t${url} (push)`]) + .join('\n') +} + +function removeCalls(exec: ExecMock): string[][] { + return exec.mock.calls + .map(([args]) => args) + .filter((args) => args[0] === 'remote' && args[1] === 'remove') +} + +describe('isOrcaGeneratedPrRemoteName', () => { + it('matches Orca-generated names, including disambiguated ones', () => { + expect(isOrcaGeneratedPrRemoteName('pr-head')).toBe(true) + expect(isOrcaGeneratedPrRemoteName('pr-contributor-orca')).toBe(true) + expect(isOrcaGeneratedPrRemoteName('pr-head-2')).toBe(true) + expect(isOrcaGeneratedPrRemoteName('pr-contributor-orca-3')).toBe(true) + }) + + it('does not match unrelated remote names', () => { + expect(isOrcaGeneratedPrRemoteName('origin')).toBe(false) + expect(isOrcaGeneratedPrRemoteName('upstream')).toBe(false) + expect(isOrcaGeneratedPrRemoteName('project-remote')).toBe(false) + }) +}) + +describe('reconcileOrphanedPrRemotesWithExec', () => { + it('leaves a remote alone when no worktree metadata ever proves Orca created it (user-created, ambiguous)', async () => { + // Same naming shape a user could coincidentally pick; no pushTarget anywhere claims it. + const exec = makeExec({ remotes: remoteLines([{ name: FORK_REMOTE, url: FORK_URL }]) }) + const reclaimed = await reconcileOrphanedPrRemotesWithExec( + REPO_PATH, + REPO_ID, + storeOf({}), + exec, + [] + ) + expect(reclaimed).toEqual([]) + expect(removeCalls(exec)).toEqual([]) + }) + + it('leaves a remote alone that is not shaped like an Orca-generated pr-* remote', async () => { + const exec = makeExec({ + remotes: remoteLines([{ name: 'my-fork', url: FORK_URL }]) + }) + const reclaimed = await reconcileOrphanedPrRemotesWithExec( + REPO_PATH, + REPO_ID, + storeOf({ [worktreeId('/wt/a')]: { ...forkTarget(), remoteName: 'my-fork' } }), + exec, + [] + ) + expect(reclaimed).toEqual([]) + expect(removeCalls(exec)).toEqual([]) + }) + + it('leaves a remote alone that a live worktree still references (path guard)', async () => { + const exec = makeExec({ remotes: remoteLines([{ name: FORK_REMOTE, url: FORK_URL }]) }) + const reclaimed = await reconcileOrphanedPrRemotesWithExec( + REPO_PATH, + REPO_ID, + storeOf({ [worktreeId('/wt/a')]: forkTarget() }), + exec, + ['/wt/a'] + ) + expect(reclaimed).toEqual([]) + expect(removeCalls(exec)).toEqual([]) + }) + + it('leaves a remote alone that is referenced by an existing branch (path 2, branch still kept)', async () => { + const exec = makeExec({ + remotes: remoteLines([{ name: FORK_REMOTE, url: FORK_URL }]), + branchConfig: `branch.contributor/fix.remote ${FORK_REMOTE}`, + localBranches: 'contributor/fix\nmain' + }) + // Metadata for the worktree that created it is gone, but the branch it preserved lives on. + const reclaimed = await reconcileOrphanedPrRemotesWithExec( + REPO_PATH, + REPO_ID, + storeOf({ [worktreeId('/wt/gone')]: forkTarget() }), + exec, + [] + ) + expect(reclaimed).toEqual([]) + expect(removeCalls(exec)).toEqual([]) + }) + + it('reclaims a remote whose protecting branch config is stale (path 2, branch since deleted)', async () => { + const exec = makeExec({ + remotes: remoteLines([{ name: FORK_REMOTE, url: FORK_URL }]), + // Config line survives even though `contributor/fix` no longer exists. + branchConfig: `branch.contributor/fix.remote ${FORK_REMOTE}`, + localBranches: 'main' + }) + const reclaimed = await reconcileOrphanedPrRemotesWithExec( + REPO_PATH, + REPO_ID, + storeOf({ [worktreeId('/wt/gone')]: forkTarget() }), + exec, + [] + ) + expect(reclaimed).toEqual([FORK_REMOTE]) + expect(removeCalls(exec)).toEqual([['remote', 'remove', FORK_REMOTE]]) + }) + + it('reclaims a remote left behind by a worktree removed outside Orca (path 3)', async () => { + const exec = makeExec({ remotes: remoteLines([{ name: FORK_REMOTE, url: FORK_URL }]) }) + // Metadata still records the (now-vanished) worktree's Orca-created pushTarget. + const reclaimed = await reconcileOrphanedPrRemotesWithExec( + REPO_PATH, + REPO_ID, + storeOf({ [worktreeId('/wt/gone')]: forkTarget() }), + exec, + [] // no live worktrees at all + ) + expect(reclaimed).toEqual([FORK_REMOTE]) + expect(removeCalls(exec)).toEqual([['remote', 'remove', FORK_REMOTE]]) + }) + + it('reclaims a remote even when the only referencing metadata lacks remoteCreated, as long as another entry proves provenance (path 1)', async () => { + const exec = makeExec({ remotes: remoteLines([{ name: FORK_REMOTE, url: FORK_URL }]) }) + const reclaimed = await reconcileOrphanedPrRemotesWithExec( + REPO_PATH, + REPO_ID, + storeOf({ + // The worktree whose removal originally bailed (legacy metadata, no remoteCreated flag)... + [worktreeId('/wt/legacy')]: forkTarget({ remoteCreated: false }), + // ...but a sibling that reused the remote correctly inherited ownership, and is also gone. + [worktreeId('/wt/sibling-gone')]: forkTarget({ remoteCreated: true }) + }), + exec, + [] + ) + expect(reclaimed).toEqual([FORK_REMOTE]) + }) + + it('never touches origin or upstream even if metadata is malformed', async () => { + const exec = makeExec({ + remotes: remoteLines([ + { name: 'origin', url: FORK_URL }, + { name: 'upstream', url: FORK_URL } + ]) + }) + const reclaimed = await reconcileOrphanedPrRemotesWithExec( + REPO_PATH, + REPO_ID, + storeOf({ [worktreeId('/wt/a')]: forkTarget({ remoteName: 'origin' }) }), + exec, + [] + ) + expect(reclaimed).toEqual([]) + expect(removeCalls(exec)).toEqual([]) + }) + + it('scopes provenance and liveness to the same repo (remotes are repo-local)', async () => { + const exec = makeExec({ remotes: remoteLines([{ name: FORK_REMOTE, url: FORK_URL }]) }) + const reclaimed = await reconcileOrphanedPrRemotesWithExec( + REPO_PATH, + REPO_ID, + storeOf({ 'repo-2::/wt/other-repo': forkTarget() }), + exec, + [] + ) + expect(reclaimed).toEqual([]) + }) + + it('sends only argv the relay accepts, so the sweep is not silently skipped over SSH', async () => { + // Exercise every branch (config probe, for-each-ref probe, and the reclaim itself). + const exec = makeExec({ + remotes: remoteLines([{ name: FORK_REMOTE, url: FORK_URL }]), + branchConfig: `branch.contributor/fix.remote ${FORK_REMOTE}`, + localBranches: 'main' + }) + await reconcileOrphanedPrRemotesWithExec( + REPO_PATH, + REPO_ID, + storeOf({ [worktreeId('/wt/gone')]: forkTarget() }), + exec, + [] + ) + expect(exec.mock.calls.length).toBeGreaterThan(0) + for (const [args] of exec.mock.calls) { + expect(() => validateGitExecArgs(args)).not.toThrow() + } + }) +}) + +describe('reconcileOrphanedPrRemotes rate limiting', () => { + it('exposes a test reset so repeated test runs are not affected by prior cooldowns', () => { + expect(() => _resetPrRemoteReconciliationRateLimitForTests()).not.toThrow() + }) +}) diff --git a/src/main/ipc/worktree-push-target-reconciliation.ts b/src/main/ipc/worktree-push-target-reconciliation.ts new file mode 100644 index 00000000000..e59da7bdfe7 --- /dev/null +++ b/src/main/ipc/worktree-push-target-reconciliation.ts @@ -0,0 +1,204 @@ +// Why: `pr-*` remotes Orca adds for fork-PR review are only ever pruned by +// `worktree-push-target-cleanup.ts`, and only when a *single* worktree removal +// triggers it. Three things escape that: (1) legacy/reused metadata missing the +// `remoteCreated` flag, (2) a "preserve branch on delete" pinning its remote via +// `branch.*.remote` config long after the worktree is gone, and (3) a worktree +// removed outside Orca entirely (no removal event ever fires). This sweep +// inverts the same safety predicates over every `pr-*` remote in the repo +// instead of one removal, so all three eventually get reclaimed. It never adds +// new safety logic — see `worktree-push-target-cleanup.ts` for the predicates. + +import { gitExecFileAsync } from '../git/runner' +import { listWorktrees } from '../git/worktree' +import type { SshGitProvider } from '../providers/ssh-git-provider' +import type { GitPushTarget } from '../../shared/worktree/types' +import { WORKTREE_ID_SEPARATOR, worktreeIdComparisonKey } from '../../shared/worktree/id' +import { iterateProcessOutputLines } from '../../shared/process-output-field-scanner' +import { + findWorktreeMetaReferencingRemote, + hasBranchConfigUsingRemote, + type GitRemoteExec, + type WorktreePushTargetStore +} from './worktree-push-target-cleanup' + +// Orca only ever mints `pr-head` or `pr--` (see `sanitizeRemoteName`), optionally +// disambiguated with `-2`..`-99` (see `ensureUniqueRemoteName`). The naming convention alone is +// not proof of provenance -- a user could name a remote `pr-foo` -- so this only narrows which +// remotes are even considered; `hasOrcaCreatedProvenance` below is the actual safety gate. +const ORCA_PR_REMOTE_NAME_PATTERN = + /^pr-(?:head|[a-z0-9](?:[a-z0-9._-]*[a-z0-9])?)(?:-[0-9]{1,2})?$/ + +export function isOrcaGeneratedPrRemoteName(name: string): boolean { + return ORCA_PR_REMOTE_NAME_PATTERN.test(name) +} + +type PrRemoteCandidate = { name: string; url: string } + +async function listPrRemoteCandidates( + execGit: GitRemoteExec, + repoPath: string +): Promise { + let stdout: string + try { + ;({ stdout } = await execGit(['remote', '-v'], repoPath)) + } catch { + return [] + } + const candidates = new Map() + for (const line of iterateProcessOutputLines(stdout)) { + const parsed = parseRemoteVerboseLine(line) + if (parsed?.direction === 'fetch' && isOrcaGeneratedPrRemoteName(parsed.name)) { + candidates.set(parsed.name, parsed.url) + } + } + return [...candidates.entries()].map(([name, url]) => ({ name, url })) +} + +function parseRemoteVerboseLine( + line: string +): { name: string; url: string; direction: 'fetch' | 'push' } | null { + const tabIndex = line.indexOf('\t') + if (tabIndex === -1) { + return null + } + const name = line.slice(0, tabIndex) + const match = /^(.*) \((fetch|push)\)$/.exec(line.slice(tabIndex + 1).trim()) + return match ? { name, url: match[1], direction: match[2] as 'fetch' | 'push' } : null +} + +async function shouldReclaimPrRemote( + execGit: GitRemoteExec, + repoPath: string, + repoId: string, + store: WorktreePushTargetStore, + remote: PrRemoteCandidate, + liveWorktreeKeys: ReadonlySet +): Promise { + const target: Pick = { + remoteName: remote.name, + remoteUrl: remote.url + } + const referencingEntries = findWorktreeMetaReferencingRemote(store, repoId, target) + // Provenance gate: only touch a remote some worktree's persisted pushTarget explicitly + // recorded Orca creating. Naming and URL shape are necessary but not sufficient proof. + if (!referencingEntries.some(({ meta }) => meta.pushTarget?.remoteCreated === true)) { + return false + } + const stillClaimedByLiveWorktree = referencingEntries.some(({ worktreeId }) => { + const key = worktreeIdComparisonKey(worktreeId) + return key !== null && liveWorktreeKeys.has(key) + }) + if (stillClaimedByLiveWorktree) { + return false + } + // A branch that still exists may push to this fork again later; only a branch that's + // actually gone (force-deleted, or deleted outside the "preserve on delete" flow) frees it. + if ( + await hasBranchConfigUsingRemote(execGit, repoPath, target, { requireExistingBranch: true }) + ) { + return false + } + return true +} + +// Exported for unit/real-git tests: the `execGit` seam and injected live-worktree paths let +// tests drive the sweep without a real repo (or with one, for the real-git coverage). +export async function reconcileOrphanedPrRemotesWithExec( + repoPath: string, + repoId: string, + store: WorktreePushTargetStore, + execGit: GitRemoteExec, + liveWorktreePaths: readonly string[] +): Promise { + const liveWorktreeKeys = new Set( + liveWorktreePaths + .map((path) => worktreeIdComparisonKey(`${repoId}${WORKTREE_ID_SEPARATOR}${path}`)) + .filter((key): key is string => key !== null) + ) + const reclaimed: string[] = [] + for (const remote of await listPrRemoteCandidates(execGit, repoPath)) { + if (await shouldReclaimPrRemote(execGit, repoPath, repoId, store, remote, liveWorktreeKeys)) { + await execGit(['remote', 'remove', remote.name], repoPath) + reclaimed.push(remote.name) + } + } + return reclaimed +} + +// Why: the sweep costs a handful of git subprocesses (remote -v, worktree list, per-candidate +// config/for-each-ref); bound to once per repo per cooldown so bursts of removals don't repeat it. +const RECONCILE_COOLDOWN_MS = 60 * 60 * 1000 +const lastReconciledAtByRepoId = new Map() + +function shouldReconcileNow(repoId: string): boolean { + const last = lastReconciledAtByRepoId.get(repoId) + return last === undefined || Date.now() - last >= RECONCILE_COOLDOWN_MS +} + +export function _resetPrRemoteReconciliationRateLimitForTests(): void { + lastReconciledAtByRepoId.clear() +} + +function logReclaimed(repoPath: string, reclaimed: string[]): void { + if (reclaimed.length > 0) { + console.log( + `[worktrees] Reclaimed ${reclaimed.length} orphaned PR remote(s) in ${repoPath}: ${reclaimed.join(', ')}` + ) + } +} + +/** Best-effort, rate-limited sweep run alongside single-target cleanup (see call sites). */ +export async function reconcileOrphanedPrRemotes( + repoPath: string, + repoId: string, + store: WorktreePushTargetStore, + gitOptions: { wslDistro?: string } = {} +): Promise { + if (!shouldReconcileNow(repoId)) { + return + } + lastReconciledAtByRepoId.set(repoId, Date.now()) + try { + const liveWorktrees = await listWorktrees(repoPath, gitOptions) + logReclaimed( + repoPath, + await reconcileOrphanedPrRemotesWithExec( + repoPath, + repoId, + store, + (args, cwd) => gitExecFileAsync(args, { cwd, ...gitOptions }), + liveWorktrees.map((worktree) => worktree.path) + ) + ) + } catch (error) { + console.warn(`[worktrees] Failed to reconcile orphaned PR remotes for ${repoPath}`, error) + } +} + +/** SSH counterpart of {@link reconcileOrphanedPrRemotes}; the execution host owns the remotes. */ +export async function reconcileOrphanedPrRemotesSsh( + provider: SshGitProvider, + repoPath: string, + repoId: string, + store: WorktreePushTargetStore +): Promise { + if (!shouldReconcileNow(repoId)) { + return + } + lastReconciledAtByRepoId.set(repoId, Date.now()) + try { + const liveWorktrees = await provider.listWorktrees(repoPath) + logReclaimed( + repoPath, + await reconcileOrphanedPrRemotesWithExec( + repoPath, + repoId, + store, + (args, cwd) => provider.exec(args, cwd), + liveWorktrees.map((worktree) => worktree.path) + ) + ) + } catch (error) { + console.warn(`[worktrees] Failed to reconcile orphaned PR remotes (SSH) for ${repoPath}`, error) + } +} diff --git a/src/main/ipc/worktree-push-target-refspec-migration.test.ts b/src/main/ipc/worktree-push-target-refspec-migration.test.ts new file mode 100644 index 00000000000..afedd946802 --- /dev/null +++ b/src/main/ipc/worktree-push-target-refspec-migration.test.ts @@ -0,0 +1,378 @@ +import { describe, expect, it, vi, type Mock } from 'vitest' +import type { WorktreeMeta } from '../../shared/worktree/meta-types' +import type { GitPushTarget } from '../../shared/worktree/types' +import type { GitRemoteExec, WorktreePushTargetStore } from './worktree-push-target-cleanup' +import { + _resetForkRemoteRefspecMigrationRateLimitForTests, + migrateForkRemoteRefspecsWithExec +} from './worktree-push-target-refspec-migration' + +type ExecMock = Mock + +const REPO_PATH = '/repo-root' +const REPO_ID = 'repo-1' +const FORK_REMOTE = 'pr-contributor-orca' + +function worktreeId(suffix: string): string { + return `${REPO_ID}::${suffix}` +} + +function forkTarget(overrides: Partial = {}): GitPushTarget { + return { + remoteName: FORK_REMOTE, + branchName: 'contributor/fix', + remoteUrl: 'git@github.com:contributor/orca.git', + remoteCreated: true, + ...overrides + } +} + +function metaWith(pushTarget: GitPushTarget | undefined): WorktreeMeta { + return { pushTarget } as unknown as WorktreeMeta +} + +function storeOf(entries: Record): WorktreePushTargetStore { + const meta: Record = {} + for (const [id, pushTarget] of Object.entries(entries)) { + meta[id] = metaWith(pushTarget) + } + return { getAllWorktreeMeta: () => meta } +} + +type ExecScript = { + fetchByRemote?: Record + urlByRemote?: Record + branchConfig?: string + trackingRefsByRemote?: Record + // Simulates #17842's reconciliation sweep concurrently `remote remove`-ing this + // remote in between this migration's pre-write and post-write existence checks. + removeUrlAfterFirstCheck?: Set + // Remotes `git remote` (bare list) reports on disk -- drives discovery of a `pr-*` + // remote with zero worktree-metadata trace at all. + remoteNames?: string[] +} + +function makeExec(script: ExecScript = {}): ExecMock { + const { + fetchByRemote = {}, + urlByRemote = {}, + branchConfig = '', + trackingRefsByRemote = {}, + removeUrlAfterFirstCheck = new Set(), + remoteNames = [] + } = script + const urlCheckCountByRemote: Record = {} + return vi.fn(async (args: string[]) => { + if (args[0] === 'remote' && args.length === 1) { + return { stdout: remoteNames.length ? `${remoteNames.join('\n')}\n` : '', stderr: '' } + } + if (args[0] === 'config' && args[1] === '--get' && args[2]!.endsWith('.url')) { + const remoteName = args[2]!.slice('remote.'.length, -'.url'.length) + urlCheckCountByRemote[remoteName] = (urlCheckCountByRemote[remoteName] ?? 0) + 1 + const concurrentlyRemoved = + removeUrlAfterFirstCheck.has(remoteName) && urlCheckCountByRemote[remoteName]! > 1 + const url = concurrentlyRemoved ? undefined : urlByRemote[remoteName] + if (!url) { + throw new Error(`no such remote ${remoteName}`) + } + return { stdout: url, stderr: '' } + } + if (args[0] === 'config' && args[1] === '--remove-section' && args[2]!.startsWith('remote.')) { + const remoteName = args[2]!.slice('remote.'.length) + delete fetchByRemote[remoteName] + delete urlByRemote[remoteName] + return { stdout: '', stderr: '' } + } + if (args[0] === 'config' && args[1] === '--get-regexp') { + return { stdout: branchConfig, stderr: '' } + } + if (args[0] === 'config' && args[1] === '--get-all' && args[2]!.endsWith('.fetch')) { + const remoteName = args[2]!.slice('remote.'.length, -'.fetch'.length) + const values = fetchByRemote[remoteName] ?? [] + if (values.length === 0) { + throw new Error('key not found') + } + return { stdout: `${values.join('\n')}\n`, stderr: '' } + } + if (args[0] === 'config' && args[1] === '--unset-all' && args[2]!.endsWith('.fetch')) { + const remoteName = args[2]!.slice('remote.'.length, -'.fetch'.length) + fetchByRemote[remoteName] = [] + return { stdout: '', stderr: '' } + } + if (args[0] === 'config' && args[1] === '--add' && args[2]!.endsWith('.fetch')) { + const remoteName = args[2]!.slice('remote.'.length, -'.fetch'.length) + fetchByRemote[remoteName] = [...(fetchByRemote[remoteName] ?? []), args[3]!] + return { stdout: '', stderr: '' } + } + if (args[0] === 'config' && args[1]?.endsWith('.tagOpt')) { + return { stdout: '', stderr: '' } + } + if (args[0] === 'for-each-ref') { + const prefix = args[2]! + const remoteName = prefix.replace('refs/remotes/', '').replace(/\/$/, '') + const refs = trackingRefsByRemote[remoteName] ?? [] + return { + stdout: refs.length ? `${refs.map((r) => `${prefix}${r}`).join('\n')}\n` : '', + stderr: '' + } + } + if (args[0] === 'update-ref' && args[1] === '-d') { + const refname = args[2]! + for (const [remoteName, refs] of Object.entries(trackingRefsByRemote)) { + const prefix = `refs/remotes/${remoteName}/` + if (refname.startsWith(prefix)) { + trackingRefsByRemote[remoteName] = refs.filter((r) => `${prefix}${r}` !== refname) + } + } + return { stdout: '', stderr: '' } + } + return { stdout: '', stderr: '' } + }) +} + +describe('migrateForkRemoteRefspecsWithExec', () => { + it('narrows a wide-refspec remote to the known branch and deletes the strays left by the old wide fetch', async () => { + const trackingRefsByRemote = { + [FORK_REMOTE]: ['contributor/fix', 'contributor/unrelated-1', 'master'] + } + const exec = makeExec({ + urlByRemote: { [FORK_REMOTE]: 'git@github.com:contributor/orca.git\n' }, + fetchByRemote: { [FORK_REMOTE]: ['+refs/heads/*:refs/remotes/pr-contributor-orca/*'] }, + trackingRefsByRemote + }) + + const migrated = await migrateForkRemoteRefspecsWithExec( + REPO_PATH, + REPO_ID, + storeOf({ [worktreeId('/wt/a')]: forkTarget() }), + exec + ) + + expect(migrated).toEqual([FORK_REMOTE]) + expect(exec.mock.calls).toContainEqual([ + [ + 'config', + '--add', + `remote.${FORK_REMOTE}.fetch`, + '+refs/heads/contributor/fix*:refs/remotes/pr-contributor-orca/contributor/fix*' + ], + REPO_PATH + ]) + // `fetch --prune` cannot reclaim strays under a narrow refspec (verified against real + // git); the migration must delete them directly instead. + expect(exec.mock.calls.some(([args]) => args[0] === 'fetch' && args[1] === '--prune')).toBe( + false + ) + expect(trackingRefsByRemote[FORK_REMOTE]).toEqual(['contributor/fix']) + }) + + it('unions branches from multiple worktrees sharing the same fork remote', async () => { + const exec = makeExec({ + urlByRemote: { [FORK_REMOTE]: 'git@github.com:contributor/orca.git\n' }, + fetchByRemote: { [FORK_REMOTE]: ['+refs/heads/*:refs/remotes/pr-contributor-orca/*'] } + }) + + await migrateForkRemoteRefspecsWithExec( + REPO_PATH, + REPO_ID, + storeOf({ + [worktreeId('/wt/a')]: forkTarget({ branchName: 'branch-a' }), + [worktreeId('/wt/b')]: forkTarget({ branchName: 'branch-b', remoteCreated: false }) + }), + exec + ) + + const addedRefspecs = exec.mock.calls + .filter(([args]) => args[0] === 'config' && args[1] === '--add') + .map(([args]) => args[3]) + expect(addedRefspecs).toEqual( + expect.arrayContaining([ + '+refs/heads/branch-a*:refs/remotes/pr-contributor-orca/branch-a*', + '+refs/heads/branch-b*:refs/remotes/pr-contributor-orca/branch-b*' + ]) + ) + }) + + it('skips a remote with no provenance evidence (no metadata entry has remoteCreated: true)', async () => { + const exec = makeExec({ + urlByRemote: { [FORK_REMOTE]: 'git@github.com:contributor/orca.git\n' }, + fetchByRemote: { [FORK_REMOTE]: ['+refs/heads/*:refs/remotes/pr-contributor-orca/*'] } + }) + + const migrated = await migrateForkRemoteRefspecsWithExec( + REPO_PATH, + REPO_ID, + storeOf({ [worktreeId('/wt/a')]: forkTarget({ remoteCreated: false }) }), + exec + ) + + expect(migrated).toEqual([]) + expect(exec.mock.calls.some(([args]) => args[1] === '--unset-all')).toBe(false) + }) + + it('never touches origin or upstream even with malformed metadata', async () => { + const exec = makeExec({ urlByRemote: { origin: 'git@github.com:stablyai/orca.git\n' } }) + + const migrated = await migrateForkRemoteRefspecsWithExec( + REPO_PATH, + REPO_ID, + storeOf({ [worktreeId('/wt/a')]: forkTarget({ remoteName: 'origin' }) }), + exec + ) + + expect(migrated).toEqual([]) + }) + + it('scopes provenance to the same repo (remotes are repo-local)', async () => { + const exec = makeExec({ + urlByRemote: { [FORK_REMOTE]: 'git@github.com:contributor/orca.git\n' }, + fetchByRemote: { [FORK_REMOTE]: ['+refs/heads/*:refs/remotes/pr-contributor-orca/*'] } + }) + + const migrated = await migrateForkRemoteRefspecsWithExec( + REPO_PATH, + REPO_ID, + storeOf({ 'repo-2::/wt/other-repo': forkTarget() }), + exec + ) + + expect(migrated).toEqual([]) + }) + + it('skips (does not re-fetch) a remote that is already narrow', async () => { + const exec = makeExec({ + urlByRemote: { [FORK_REMOTE]: 'git@github.com:contributor/orca.git\n' }, + fetchByRemote: { + [FORK_REMOTE]: [ + '+refs/heads/contributor/fix*:refs/remotes/pr-contributor-orca/contributor/fix*' + ] + } + }) + + const migrated = await migrateForkRemoteRefspecsWithExec( + REPO_PATH, + REPO_ID, + storeOf({ [worktreeId('/wt/a')]: forkTarget() }), + exec + ) + + expect(migrated).toEqual([]) + expect(exec.mock.calls.some(([args]) => args[0] === 'fetch')).toBe(false) + }) + + it('abandons and cleans up a remote reclaimed concurrently by #17842 reconciliation mid-migration', async () => { + const exec = makeExec({ + urlByRemote: { [FORK_REMOTE]: 'git@github.com:contributor/orca.git\n' }, + fetchByRemote: { [FORK_REMOTE]: ['+refs/heads/*:refs/remotes/pr-contributor-orca/*'] }, + removeUrlAfterFirstCheck: new Set([FORK_REMOTE]) + }) + + const migrated = await migrateForkRemoteRefspecsWithExec( + REPO_PATH, + REPO_ID, + storeOf({ [worktreeId('/wt/a')]: forkTarget() }), + exec + ) + + // Not reported as migrated -- reconciliation won the race, so this sweep backs off. + expect(migrated).toEqual([]) + expect( + exec.mock.calls.some( + ([args]) => + args[0] === 'config' && + args[1] === '--remove-section' && + args[2] === `remote.${FORK_REMOTE}` + ) + ).toBe(true) + // No fetch --prune-equivalent local ref deletion ran for a remote that's already gone. + expect(exec.mock.calls.some(([args]) => args[0] === 'update-ref')).toBe(false) + }) + + it('clears the fetch refspec of a wide pr-* remote with zero worktree-metadata trace at all', async () => { + const ORPHAN_REMOTE = 'pr-ghost-orca' + const trackingRefsByRemote = { [ORPHAN_REMOTE]: ['some-branch', 'another-branch'] } + const exec = makeExec({ + remoteNames: [ORPHAN_REMOTE], + urlByRemote: { [ORPHAN_REMOTE]: 'git@github.com:ghost/orca.git\n' }, + fetchByRemote: { [ORPHAN_REMOTE]: ['+refs/heads/*:refs/remotes/pr-ghost-orca/*'] }, + trackingRefsByRemote + }) + + // No worktree metadata references this remote at all (worktree removed outside + // preserve-on-delete, metadata purged) -- only discoverable via `git remote`. + const migrated = await migrateForkRemoteRefspecsWithExec(REPO_PATH, REPO_ID, storeOf({}), exec) + + expect(migrated).toEqual([ORPHAN_REMOTE]) + expect(exec.mock.calls).toContainEqual([ + ['config', '--unset-all', `remote.${ORPHAN_REMOTE}.fetch`], + REPO_PATH + ]) + // No branch to narrow to, so it never adds a replacement refspec. + expect(exec.mock.calls.some(([args]) => args[0] === 'config' && args[1] === '--add')).toBe( + false + ) + // Every stray tracking ref is pruned, same as the narrowing path. + expect(trackingRefsByRemote[ORPHAN_REMOTE]).toEqual([]) + }) + + it('leaves a zero-provenance pr-* remote alone if its refspec is not the stock wide default', async () => { + const CUSTOM_REMOTE = 'pr-custom-orca' + const exec = makeExec({ + remoteNames: [CUSTOM_REMOTE], + urlByRemote: { [CUSTOM_REMOTE]: 'git@github.com:custom/orca.git\n' }, + fetchByRemote: { + [CUSTOM_REMOTE]: ['+refs/heads/some-branch:refs/remotes/pr-custom-orca/some-branch'] + } + }) + + const migrated = await migrateForkRemoteRefspecsWithExec(REPO_PATH, REPO_ID, storeOf({}), exec) + + expect(migrated).toEqual([]) + expect(exec.mock.calls.some(([args]) => args[1] === '--unset-all')).toBe(false) + }) + + it('never discovers a non-pr-prefixed remote through the bare listing, even if wide', async () => { + const exec = makeExec({ + remoteNames: ['some-other-remote'], + urlByRemote: { 'some-other-remote': 'git@github.com:someone/else.git\n' }, + fetchByRemote: { 'some-other-remote': ['+refs/heads/*:refs/remotes/some-other-remote/*'] } + }) + + const migrated = await migrateForkRemoteRefspecsWithExec(REPO_PATH, REPO_ID, storeOf({}), exec) + + expect(migrated).toEqual([]) + expect(exec.mock.calls.some(([args]) => args[1] === '--unset-all')).toBe(false) + }) + + it('also narrows branches only referenced by surviving branch.*.remote config (no metadata left)', async () => { + const exec = makeExec({ + urlByRemote: { [FORK_REMOTE]: 'git@github.com:contributor/orca.git\n' }, + fetchByRemote: { [FORK_REMOTE]: ['+refs/heads/*:refs/remotes/pr-contributor-orca/*'] }, + branchConfig: `branch.contributor/preserved.remote ${FORK_REMOTE}` + }) + + const migrated = await migrateForkRemoteRefspecsWithExec( + REPO_PATH, + REPO_ID, + // Only proof of Orca provenance; the branch itself comes from local config. + storeOf({ [worktreeId('/wt/gone')]: forkTarget({ branchName: 'contributor/fix' }) }), + exec + ) + + expect(migrated).toEqual([FORK_REMOTE]) + const addedRefspecs = exec.mock.calls + .filter(([args]) => args[0] === 'config' && args[1] === '--add') + .map(([args]) => args[3]) + expect(addedRefspecs).toEqual( + expect.arrayContaining([ + '+refs/heads/contributor/preserved*:refs/remotes/pr-contributor-orca/contributor/preserved*' + ]) + ) + }) +}) + +describe('fork remote refspec migration rate limiting', () => { + it('exposes a test reset so repeated test runs are not affected by prior cooldowns', () => { + expect(() => _resetForkRemoteRefspecMigrationRateLimitForTests()).not.toThrow() + }) +}) diff --git a/src/main/ipc/worktree-push-target-refspec-migration.ts b/src/main/ipc/worktree-push-target-refspec-migration.ts new file mode 100644 index 00000000000..560f7595127 --- /dev/null +++ b/src/main/ipc/worktree-push-target-refspec-migration.ts @@ -0,0 +1,227 @@ +// Why: remotes minted or reused before #17828's narrow-refspec fix are stuck on the +// wide `+refs/heads/*:refs/remotes//*` default, so any later plain `git fetch` +// keeps re-importing the fork's whole branch set. This sweep rewrites each surviving +// Orca-provenance `pr-*` remote's refspec to only the branches Orca actually tracked +// for it, then deletes the refs the earlier wide fetch already imported for every other +// branch (`git fetch --prune` cannot reclaim these once the refspec is narrow -- verified +// against real git, see #17828 PR). It never deletes a remote (that is +// `worktree-push-target-cleanup.ts`'s job) -- only narrows what a future fetch pulls. +// +// Candidate discovery also widens past worktree metadata to every `pr-*` remote on disk +// (see `listRemoteNames` below): metadata-only discovery misses a remote whose every +// worktree was removed outside preserve-on-delete (worktree gone *and* metadata purged, +// not just the worktree) -- field data on a real repo found 15 of 31 fork remotes with no +// branch pinning them at all, permanently invisible to metadata-only discovery and stuck +// wide forever. For those, there's nothing to narrow *to*, so the sweep clears the fetch +// refspec entirely instead (stays pushable, imports nothing on a plain fetch) -- gated on +// the remote still carrying the untouched stock wide default, since a `pr`-prefixed name +// alone isn't proof of Orca provenance the way a metadata entry is. +// +// Interaction with `worktree-push-target-reconciliation.ts` (#17842, orphaned `pr-*` +// remote reclamation): the two sweeps fire from different lifecycle events (this one +// from worktree creation, that one from worktree removal), rate-limit via separate +// `Map` cooldowns, and so never share state or starve each other. +// They *can* still race on the same remote if creation and removal happen close +// together for the same repo, because both derive their candidate remotes from the +// same worktree-metadata store: a remote reconciliation is about to reclaim (no live +// worktree still claims it) can be one this sweep is concurrently narrowing (its stale +// metadata entry hasn't been pruned from the store yet). `remoteHasUrl` re-checked both +// before and after the narrowing writes closes the practical impact of that race down +// to "reconciliation wins and this sweep's writes get cleaned back up" rather than a +// stray url-less `remote..*` config section -- see the guard below. + +import { gitExecFileAsync } from '../git/runner' +import { + clearForkRemoteFetchRefspec, + ensureRemoteTracksBranchNarrowly, + getRemoteFetchRefspecs, + pruneUntrackedForkRemoteRefs, + remoteHasUrl, + wildcardForkFetchRefspec +} from '../git/fork-remote-refspec' +import { getRepoIdFromWorktreeId } from '../../shared/worktree/id' +import { iterateProcessOutputLines } from '../../shared/process-output-field-scanner' +import type { GitRemoteExec, WorktreePushTargetStore } from './worktree-push-target-cleanup' + +const NEVER_MIGRATE_REMOTE_NAMES = new Set(['origin', 'upstream']) +// Fork remotes are always minted as `pr-${slug}` (see pull-request-push-target.ts). Used +// only as a secondary discovery signal below for remotes with zero metadata trace -- +// primary gating stays the wide-refspec check, not this prefix alone. +const PR_REMOTE_NAME_PREFIX = 'pr-' + +async function listRemoteNames(execGit: GitRemoteExec, repoPath: string): Promise { + try { + const { stdout } = await execGit(['remote'], repoPath) + return [...iterateProcessOutputLines(stdout)].map((line) => line.trim()).filter(Boolean) + } catch { + return [] + } +} + +/** `pushTarget`-derived branches to keep per remote, gated on at least one entry proving Orca created it. */ +function collectProvenBranchesByRemote( + store: WorktreePushTargetStore, + repoId: string +): Map> { + const branchesByRemote = new Map>() + const provenRemotes = new Set() + for (const [worktreeId, meta] of Object.entries(store.getAllWorktreeMeta())) { + if (getRepoIdFromWorktreeId(worktreeId) !== repoId || !meta.pushTarget) { + continue + } + const { remoteName, branchName, remoteCreated } = meta.pushTarget + if (remoteCreated === true) { + provenRemotes.add(remoteName) + } + const branches = branchesByRemote.get(remoteName) ?? new Set() + branches.add(branchName) + branchesByRemote.set(remoteName, branches) + } + for (const remoteName of branchesByRemote.keys()) { + if (!provenRemotes.has(remoteName)) { + branchesByRemote.delete(remoteName) + } + } + return branchesByRemote +} + +// `branch..remote`/`.pushRemote` config can outlive worktree metadata (preserve-on-delete), +// so a branch it protects is still worth keeping narrowly tracked even with no metadata left. +async function collectBranchesFromLocalConfig( + execGit: GitRemoteExec, + repoPath: string, + remoteName: string +): Promise { + let stdout: string + try { + ;({ stdout } = await execGit( + ['config', '--get-regexp', '^branch\\..*\\.(remote|pushRemote)$'], + repoPath + )) + } catch { + return [] + } + const branches: string[] = [] + for (const line of iterateProcessOutputLines(stdout)) { + const match = /^branch\.(.+)\.(?:remote|pushRemote) (.+)$/.exec(line.trim()) + if (match && match[2] === remoteName) { + branches.push(match[1]!) + } + } + return branches +} + +/** + * Exported for tests: the `execGit` seam drives the migration matrix without a real repo. + * Returns the names of remotes actually rewritten (wide -> narrow, then pruned). + */ +export async function migrateForkRemoteRefspecsWithExec( + repoPath: string, + repoId: string, + store: WorktreePushTargetStore, + execGit: GitRemoteExec +): Promise { + const branchesByRemote = collectProvenBranchesByRemote(store, repoId) + // Why: `branchesByRemote` only surfaces remotes with a *surviving* worktree-metadata + // entry. A remote whose every worktree was removed outside preserve-on-delete (metadata + // purged, not just the worktree) is invisible to it -- field data on a real repo found + // 15 of 31 fork remotes with zero branch pinning at all, still stuck wide. Widen + // discovery to every `pr-*` remote on disk so those aren't silently skipped forever. + const candidateNames = new Set(branchesByRemote.keys()) + for (const remoteName of await listRemoteNames(execGit, repoPath)) { + if (remoteName.startsWith(PR_REMOTE_NAME_PREFIX)) { + candidateNames.add(remoteName) + } + } + const migrated: string[] = [] + for (const remoteName of candidateNames) { + if (NEVER_MIGRATE_REMOTE_NAMES.has(remoteName)) { + continue + } + const branches = new Set(branchesByRemote.get(remoteName) ?? []) + for (const branch of await collectBranchesFromLocalConfig(execGit, repoPath, remoteName)) { + branches.add(branch) + } + if (!(await remoteHasUrl(execGit, repoPath, remoteName))) { + continue // config references a remote that no longer exists + } + const before = await getRemoteFetchRefspecs(execGit, repoPath, remoteName) + const wasWide = before.includes(wildcardForkFetchRefspec(remoteName)) + if (branches.size === 0) { + // No metadata and no branch config pins this remote to anything -- there's nothing + // to narrow *to*. Only act if it's still the untouched stock wide default: that's + // the strongest available signal this came from a bare `git remote add` (ours or a + // pre-#17828 Orca's), not a `pr`-prefixed remote a user configured by hand. Clears + // rather than deletes -- removing the remote outright stays #17842's job. + if (!wasWide) { + continue + } + await clearForkRemoteFetchRefspec(execGit, repoPath, remoteName) + } else { + for (const branch of branches) { + await ensureRemoteTracksBranchNarrowly(execGit, repoPath, remoteName, branch) + } + } + // #17842's reconciliation sweep can concurrently `remote remove` this same + // remote (both sweeps derive their candidate list from the same, possibly-stale, + // worktree metadata). `remote remove` deletes the whole `remote..*` section, + // but `ensureRemoteTracksBranchNarrowly` above would have just resurrected a + // url-less `fetch`/`tagOpt` section via plain `config --add`, which doesn't care + // whether the remote "exists". Detect that and clean up instead of leaving ghost + // config behind -- narrows but does not close the race (no cross-process lock + // exists), so this is a best-effort self-heal, not a guarantee. + if (!(await remoteHasUrl(execGit, repoPath, remoteName))) { + await execGit(['config', '--remove-section', `remote.${remoteName}`], repoPath).catch( + () => {} + ) + continue + } + if (!wasWide) { + continue // already narrow (minted post-fix, or a prior sweep already ran); nothing to prune + } + // Best-effort: the refspec is narrowed regardless of whether this local ref cleanup + // succeeds. Purely local (no network), so failures here should be rare/unexpected. + await pruneUntrackedForkRemoteRefs(execGit, repoPath, remoteName, branches).catch(() => []) + migrated.push(remoteName) + } + return migrated +} + +// Why: the sweep costs a handful of git subprocesses per candidate remote; bound to once +// per repo per cooldown so bursts of worktree creates don't repeat it. +const MIGRATE_COOLDOWN_MS = 60 * 60 * 1000 +const lastMigratedAtByRepoId = new Map() + +function shouldMigrateNow(repoId: string): boolean { + const last = lastMigratedAtByRepoId.get(repoId) + return last === undefined || Date.now() - last >= MIGRATE_COOLDOWN_MS +} + +export function _resetForkRemoteRefspecMigrationRateLimitForTests(): void { + lastMigratedAtByRepoId.clear() +} + +/** Best-effort, rate-limited sweep; call sites fire this without awaiting it. */ +export async function migrateForkRemoteRefspecs( + repoPath: string, + repoId: string, + store: WorktreePushTargetStore, + gitOptions: { wslDistro?: string } = {} +): Promise { + if (!shouldMigrateNow(repoId)) { + return + } + lastMigratedAtByRepoId.set(repoId, Date.now()) + try { + const migrated = await migrateForkRemoteRefspecsWithExec(repoPath, repoId, store, (args, cwd) => + gitExecFileAsync(args, { cwd, ...gitOptions }) + ) + if (migrated.length > 0) { + console.log( + `[worktrees] Narrowed fetch refspec for ${migrated.length} fork remote(s) in ${repoPath}: ${migrated.join(', ')}` + ) + } + } catch (error) { + console.warn(`[worktrees] Fork remote refspec migration failed for ${repoPath}:`, error) + } +} diff --git a/src/main/ipc/worktree-push-target-refspec-real-git.test.ts b/src/main/ipc/worktree-push-target-refspec-real-git.test.ts new file mode 100644 index 00000000000..57f84560bdd --- /dev/null +++ b/src/main/ipc/worktree-push-target-refspec-real-git.test.ts @@ -0,0 +1,249 @@ +// Real-binary coverage for #17828: the mocked-exec suites in +// `worktree-push-target-setup.test.ts` / `worktree-push-target-refspec-migration.test.ts` +// prove the decision logic, but not that real `git remote add -t/--no-tags`, a plain +// `git fetch `, and `git config --get-all/--unset-all/--add` behave the way this +// fix assumes. In particular this proves the core claim of the fix: a minted remote never +// imports more than the branch(es) Orca asked for, even from a fork with many branches. +import { execFile } from 'node:child_process' +import { mkdir, mkdtemp, realpath, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { promisify } from 'node:util' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import type { WorktreeMeta } from '../../shared/worktree/meta-types' +import type { GitPushTarget } from '../../shared/worktree/types' +import type { GitRemoteExec, WorktreePushTargetStore } from './worktree-push-target-cleanup' +import { prepareWorktreePushTargetWithExec } from './worktree-push-target-setup' +import { migrateForkRemoteRefspecsWithExec } from './worktree-push-target-refspec-migration' + +const execFileAsync = promisify(execFile) + +const REPO_ID = 'repo-1' +const FORK_REMOTE = 'pr-contributor-orca' +const TRACKED_BRANCH = 'contributor/fix' +const OTHER_FORK_BRANCHES = Array.from({ length: 12 }, (_, i) => `contributor/unrelated-${i}`) + +let scratchDir = '' +let repoPath = '' +let forkPath = '' + +async function git(args: string[], cwd: string): Promise { + const { stdout } = await execFileAsync('git', args, { cwd }) + return stdout +} + +const execGit: GitRemoteExec = (args, cwd) => execFileAsync('git', args, { cwd }) + +async function setIdentity(cwd: string): Promise { + await git(['config', 'user.name', 'Orca Test'], cwd) + await git(['config', 'user.email', 'orca@example.test'], cwd) + await git(['config', 'commit.gpgSign', 'false'], cwd) + await git(['config', 'core.hooksPath', '.git/no-hooks'], cwd) +} + +async function trackedRefsUnder(remoteName: string): Promise { + const stdout = await git( + ['for-each-ref', '--format=%(refname:short)', `refs/remotes/${remoteName}/`], + repoPath + ) + return stdout.split(/\r?\n/).filter(Boolean) +} + +function worktreeId(suffix: string): string { + return `${REPO_ID}::${suffix}` +} + +function storeOf(entries: Record): WorktreePushTargetStore { + const meta: Record = {} + for (const [id, pushTarget] of Object.entries(entries)) { + meta[id] = { pushTarget } as unknown as WorktreeMeta + } + return { getAllWorktreeMeta: () => meta } +} + +beforeEach(async () => { + // realpath: macOS hands out /var/... temp paths while Git reports /private/var/... + scratchDir = await realpath(await mkdtemp(join(tmpdir(), 'orca-fork-refspec-'))) + repoPath = join(scratchDir, 'repo') + forkPath = join(scratchDir, 'fork') + await mkdir(repoPath, { recursive: true }) + await git(['init', '-q'], repoPath) + await setIdentity(repoPath) + await writeFile(join(repoPath, 'seed.txt'), 'seed\n') + await git(['add', '-A'], repoPath) + await git(['commit', '-qm', 'seed'], repoPath) + + // A large fork: the tracked PR branch plus a dozen unrelated branches, simulating the + // 1000+-branch forks the issue describes (scaled down for test speed). + await git(['clone', '-q', repoPath, forkPath], scratchDir) + await setIdentity(forkPath) + for (const branch of [TRACKED_BRANCH, ...OTHER_FORK_BRANCHES]) { + await git(['checkout', '-qb', branch], forkPath) + await writeFile(join(forkPath, `${branch.replace(/\//g, '-')}.txt`), 'x\n') + await git(['add', '-A'], forkPath) + await git(['commit', '-qm', branch], forkPath) + } +}) + +afterEach(async () => { + await rm(scratchDir, { recursive: true, force: true }) +}) + +describe('minting a fork remote against the real Git binary (#17828)', () => { + it('caps the remote at exactly one fetch refspec, and a plain `git fetch` imports only that branch', async () => { + const target: GitPushTarget = { + remoteName: FORK_REMOTE, + branchName: TRACKED_BRANCH, + remoteUrl: forkPath + } + + await prepareWorktreePushTargetWithExec(execGit, repoPath, target, () => false) + + const refspecs = (await git(['config', '--get-all', `remote.${FORK_REMOTE}.fetch`], repoPath)) + .split(/\r?\n/) + .filter(Boolean) + expect(refspecs).toEqual([ + `+refs/heads/${TRACKED_BRANCH}*:refs/remotes/${FORK_REMOTE}/${TRACKED_BRANCH}*` + ]) + await expect( + git(['config', '--get', `remote.${FORK_REMOTE}.tagOpt`], repoPath) + ).resolves.toContain('--no-tags') + + // The critical claim: a later plain `git fetch ` (no explicit refspec) -- + // exactly what Orca's own Fetch action and any agent/user command run -- must not + // import the fork's other dozen branches. + await git(['fetch', FORK_REMOTE], repoPath) + await expect(trackedRefsUnder(FORK_REMOTE)).resolves.toEqual([ + `${FORK_REMOTE}/${TRACKED_BRANCH}` + ]) + }) + + it('a bare `git fetch` (branch-scoped upstream, no remote arg) survives the tracked branch being deleted upstream', async () => { + const target: GitPushTarget = { + remoteName: FORK_REMOTE, + branchName: TRACKED_BRANCH, + remoteUrl: forkPath + } + await prepareWorktreePushTargetWithExec(execGit, repoPath, target, () => false) + // Mirrors `configureCreatedWorktreePushTargetWithExec`: local branch tracks the fork + // remote, so a *bare* `git fetch` (the shape an agent running raw git actually types) + // resolves to this remote via `branch..remote` -- not `origin`. + await git(['checkout', '-qb', 'local-branch', `${FORK_REMOTE}/${TRACKED_BRANCH}`], repoPath) + await git( + ['branch', '--set-upstream-to', `${FORK_REMOTE}/${TRACKED_BRANCH}`, 'local-branch'], + repoPath + ) + + // Contributor deletes the branch after the PR merges/closes (checkout any other + // branch first -- the fork's actual default-branch name isn't relevant here). + await git(['checkout', '-q', OTHER_FORK_BRANCHES[0]!], forkPath) + await git(['branch', '-D', TRACKED_BRANCH], forkPath) + + // Before this fix's trailing-`*` refspec, this exact command hard-failed with + // "couldn't find remote ref" -- degrading a common agent/user action into a fatal error. + await expect(git(['fetch'], repoPath)).resolves.toBeDefined() + // And `--prune` (Orca's own Fetch action) correctly reclaims the now-dead ref. + await git(['fetch', '--prune'], repoPath) + await expect(trackedRefsUnder(FORK_REMOTE)).resolves.toEqual([]) + }) + + it('widens rather than replaces when a second worktree reuses the remote for another branch', async () => { + await prepareWorktreePushTargetWithExec( + execGit, + repoPath, + { remoteName: FORK_REMOTE, branchName: TRACKED_BRANCH, remoteUrl: forkPath }, + () => false + ) + const secondBranch = OTHER_FORK_BRANCHES[0]! + + await prepareWorktreePushTargetWithExec( + execGit, + repoPath, + { remoteName: FORK_REMOTE, branchName: secondBranch, remoteUrl: forkPath }, + () => true + ) + + await git(['fetch', FORK_REMOTE], repoPath) + const refs = await trackedRefsUnder(FORK_REMOTE) + expect(refs.sort()).toEqual( + [`${FORK_REMOTE}/${TRACKED_BRANCH}`, `${FORK_REMOTE}/${secondBranch}`].sort() + ) + // Only the two branches Orca actually asked for -- not the other ten. + expect(refs).toHaveLength(2) + }) +}) + +describe('migrateForkRemoteRefspecsWithExec against the real Git binary', () => { + it('narrows a pre-existing wide-refspec remote and prunes the strays it already fetched', async () => { + // Simulate a remote minted before the #17828 fix: bare `remote add`, full fetch. + const forkBranchCount = (await git(['branch', '--format=%(refname:short)'], forkPath)) + .split(/\r?\n/) + .filter(Boolean).length + await git(['remote', 'add', FORK_REMOTE, forkPath], repoPath) + await git(['fetch', FORK_REMOTE], repoPath) + const before = await trackedRefsUnder(FORK_REMOTE) + // Every fork branch imported (tracked branch + unrelated ones + the fork's default branch). + // Git >= 2.44's `followRemoteHEAD` auto-creates `refs/remotes//HEAD` on a fetch + // matching the full wildcard refspec (verified: absent on 2.44, present on 2.55) -- + // excluded here since it isn't a branch this test (or the migration) cares about. + // `%(refname:short)` shortens the remote's own HEAD symref to just the remote name + // (no `/HEAD` suffix) -- verified against real git. + const headRef = FORK_REMOTE + expect(before.filter((ref) => ref !== headRef).length).toBe(forkBranchCount) + + const migrated = await migrateForkRemoteRefspecsWithExec( + repoPath, + REPO_ID, + storeOf({ + [worktreeId('/wt/a')]: { + remoteName: FORK_REMOTE, + branchName: TRACKED_BRANCH, + remoteUrl: forkPath, + remoteCreated: true + } + }), + execGit + ) + + expect(migrated).toEqual([FORK_REMOTE]) + const refspecs = (await git(['config', '--get-all', `remote.${FORK_REMOTE}.fetch`], repoPath)) + .split(/\r?\n/) + .filter(Boolean) + expect(refspecs).toEqual([ + `+refs/heads/${TRACKED_BRANCH}*:refs/remotes/${FORK_REMOTE}/${TRACKED_BRANCH}*` + ]) + // `pruneUntrackedForkRemoteRefs` deliberately never deletes `HEAD` (see its docstring), + // so a `HEAD` symref this git version auto-created above legitimately survives pruning -- + // assert on the branch refs only, same exclusion as above. + const after = await trackedRefsUnder(FORK_REMOTE) + expect(after.filter((ref) => ref !== headRef)).toEqual([`${FORK_REMOTE}/${TRACKED_BRANCH}`]) + }) + + it('clears the refspec of a wide pr-* remote with zero worktree-metadata trace, and prunes its refs', async () => { + // Simulates a remote whose worktree was removed outside preserve-on-delete: no live + // worktree, and no surviving metadata entry either -- the store knows nothing about it. + await git(['remote', 'add', FORK_REMOTE, forkPath], repoPath) + await git(['fetch', FORK_REMOTE], repoPath) + const before = await trackedRefsUnder(FORK_REMOTE) + expect(before.length).toBeGreaterThan(1) + + const migrated = await migrateForkRemoteRefspecsWithExec( + repoPath, + REPO_ID, + storeOf({}), + execGit + ) + + expect(migrated).toEqual([FORK_REMOTE]) + await expect( + git(['config', '--get-all', `remote.${FORK_REMOTE}.fetch`], repoPath) + ).rejects.toThrow() + // The remote itself survives (only #17842's reconciliation removes remotes outright), + // and stays pushable -- just imports nothing on a subsequent plain fetch. + await expect( + git(['config', '--get', `remote.${FORK_REMOTE}.url`], repoPath) + ).resolves.toContain(forkPath) + await git(['fetch', FORK_REMOTE], repoPath) + await expect(trackedRefsUnder(FORK_REMOTE)).resolves.toEqual([]) + }) +}) diff --git a/src/main/ipc/worktree-push-target-setup.test.ts b/src/main/ipc/worktree-push-target-setup.test.ts index d70eec5218a..be718cfd10c 100644 --- a/src/main/ipc/worktree-push-target-setup.test.ts +++ b/src/main/ipc/worktree-push-target-setup.test.ts @@ -29,7 +29,8 @@ function makeRepoExec(remotes: Record): ExecMock { return { stdout: `${url}\n`, stderr: '' } } if (args[0] === 'remote' && args[1] === 'add') { - remotes[args[2]!] = args[3]! + // Why: name/url are always the last two args, regardless of `-t`/`--no-tags` flags. + remotes[args.at(-2)!] = args.at(-1)! return { stdout: '', stderr: '' } } if (args[0] === 'remote' && args[1] === 'remove') { @@ -62,13 +63,13 @@ describe('prepareWorktreePushTargetWithExec', () => { const result = await prepareWorktreePushTargetWithExec(exec, REPO, forkTarget(), () => false) expect(callsMatching(exec, ['remote', 'add'])).toEqual([ - ['remote', 'add', 'pr-contributor-orca', FORK_SSH] + ['remote', 'add', '-t', 'contributor/fix', '--no-tags', 'pr-contributor-orca', FORK_SSH] ]) expect(callsMatching(exec, ['fetch'])).toEqual([ [ 'fetch', 'pr-contributor-orca', - '+refs/heads/contributor/fix:refs/remotes/pr-contributor-orca/contributor/fix' + '+refs/heads/contributor/fix*:refs/remotes/pr-contributor-orca/contributor/fix*' ] ]) expect(result).toEqual({ @@ -92,7 +93,7 @@ describe('prepareWorktreePushTargetWithExec', () => { [ 'fetch', 'pr-contributor-orca', - '+refs/heads/contributor/fix:refs/remotes/pr-contributor-orca/contributor/fix' + '+refs/heads/contributor/fix*:refs/remotes/pr-contributor-orca/contributor/fix*' ] ]) // remoteCreated omitted because the predicate says no known worktree owns it. @@ -118,7 +119,7 @@ describe('prepareWorktreePushTargetWithExec', () => { const result = await prepareWorktreePushTargetWithExec(exec, REPO, forkTarget(), () => false) expect(callsMatching(exec, ['remote', 'add'])).toEqual([ - ['remote', 'add', 'pr-contributor-orca-2', FORK_SSH] + ['remote', 'add', '-t', 'contributor/fix', '--no-tags', 'pr-contributor-orca-2', FORK_SSH] ]) expect(result.remoteName).toBe('pr-contributor-orca-2') expect(result.remoteCreated).toBe(true) @@ -136,7 +137,7 @@ describe('prepareWorktreePushTargetWithExec', () => { expect(callsMatching(exec, ['remote', 'add'])).toEqual([]) expect(callsMatching(exec, ['fetch'])).toEqual([ - ['fetch', 'origin', '+refs/heads/feature:refs/remotes/origin/feature'] + ['fetch', 'origin', '+refs/heads/feature*:refs/remotes/origin/feature*'] ]) expect(result).toEqual({ remoteName: 'origin', branchName: 'feature' }) }) diff --git a/src/main/ipc/worktree-push-target-setup.ts b/src/main/ipc/worktree-push-target-setup.ts index 28bfa15e328..e064b1f35c3 100644 --- a/src/main/ipc/worktree-push-target-setup.ts +++ b/src/main/ipc/worktree-push-target-setup.ts @@ -7,6 +7,10 @@ import type { GitPushTarget } from '../../shared/worktree/types' import { parseGitHubOwnerRepo } from '../github/gh-utils' import type { GitRemoteExec } from './worktree-push-target-cleanup' +import { + buildNarrowForkFetchRefspec, + ensureRemoteTracksBranchNarrowly +} from '../git/fork-remote-refspec' export async function findRemoteForUrl( execGit: GitRemoteExec, @@ -91,9 +95,22 @@ export async function prepareWorktreePushTargetWithExec( // Why: if a later PR worktree reuses an Orca-created fork remote, it // must inherit ownership so deleting the final user can remove it. remoteCreated = isRemoteCreatedByKnownWorktree(existingRemote) + // Why: a remote created before this fix (or reused for a second branch on the + // same fork) may still carry the wide default refspec; widen-but-bound it to + // cover this branch too rather than trusting whatever is already configured. + await ensureRemoteTracksBranchNarrowly(execGit, repoPath, remoteName, target.branchName) } else { remoteName = await ensureUniqueRemoteName(execGit, repoPath, target.remoteName) - await execGit(['remote', 'add', remoteName, target.remoteUrl], repoPath) + // Why: `-t --no-tags` means this remote is never, even transiently, + // written with the wide default `refs/heads/*` refspec + tag auto-follow (#17828). + // `-t` itself writes a literal (non-wildcard-suffixed) refspec, so immediately + // rewrite it to the trailing-`*` form via `ensureRemoteTracksBranchNarrowly` + // (see that function's comment for why the suffix matters). + await execGit( + ['remote', 'add', '-t', target.branchName, '--no-tags', remoteName, target.remoteUrl], + repoPath + ) + await ensureRemoteTracksBranchNarrowly(execGit, repoPath, remoteName, target.branchName) remoteCreated = true remoteAddedHere = true } @@ -101,11 +118,7 @@ export async function prepareWorktreePushTargetWithExec( try { await execGit( - [ - 'fetch', - remoteName, - `+refs/heads/${target.branchName}:refs/remotes/${remoteName}/${target.branchName}` - ], + ['fetch', remoteName, buildNarrowForkFetchRefspec(remoteName, target.branchName)], repoPath ) } catch (error) { diff --git a/src/main/ipc/worktree-remote.ts b/src/main/ipc/worktree-remote.ts index c389eade83d..ce6ee7b3394 100644 --- a/src/main/ipc/worktree-remote.ts +++ b/src/main/ipc/worktree-remote.ts @@ -104,12 +104,17 @@ import { type GitRemoteExec, type WorktreePushTargetStore } from './worktree-push-target-cleanup' +import { + reconcileOrphanedPrRemotes, + reconcileOrphanedPrRemotesSsh +} from './worktree-push-target-reconciliation' import { configureCreatedWorktreePushTargetWithExec, ensureUniqueRemoteName, findRemoteForUrl, prepareWorktreePushTargetWithExec } from './worktree-push-target-setup' +import { migrateForkRemoteRefspecs } from './worktree-push-target-refspec-migration' import { isENOENT } from './filesystem-path-containment' import { registerCreatedWorktreeRoot, @@ -966,7 +971,7 @@ export async function prepareWorktreePushTarget( gitOptions: { wslDistro?: string } = {} ): Promise { await validateGitPushTarget(repoPath, target, gitOptions) - return prepareWorktreePushTargetWithExec( + const prepared = await prepareWorktreePushTargetWithExec( (args, cwd) => gitExecFileAsync(args, { cwd, ...gitOptions }), repoPath, target, @@ -979,6 +984,13 @@ export async function prepareWorktreePushTarget( ) : false ) + // Why: opportunistically narrow any other fork remote in this repo still on the old + // wide refspec (pre-#17828 mint, or reused before this fix). Rate-limited and + // fire-and-forget so a large leaked-remote backlog never slows down this create. + if (store && repoId) { + void migrateForkRemoteRefspecs(repoPath, repoId, store, gitOptions) + } + return prepared } function isPushTargetRemoteCreatedByKnownWorktree( @@ -1022,6 +1034,18 @@ export async function cleanupUnusedWorktreePushTargetRemote( } catch (error) { console.warn(`[worktrees] Failed to clean up fork PR remote for ${removedWorktreeId}`, error) } + // Why: also catches remotes this specific removal couldn't reclaim (legacy metadata, + // a preserved branch since deleted, a worktree removed outside Orca) -- see + // worktree-push-target-reconciliation.ts. Rate-limited internally; safe to call every removal. + // Not awaited: a repo with a large backlog (the scenario this exists for) can have dozens of + // candidate remotes, each probed with a couple of git subprocesses -- that must never add + // latency to the worktree-removal call the user is waiting on. It catches its own errors. + void reconcileOrphanedPrRemotes( + repoPath, + getRepoIdFromWorktreeId(removedWorktreeId), + store, + gitOptions + ) } export async function configureCreatedWorktreePushTarget( @@ -1126,6 +1150,14 @@ export async function cleanupUnusedWorktreePushTargetRemoteSsh( error ) } + // Why: SSH counterpart of the sweep above -- the execution host owns these remotes. + // Not awaited for the same reason as the local path: never add sweep latency to removal. + void reconcileOrphanedPrRemotesSsh( + provider, + repoPath, + getRepoIdFromWorktreeId(removedWorktreeId), + store + ) } async function readRemoteEffectiveHooks( @@ -2157,130 +2189,139 @@ export async function createLocalWorktree( let lastExistingReviewNumber: number | null = null const shouldRetireGeneratedName = args.nameWasGenerated === true && isGeneratedWorktreeCreateName(sanitizedName) - const retiredNameRegistry = shouldRetireGeneratedName - ? await getRetiredNameRegistryForRepo(store, repo, store.getRepos(), settings) - : null - const isRetiredName = retiredNameRegistry ? createRetiredNameLookup(retiredNameRegistry) : null - // Why: a create-from-review branch override may already exist locally; suffix both branch and path instead of blocking the user. - for (let suffix = 1, attempts = 0; attempts < WORKTREE_CREATE_MAX_SUFFIX_ATTEMPTS; suffix += 1) { - effectiveSanitizedName = shouldRetireGeneratedName - ? getGeneratedWorktreeCreateCandidate( - sanitizedName, - suffix, - retiredNameRegistry?.exhaustedTiers - ) - : getWorktreeCreateCandidate(sanitizedName, suffix) - effectiveRequestedName = shouldRetireGeneratedName - ? effectiveSanitizedName - : requestedName.trim() - ? getWorktreeCreateCandidate(requestedName, suffix) - : effectiveSanitizedName - if (isRetiredName?.(effectiveSanitizedName)) { - continue - } - attempts += 1 - lastExistingReviewNumber = null + await timing.time('resolve_name', async () => { + const retiredNameRegistry = shouldRetireGeneratedName + ? await getRetiredNameRegistryForRepo(store, repo, store.getRepos(), settings) + : null + const isRetiredName = retiredNameRegistry ? createRetiredNameLookup(retiredNameRegistry) : null + // Why: a create-from-review branch override may already exist locally; suffix both branch and path instead of blocking the user. + for ( + let suffix = 1, attempts = 0; + attempts < WORKTREE_CREATE_MAX_SUFFIX_ATTEMPTS; + suffix += 1 + ) { + effectiveSanitizedName = shouldRetireGeneratedName + ? getGeneratedWorktreeCreateCandidate( + sanitizedName, + suffix, + retiredNameRegistry?.exhaustedTiers + ) + : getWorktreeCreateCandidate(sanitizedName, suffix) + effectiveRequestedName = shouldRetireGeneratedName + ? effectiveSanitizedName + : requestedName.trim() + ? getWorktreeCreateCandidate(requestedName, suffix) + : effectiveSanitizedName + if (isRetiredName?.(effectiveSanitizedName)) { + continue + } + attempts += 1 + lastExistingReviewNumber = null - branchName = await resolveCreateBranchName( - repo.path, - selectedExistingLocalBranchName - ? selectedExistingLocalBranchName - : getBranchNameOverrideCandidate(args.branchNameOverride, suffix), - effectiveSanitizedName, - settings, - username, - localWorktreeGitOptions - ) - checkoutExistingBranch = await canCheckoutExistingLocalBranch( - repo.path, - branchName, - baseBranch, - localWorktreeGitOptions - ) - if (checkoutExistingBranch && !selectedExistingLocalBranchName) { - // Why: suffix retries may need a new path, but an existing-branch checkout must keep the user-selected branch, not a sibling. - selectedExistingLocalBranchName = branchName - } - lastBranchConflictKind = checkoutExistingBranch - ? null - : await getBranchConflictKind(repo.path, branchName, baseBranch, localWorktreeGitOptions) - const allowedPushTargetRemoteConflict = - lastBranchConflictKind && - isAllowedPushTargetRemoteConflict(lastBranchConflictKind, branchName, args) - if (lastBranchConflictKind) { - if (allowedPushTargetRemoteConflict) { - lastExistingPR = null - let lookupFailed = false - const selectedReview = getSelectedReviewBranch(args) - if (selectedReview?.provider === 'github') { - try { - lastExistingPR = await getLocalGitHubPrForBranch( - repo.path, - branchName, - localWorktreeGitOptions - ) - } catch { - lookupFailed = true - } - if (!lookupFailed && isMatchingSelectedGitHubPr(lastExistingPR, args, branchName)) { - lastBranchConflictKind = null - } else if (lastExistingPR) { - lastExistingReviewNumber = lastExistingPR.number - } - } else if (selectedReview) { - let hostedReview: Awaited> = null - try { - hostedReview = await getSelectedHostedReviewForBranch(repo, branchName, args) - } catch { - lookupFailed = true - } - if (!lookupFailed && hostedReview?.matchesSelected) { - lastBranchConflictKind = null - } else if (hostedReview) { - lastExistingReviewNumber = hostedReview.number + branchName = await resolveCreateBranchName( + repo.path, + selectedExistingLocalBranchName + ? selectedExistingLocalBranchName + : getBranchNameOverrideCandidate(args.branchNameOverride, suffix), + effectiveSanitizedName, + settings, + username, + localWorktreeGitOptions + ) + checkoutExistingBranch = await canCheckoutExistingLocalBranch( + repo.path, + branchName, + baseBranch, + localWorktreeGitOptions + ) + if (checkoutExistingBranch && !selectedExistingLocalBranchName) { + // Why: suffix retries may need a new path, but an existing-branch checkout must keep the user-selected branch, not a sibling. + selectedExistingLocalBranchName = branchName + } + lastBranchConflictKind = checkoutExistingBranch + ? null + : await getBranchConflictKind(repo.path, branchName, baseBranch, localWorktreeGitOptions) + const allowedPushTargetRemoteConflict = + lastBranchConflictKind && + isAllowedPushTargetRemoteConflict(lastBranchConflictKind, branchName, args) + if (lastBranchConflictKind) { + if (allowedPushTargetRemoteConflict) { + lastExistingPR = null + let lookupFailed = false + const selectedReview = getSelectedReviewBranch(args) + if (selectedReview?.provider === 'github') { + try { + lastExistingPR = await getLocalGitHubPrForBranch( + repo.path, + branchName, + localWorktreeGitOptions + ) + } catch { + lookupFailed = true + } + if (!lookupFailed && isMatchingSelectedGitHubPr(lastExistingPR, args, branchName)) { + lastBranchConflictKind = null + } else if (lastExistingPR) { + lastExistingReviewNumber = lastExistingPR.number + } + } else if (selectedReview) { + let hostedReview: Awaited> = null + try { + hostedReview = await getSelectedHostedReviewForBranch(repo, branchName, args) + } catch { + lookupFailed = true + } + if (!lookupFailed && hostedReview?.matchesSelected) { + lastBranchConflictKind = null + } else if (hostedReview) { + lastExistingReviewNumber = hostedReview.number + } } } } - } - if (lastBranchConflictKind) { - continue - } - - // Why: gh pr list is a ~1–3s network call; only probe PR conflicts after a branch collision (suffix > 1) so the common no-collision path skips it. - if (suffix > 1 && !checkoutExistingBranch) { - lastExistingPR = null - try { - lastExistingPR = await getLocalGitHubPrForBranch( - repo.path, - branchName, - localWorktreeGitOptions - ) - } catch { - // GitHub API may be unreachable, rate-limited, or token missing - } - if (lastExistingPR && !isMatchingSelectedGitHubPr(lastExistingPR, args, branchName)) { - lastExistingReviewNumber = lastExistingPR.number + if (lastBranchConflictKind) { continue } - } - worktreePath = ensurePathWithinWorkspace( - computeWorktreePath(effectiveSanitizedName, repo.path, worktreePathSettings), - workspaceRoot - ) - if (existsSync(worktreePath)) { - continue - } + // Why: gh pr list is a ~1–3s network call; only probe PR conflicts after a branch collision (suffix > 1) so the common no-collision path skips it. + if (suffix > 1 && !checkoutExistingBranch) { + lastExistingPR = null + try { + lastExistingPR = await getLocalGitHubPrForBranch( + repo.path, + branchName, + localWorktreeGitOptions + ) + } catch { + // GitHub API may be unreachable, rate-limited, or token missing + } + if (lastExistingPR && !isMatchingSelectedGitHubPr(lastExistingPR, args, branchName)) { + lastExistingReviewNumber = lastExistingPR.number + continue + } + } - resolved = true - break - } + worktreePath = ensurePathWithinWorkspace( + computeWorktreePath(effectiveSanitizedName, repo.path, worktreePathSettings), + workspaceRoot + ) + if (existsSync(worktreePath)) { + continue + } + + resolved = true + break + } + }) if (!resolved) { // Why: every suffix collided; reject with a specific reason so the user sees why create failed instead of a generic error or hung spinner. - if (lastExistingReviewNumber !== null) { + // Read once and format eagerly: the suffix loop assigns this from a callback, so the `let`'s + // narrowing does not reach the message. + const existingReviewNumber = lastExistingReviewNumber + if (existingReviewNumber !== null) { throw new Error( - `Branch "${branchName}" already has PR #${lastExistingReviewNumber}. Pick a different ${branchConflictSubject}.` + `Branch "${branchName}" already has PR #${String(existingReviewNumber)}. Pick a different ${branchConflictSubject}.` ) } if (lastBranchConflictKind) { @@ -2329,14 +2370,17 @@ export async function createLocalWorktree( emitCreateWorktreeProgress(mainWindow, 'creating', args.creationId) let preparedPushTarget: GitPushTarget | undefined - if (args.pushTarget) { + const requestedPushTarget = args.pushTarget + if (requestedPushTarget) { // Why: validate/fetch the contributor remote before create so a failure doesn't leave a half-created worktree with conflicts on retry. - preparedPushTarget = await prepareWorktreePushTarget( - repo.path, - args.pushTarget, - store, - repo.id, - localWorktreeGitOptions + preparedPushTarget = await timing.time('prepare_push_target', () => + prepareWorktreePushTarget( + repo.path, + requestedPushTarget, + store, + repo.id, + localWorktreeGitOptions + ) ) } @@ -2358,7 +2402,7 @@ export async function createLocalWorktree( addResult = (await timing.time('git_worktree_add', async () => { if (sparseDirectories.length === 0 && !checkoutExistingBranch) { - const preparedResult = await consumePreparedWorktreeCreate({ + const prepared = await consumePreparedWorktreeCreate({ repoPath: repo.path, workspaceRoot, worktreePath, @@ -2367,9 +2411,19 @@ export async function createLocalWorktree( refreshLocalBaseRef: settings.refreshLocalBaseRefOnWorktreeCreate, ...(preparedWorktreeOptions ? { options: preparedWorktreeOptions } : {}) }) - if (preparedResult) { - return preparedResult + timing.recordPreparedCheckout( + prepared.status === 'hit' + ? { status: 'hit', retargeted: prepared.retargeted } + : { status: 'miss', reason: prepared.reason } + ) + if (prepared.status === 'hit') { + return prepared.result } + } else { + timing.recordPreparedCheckout({ + status: 'miss', + reason: sparseDirectories.length > 0 ? 'sparse_checkout' : 'checkout_existing_branch' + }) } if (sparseDirectories.length > 0) { if (checkoutExistingBranch) { diff --git a/src/main/ipc/worktrees-create-metadata-persistence.test.ts b/src/main/ipc/worktrees-create-metadata-persistence.test.ts index e79df5c5738..934844de2e3 100644 --- a/src/main/ipc/worktrees-create-metadata-persistence.test.ts +++ b/src/main/ipc/worktrees-create-metadata-persistence.test.ts @@ -450,14 +450,22 @@ describe('registerWorktreeHandlers', () => { }) expect(gitExecFileAsyncMock).toHaveBeenCalledWith( - ['remote', 'add', 'pr-prateek-orca', 'git@github.com:prateek/orca.git'], + [ + 'remote', + 'add', + '-t', + 'prateek/fix-sidebar-agents-toggle', + '--no-tags', + 'pr-prateek-orca', + 'git@github.com:prateek/orca.git' + ], { cwd: '/workspace/repo' } ) expect(gitExecFileAsyncMock).toHaveBeenCalledWith( [ 'fetch', 'pr-prateek-orca', - '+refs/heads/prateek/fix-sidebar-agents-toggle:refs/remotes/pr-prateek-orca/prateek/fix-sidebar-agents-toggle' + '+refs/heads/prateek/fix-sidebar-agents-toggle*:refs/remotes/pr-prateek-orca/prateek/fix-sidebar-agents-toggle*' ], { cwd: '/workspace/repo' } ) diff --git a/src/main/ipc/worktrees-local-create-flow.test.ts b/src/main/ipc/worktrees-local-create-flow.test.ts index d01efc489da..abc711bbd9b 100644 --- a/src/main/ipc/worktrees-local-create-flow.test.ts +++ b/src/main/ipc/worktrees-local-create-flow.test.ts @@ -633,7 +633,10 @@ describe('registerWorktreeHandlers', () => { })) as { setup?: unknown startupTerminal?: { spawned: boolean; surface?: string } - timing?: { phases: { phase: string }[] } + timing?: { + phases: { phase: string }[] + preparedCheckout?: { status: string; reason?: string } + } } expect(createSetupRunnerScriptMock).toHaveBeenCalledWith( expect.objectContaining({ id: 'repo-1' }), @@ -695,6 +698,8 @@ describe('registerWorktreeHandlers', () => { 'spawn_startup_terminal' ]) ) + // Nothing warmed this repo, so the create must report the cold path rather than stay silent. + expect(result.timing?.preparedCheckout).toEqual({ status: 'miss', reason: 'none_armed' }) }) it('returns the wrapped setup command when startup spawned but setup creation failed', async () => { diff --git a/src/main/ipc/worktrees-setup-launch-sparse-checkout.test.ts b/src/main/ipc/worktrees-setup-launch-sparse-checkout.test.ts index 0996df03cb4..725f2df8c1f 100644 --- a/src/main/ipc/worktrees-setup-launch-sparse-checkout.test.ts +++ b/src/main/ipc/worktrees-setup-launch-sparse-checkout.test.ts @@ -205,14 +205,14 @@ describe('registerWorktreeHandlers', () => { } ]) - const result = await handlers['worktrees:create'](null, { + const result = (await handlers['worktrees:create'](null, { repoId: 'repo-1', name: 'improve-dashboard', sparseCheckout: { directories: [' packages/web ', 'apps\\api\\', 'packages/web/'], presetId: 'preset-1' } - }) + })) as { timing?: { preparedCheckout?: { status: string; reason?: string } } } expect(addWorktreeMock).not.toHaveBeenCalled() expect(addSparseWorktreeMock).toHaveBeenCalledWith( @@ -240,6 +240,11 @@ describe('registerWorktreeHandlers', () => { sparsePresetId: 'preset-1' }) }) + // A sparse create can never claim a prepared checkout; say so rather than looking like a miss. + expect(result.timing?.preparedCheckout).toEqual({ + status: 'miss', + reason: 'sparse_checkout' + }) }) it('retires a generated sparse name when creation rollback also fails', async () => { diff --git a/src/main/ipc/worktrees-ssh-repo-owner-resolution.test.ts b/src/main/ipc/worktrees-ssh-repo-owner-resolution.test.ts index 6a398e9b02d..b3231da86f6 100644 --- a/src/main/ipc/worktrees-ssh-repo-owner-resolution.test.ts +++ b/src/main/ipc/worktrees-ssh-repo-owner-resolution.test.ts @@ -1,7 +1,11 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { GitWorktreeInfo, Worktree } from '../../shared/worktree/types' import type { ProviderRequestId } from '../../shared/detected-worktree-provider-contract' -import { LOCAL_EXECUTION_HOST_ID, toSshExecutionHostId } from '../../shared/execution-host' +import { + LOCAL_EXECUTION_HOST_ID, + toRuntimeExecutionHostId, + toSshExecutionHostId +} from '../../shared/execution-host' import { getSshProviderAuthority } from '../ssh/ssh-provider-authority' import { listWorktreesMock, @@ -443,7 +447,76 @@ describe('registerWorktreeHandlers', () => { expect(store.removeWorktreeMeta).not.toHaveBeenCalled() }) - it('refuses to retire metadata for non-SSH hosts and unowned repos', async () => { + // Runtime-host rows are exempt from gcStaleWorktreeMeta exactly as SSH ones are, so a paired + // client needs this path to ever drop them (#17776). + it('retires runtime-host metadata an authoritative scan proved gone', async () => { + const runtimeHostId = toRuntimeExecutionHostId('env-1') + const runtimeRepo = { + id: 'repo-1', + path: '/home/orca/repo', + displayName: 'repo', + badgeColor: '#000', + addedAt: 0, + executionHostId: runtimeHostId + } + const metaById: Record> = { + 'repo-1::/home/orca/deleted': makeWorktreeMeta({ hostId: runtimeHostId }), + 'repo-1::/home/orca/other-host': makeWorktreeMeta({ + hostId: toSshExecutionHostId('target-a') + }) + } + store.getRepos.mockReturnValue([runtimeRepo]) + store.getProjectHostSetups.mockReturnValue([]) + store.getAllWorktreeMeta.mockReturnValue(metaById) + store.removeWorktreeMeta.mockImplementation((worktreeId: string) => { + delete metaById[worktreeId] + }) + + const forgotten = await handlers['worktrees:forgetRemovedForExecutionHost'](null, { + repoId: runtimeRepo.id, + executionHostId: runtimeHostId, + worktreeIds: ['repo-1::/home/orca/deleted', 'repo-1::/home/orca/other-host'] + }) + + // The row stamped to another host needs that host's own scan, not this one's. + expect(forgotten).toEqual({ forgottenWorktreeIds: ['repo-1::/home/orca/deleted'] }) + expect(store.removeWorktreeMeta).toHaveBeenCalledExactlyOnceWith( + 'repo-1::/home/orca/deleted', + runtimeHostId + ) + }) + + // A repo that reaches its checkouts over SSH is not the runtime host's to condemn. The refusal + // comes from `findExactRepoOwner`: a runtime `executionHostId` beside a `connectionId` is + // contradictory ownership evidence, so no owner resolves at all. + it('refuses to retire a connection-backed repo under a runtime host id', async () => { + const runtimeHostId = toRuntimeExecutionHostId('env-1') + store.getRepos.mockReturnValue([ + { + id: 'repo-1', + path: '/home/orca/repo', + displayName: 'repo', + badgeColor: '#000', + addedAt: 0, + executionHostId: runtimeHostId, + connectionId: 'target-a' + } + ]) + store.getAllWorktreeMeta.mockReturnValue({ + 'repo-1::/home/orca/deleted': makeWorktreeMeta({ hostId: runtimeHostId }) + }) + + expect( + await handlers['worktrees:forgetRemovedForExecutionHost'](null, { + repoId: 'repo-1', + executionHostId: runtimeHostId, + worktreeIds: ['repo-1::/home/orca/deleted'] + }) + ).toEqual({ forgottenWorktreeIds: [] }) + expect(store.removeWorktreeMeta).not.toHaveBeenCalled() + }) + + it('refuses to retire metadata for non-executing hosts and unowned repos', async () => { const sshRepo = { id: 'repo-1', path: '/remote/repo-a', diff --git a/src/main/ipc/worktrees-wsl-runtime-routing.test.ts b/src/main/ipc/worktrees-wsl-runtime-routing.test.ts index cb880263d87..85a015496ed 100644 --- a/src/main/ipc/worktrees-wsl-runtime-routing.test.ts +++ b/src/main/ipc/worktrees-wsl-runtime-routing.test.ts @@ -266,21 +266,50 @@ describe('registerWorktreeHandlers', () => { } }) + const wslRoutingOptions = { cwd: '/workspace/repo', wslDistro: 'Ubuntu' } expect(gitExecFileAsyncMock).toHaveBeenCalledWith( ['check-ref-format', '--branch', 'contributor/wsl-fork'], - { cwd: '/workspace/repo', wslDistro: 'Ubuntu' } + wslRoutingOptions + ) + // Mint: `-t --no-tags` (see #17828) keeps the remote's own default off the + // wide wildcard, then `ensureRemoteTracksBranchNarrowly` rewrites it to the trailing-`*` + // form immediately after -- both routed through the same WSL project runtime. + expect(gitExecFileAsyncMock).toHaveBeenCalledWith( + [ + 'remote', + 'add', + '-t', + 'contributor/wsl-fork', + '--no-tags', + 'pr-contributor-orca', + 'git@github.com:contributor/orca.git' + ], + wslRoutingOptions ) expect(gitExecFileAsyncMock).toHaveBeenCalledWith( - ['remote', 'add', 'pr-contributor-orca', 'git@github.com:contributor/orca.git'], - { cwd: '/workspace/repo', wslDistro: 'Ubuntu' } + ['config', '--get-all', 'remote.pr-contributor-orca.fetch'], + wslRoutingOptions + ) + expect(gitExecFileAsyncMock).toHaveBeenCalledWith( + [ + 'config', + '--add', + 'remote.pr-contributor-orca.fetch', + '+refs/heads/contributor/wsl-fork*:refs/remotes/pr-contributor-orca/contributor/wsl-fork*' + ], + wslRoutingOptions + ) + expect(gitExecFileAsyncMock).toHaveBeenCalledWith( + ['config', 'remote.pr-contributor-orca.tagOpt', '--no-tags'], + wslRoutingOptions ) expect(gitExecFileAsyncMock).toHaveBeenCalledWith( [ 'fetch', 'pr-contributor-orca', - '+refs/heads/contributor/wsl-fork:refs/remotes/pr-contributor-orca/contributor/wsl-fork' + '+refs/heads/contributor/wsl-fork*:refs/remotes/pr-contributor-orca/contributor/wsl-fork*' ], - { cwd: '/workspace/repo', wslDistro: 'Ubuntu' } + wslRoutingOptions ) expect(gitExecFileAsyncMock).toHaveBeenCalledWith( ['branch', '--set-upstream-to', 'pr-contributor-orca/contributor/wsl-fork', 'wsl-fork'], diff --git a/src/main/ipc/worktrees.ts b/src/main/ipc/worktrees.ts index 5e70faa1871..3fd2fb41908 100644 --- a/src/main/ipc/worktrees.ts +++ b/src/main/ipc/worktrees.ts @@ -13,13 +13,21 @@ import { registerDetectedWorktreeHandlers } from './worktrees/listing/register-d import { registerHostCatalogHandlers } from './worktrees/listing/register-host-catalog-handlers' import { registerWorktreeCatalogHandlers } from './worktrees/listing/register-worktree-catalog-handlers' import { registerDetectedWorktreeScanInvalidation } from './worktrees/listing/register-detected-worktree-scan-invalidation' +import { registerSparseCheckoutCacheInvalidation } from './worktrees/listing/register-sparse-checkout-cache-invalidation' import { registerWorktreeMetadataHandlers } from './worktrees/metadata/register-worktree-metadata-handlers' import { registerWorktreeForgetHandlers } from './worktrees/removal/register-worktree-forget-handlers' import { registerWorktreeRemovalHandlers } from './worktrees/removal/register-worktree-removal-handlers' -import type { WorktreeIpcContext } from './worktrees/worktree-ipc-context' +import { + createWorktreeRemovalRegistry, + type WorktreeIpcContext +} from './worktrees/worktree-ipc-context' registerDetectedWorktreeScanInvalidation() +// Why not module scope like the invalidation above: this needs `mainWindow`/`store`, which only +// exist once a window is attached, and must track the current ones across re-registration. +let disposeSparseCheckoutCacheInvalidation: (() => void) | undefined + const WORKTREE_HANDLER_CHANNELS = [ 'worktrees:listAll', 'worktrees:list', @@ -61,7 +69,7 @@ export function registerWorktreeHandlers( runtime, ...(options ? { options } : {}), detectedWorktreeCancellations: createSenderScopedRequestCancellations(), - worktreeRemovalsInFlight: new Map() + worktreeRemovalsInFlight: createWorktreeRemovalRegistry() } // Remove all stale registrations before installing any replacement handler. @@ -69,6 +77,12 @@ export function registerWorktreeHandlers( ipcMain.removeHandler(channel) } + disposeSparseCheckoutCacheInvalidation?.() + disposeSparseCheckoutCacheInvalidation = registerSparseCheckoutCacheInvalidation( + mainWindow, + store + ) + registerWorktreeCatalogHandlers(context) registerHostCatalogHandlers(context) registerDetectedWorktreeHandlers(context) diff --git a/src/main/ipc/worktrees/create/register-worktree-create-handlers.ts b/src/main/ipc/worktrees/create/register-worktree-create-handlers.ts index 775a0859790..f371529963c 100644 --- a/src/main/ipc/worktrees/create/register-worktree-create-handlers.ts +++ b/src/main/ipc/worktrees/create/register-worktree-create-handlers.ts @@ -4,7 +4,10 @@ import type { CreateWorktreeResult, AdoptProvisionedRootArgs } from '../../../../shared/worktree/create-types' -import { withWorktreeSpan } from '../../../observability/instrumentation' +import { + addWorktreeCreatePhaseAttributes, + withWorktreeSpan +} from '../../../observability/instrumentation' import { workspaceSourceSchema } from '../../../../shared/telemetry-events' import type { WorkspaceSource } from '../../../../shared/telemetry-events' import { @@ -36,7 +39,7 @@ export function registerWorktreeCreateHandlers(context: WorktreeIpcContext): voi async (_event, rawArgs: CreateWorktreeArgs): Promise => { const args = normalizeLinkedWorkItemFields(rawArgs) // Why span here: parent the child git spans for the trace tree; don't attach branch name/remote URL (user content) — repo ID is the safer correlator. - return withWorktreeSpan({ stage: 'create' }, async () => { + return withWorktreeSpan({ stage: 'create' }, async (span) => { const repo = store.getRepo(args.repoId) if (!repo) { throw new Error(`Repo not found: ${args.repoId}`) @@ -74,6 +77,9 @@ export function registerWorktreeCreateHandlers(context: WorktreeIpcContext): voi throw error } finishAutomationWorkspaceProvenanceRequest(args.automationProvenanceRequest) + if (result.timing) { + addWorktreeCreatePhaseAttributes(span, result.timing) + } // Why: reaching here means create succeeded (helpers throw); skip a separate workspace_initialized (telemetry-plan.md§Deferred); never send the branch name. track('workspace_created', { diff --git a/src/main/ipc/worktrees/listing/register-host-catalog-handlers.ts b/src/main/ipc/worktrees/listing/register-host-catalog-handlers.ts index 0d47f3638c2..4467506e790 100644 --- a/src/main/ipc/worktrees/listing/register-host-catalog-handlers.ts +++ b/src/main/ipc/worktrees/listing/register-host-catalog-handlers.ts @@ -103,11 +103,21 @@ export function registerHostCatalogHandlers(context: WorktreeIpcContext): void { const requestedExecutionHostId = args?.executionHostId ?? 'ssh:' const worktreeIds = Array.isArray(args?.worktreeIds) ? args.worktreeIds : [] const parsedHost = parseExecutionHostId(requestedExecutionHostId) - if (parsedHost?.kind !== 'ssh' || worktreeIds.length === 0) { + // Runtime hosts belong here for the same reason SSH ones do: their rows are exempt from + // gcStaleWorktreeMeta, so a scan-proven removal is the only thing that ever retires them. + if ( + (parsedHost?.kind !== 'ssh' && parsedHost?.kind !== 'runtime') || + worktreeIds.length === 0 + ) { return nothingForgotten } + // No runtime arm in the check below: `findExactRepoOwner` already refuses a repo carrying both + // a runtime `executionHostId` and a `connectionId`, because `resolveRepoOwnershipEvidence` + // calls that pair contradictory and one non-owned candidate voids the whole lookup. A second + // check would be unreachable, and unreachable code on a destructive path reads as a guarantee + // it is not making. const repo = findExactRepoOwner(store, args?.repoId ?? '', requestedExecutionHostId) - if (!repo || repo.connectionId !== parsedHost.targetId) { + if (!repo || (parsedHost.kind === 'ssh' && repo.connectionId !== parsedHost.targetId)) { return nothingForgotten } // Why: a folder workspace's meta IS the workspace record, not a checkout row — gcStaleWorktreeMeta skips diff --git a/src/main/ipc/worktrees/listing/register-sparse-checkout-cache-invalidation.test.ts b/src/main/ipc/worktrees/listing/register-sparse-checkout-cache-invalidation.test.ts new file mode 100644 index 00000000000..760fa2801e4 --- /dev/null +++ b/src/main/ipc/worktrees/listing/register-sparse-checkout-cache-invalidation.test.ts @@ -0,0 +1,85 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { Repo } from '../../../../shared/repo-types' +import type { Store } from '../../../persistence/loading-store/store' +import { runWorktreeChangeInvalidators } from '../../worktree-change-invalidators' +import { registerSparseCheckoutCacheInvalidation } from './register-sparse-checkout-cache-invalidation' + +const { + clearSparseCheckoutStateCacheMock, + clearSparseCheckoutStateCacheForRepoMock, + onSparseCheckoutStateChangedMock, + notifyWorktreesChangedMock +} = vi.hoisted(() => ({ + clearSparseCheckoutStateCacheMock: vi.fn(), + clearSparseCheckoutStateCacheForRepoMock: vi.fn(), + onSparseCheckoutStateChangedMock: vi.fn(), + notifyWorktreesChangedMock: vi.fn() +})) + +vi.mock('../../../git/worktree-sparse-checkout-cache', () => ({ + clearSparseCheckoutStateCache: clearSparseCheckoutStateCacheMock, + clearSparseCheckoutStateCacheForRepo: clearSparseCheckoutStateCacheForRepoMock, + onSparseCheckoutStateChanged: onSparseCheckoutStateChangedMock +})) + +vi.mock('../../worktree-remote', () => ({ + notifyWorktreesChanged: notifyWorktreesChangedMock +})) + +function makeStore(repos: Repo[]): Store { + return { + getRepo: (id: string) => repos.find((repo) => repo.id === id), + getRepos: () => repos + } as unknown as Store +} + +const mainWindow = {} as never + +beforeEach(() => { + vi.clearAllMocks() +}) + +describe('registerSparseCheckoutCacheInvalidation', () => { + it('clears only the resolved repo`s cache when the invalidator registry fires', () => { + const store = makeStore([{ id: 'repo-1', path: '/repo-1' } as Repo]) + const dispose = registerSparseCheckoutCacheInvalidation(mainWindow, store) + try { + runWorktreeChangeInvalidators('repo-1') + expect(clearSparseCheckoutStateCacheForRepoMock).toHaveBeenCalledWith('/repo-1') + expect(clearSparseCheckoutStateCacheMock).not.toHaveBeenCalled() + } finally { + dispose() + } + }) + + it('falls back to a full clear when the repo cannot be resolved', () => { + const store = makeStore([]) + const dispose = registerSparseCheckoutCacheInvalidation(mainWindow, store) + try { + runWorktreeChangeInvalidators('unknown-repo') + expect(clearSparseCheckoutStateCacheMock).toHaveBeenCalledTimes(1) + expect(clearSparseCheckoutStateCacheForRepoMock).not.toHaveBeenCalled() + } finally { + dispose() + } + }) + + it('forwards a background stale-while-revalidate flip to the shared worktrees-changed notification', () => { + const store = makeStore([{ id: 'repo-1', path: '/repo-1' } as Repo]) + const dispose = registerSparseCheckoutCacheInvalidation(mainWindow, store) + try { + const listener = onSparseCheckoutStateChangedMock.mock.calls.at(-1)?.[0] + listener?.('/repo-1', '/repo-1/wt-a', true) + expect(notifyWorktreesChangedMock).toHaveBeenCalledWith(mainWindow, 'repo-1') + } finally { + dispose() + } + }) + + it('drops the change listener on disposal so a stale window/store stops receiving flips', () => { + const store = makeStore([{ id: 'repo-1', path: '/repo-1' } as Repo]) + const dispose = registerSparseCheckoutCacheInvalidation(mainWindow, store) + dispose() + expect(onSparseCheckoutStateChangedMock).toHaveBeenLastCalledWith(undefined) + }) +}) diff --git a/src/main/ipc/worktrees/listing/register-sparse-checkout-cache-invalidation.ts b/src/main/ipc/worktrees/listing/register-sparse-checkout-cache-invalidation.ts new file mode 100644 index 00000000000..3c63ada185a --- /dev/null +++ b/src/main/ipc/worktrees/listing/register-sparse-checkout-cache-invalidation.ts @@ -0,0 +1,41 @@ +import type { BrowserWindow } from 'electron' +import type { Store } from '../../../persistence/loading-store/store' +import { + clearSparseCheckoutStateCache, + clearSparseCheckoutStateCacheForRepo, + onSparseCheckoutStateChanged +} from '../../../git/worktree-sparse-checkout-cache' +import { areWorktreePathsEqual } from '../../../git/worktree-path-comparison' +import { registerWorktreeChangeInvalidator } from '../../worktree-change-invalidators' +import { notifyWorktreesChanged } from '../../worktree-remote' + +/** + * Scope worktree-change invalidation to the affected repo (falling back to a full clear when the + * repo can't be resolved, e.g. it was already removed from the store), and forward a background + * stale-while-revalidate flip to the same worktrees-changed notification other mutations use. + */ +export function registerSparseCheckoutCacheInvalidation( + mainWindow: BrowserWindow, + store: Store +): () => void { + const unregisterInvalidator = registerWorktreeChangeInvalidator((repoId) => { + const repoPath = store.getRepo(repoId)?.path + if (repoPath) { + clearSparseCheckoutStateCacheForRepo(repoPath) + } else { + clearSparseCheckoutStateCache() + } + }) + onSparseCheckoutStateChanged((repoPath) => { + const repo = store + .getRepos() + .find((candidate) => areWorktreePathsEqual(candidate.path, repoPath)) + if (repo) { + notifyWorktreesChanged(mainWindow, repo.id) + } + }) + return () => { + unregisterInvalidator() + onSparseCheckoutStateChanged(undefined) + } +} diff --git a/src/main/ipc/worktrees/worktree-ipc-context.ts b/src/main/ipc/worktrees/worktree-ipc-context.ts index 5455a71d06e..153e4b723ec 100644 --- a/src/main/ipc/worktrees/worktree-ipc-context.ts +++ b/src/main/ipc/worktrees/worktree-ipc-context.ts @@ -14,3 +14,18 @@ export type WorktreeIpcContext = { detectedWorktreeCancellations: SenderScopedRequestCancellations worktreeRemovalsInFlight: Map } + +// Why: removal and forget both delete refs, and a ref deletion has to take the +// `packed-refs` lock. Idle ref maintenance needs a process-wide view of that +// registry so it never packs while one is running. +let activeWorktreeRemovals: ReadonlyMap | null = null + +export function createWorktreeRemovalRegistry(): Map { + const registry = new Map() + activeWorktreeRemovals = registry + return registry +} + +export function hasWorktreeRemovalsInFlight(): boolean { + return (activeWorktreeRemovals?.size ?? 0) > 0 +} diff --git a/src/main/macos-press-and-hold-default.test.ts b/src/main/macos-press-and-hold-default.test.ts index c30aed82d10..b1535b50408 100644 --- a/src/main/macos-press-and-hold-default.test.ts +++ b/src/main/macos-press-and-hold-default.test.ts @@ -267,20 +267,26 @@ describe('readBundleIdentifierFromExecutablePath', () => { }) describe('startup wiring', () => { - const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') + const source = readFileSync( + join(process.cwd(), 'src/main/startup/main-process-preflight.ts'), + 'utf8' + ) + const entrySource = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') it('runs before app.whenReady(), which is the last point AppKit could still see it', () => { const callIndex = source.indexOf( 'applyMacPressAndHoldDefaultAtStartup(getCanonicalUserDataPath())' ) const initDataPathIndex = source.indexOf('initDataPath()') - const readyIndex = source.indexOf('app.whenReady().then(') + const readyIndex = entrySource.indexOf('void app.whenReady().then(async () => {') + const preflightCall = entrySource.indexOf('runMainProcessPreflight({') expect(callIndex).toBeGreaterThanOrEqual(0) expect(readyIndex).toBeGreaterThanOrEqual(0) + expect(preflightCall).toBeGreaterThanOrEqual(0) // Why after initDataPath: the record lives beside orca-data.json, and the canonical userData // path is only captured there. expect(callIndex).toBeGreaterThan(initDataPathIndex) - expect(callIndex).toBeLessThan(readyIndex) + expect(preflightCall).toBeLessThan(readyIndex) }) }) diff --git a/src/main/observability/instrumentation.test.ts b/src/main/observability/instrumentation.test.ts index 2f7d1da05dd..452b5cd155c 100644 --- a/src/main/observability/instrumentation.test.ts +++ b/src/main/observability/instrumentation.test.ts @@ -3,6 +3,7 @@ import { _resetTracerForTests, setActiveSink, type TracerSink } from './tracer' import { _gitSpanSamplingBucketCountForTests, _resetGitSpanSamplingForTests, + addWorktreeCreatePhaseAttributes, withGitSpan } from './instrumentation' @@ -167,3 +168,84 @@ describe('withGitSpan sampling', () => { expect(_gitSpanSamplingBucketCountForTests()).toBe(1) }) }) + +describe('addWorktreeCreatePhaseAttributes', () => { + function capture(): { + attributes: Record + span: Parameters[0] + } { + const attributes: Record = {} + const span = { + setAttribute: (key: string, value: unknown) => { + attributes[key] = value + } + } as unknown as Parameters[0] + return { attributes, span } + } + + it('counts concurrent phases once when measuring unattributed time', () => { + const { attributes, span } = capture() + // Create resolves shared directories and .worktreeinclude concurrently; summing their + // durations would claim 400ms of coverage for a 200ms window. + addWorktreeCreatePhaseAttributes(span, { + totalDurationMs: 1000, + phases: [ + { phase: 'resolve_shared_directories', startedAtMs: 100, durationMs: 200 }, + { phase: 'resolve_worktreeinclude', startedAtMs: 150, durationMs: 150 } + ] + }) + + expect(attributes['worktree.create.phase.resolve_shared_directories_ms']).toBe(200) + expect(attributes['worktree.create.phase.resolve_worktreeinclude_ms']).toBe(150) + // Covered wall clock is 100..300, so 800ms is genuinely unaccounted for. + expect(attributes['worktree.create.unattributed_ms']).toBe(800) + }) + + it('sums disjoint phases and never reports negative unattributed time', () => { + const { attributes, span } = capture() + addWorktreeCreatePhaseAttributes(span, { + totalDurationMs: 500, + phases: [ + { phase: 'resolve_name', startedAtMs: 0, durationMs: 100 }, + { phase: 'git_worktree_add', startedAtMs: 300, durationMs: 200 } + ] + }) + + expect(attributes['worktree.create.total_ms']).toBe(500) + expect(attributes['worktree.create.unattributed_ms']).toBe(200) + }) + + it('records a prepared-checkout hit and whether it had to be retargeted', () => { + const { attributes, span } = capture() + addWorktreeCreatePhaseAttributes(span, { + totalDurationMs: 900, + phases: [{ phase: 'git_worktree_add', startedAtMs: 0, durationMs: 400 }], + preparedCheckout: { status: 'hit', retargeted: true } + }) + + expect(attributes['worktree.create.prepared_checkout']).toBe('hit') + expect(attributes['worktree.create.prepared_checkout_retargeted']).toBe(true) + expect(attributes['worktree.create.prepared_checkout_miss']).toBeUndefined() + expect(attributes['worktree.create.unattributed_ms']).toBe(500) + }) + + it('records why a create missed the prepared checkout', () => { + const { attributes, span } = capture() + addWorktreeCreatePhaseAttributes(span, { + totalDurationMs: 8_000, + phases: [], + preparedCheckout: { status: 'miss', reason: 'base_mismatch' } + }) + + expect(attributes['worktree.create.prepared_checkout']).toBe('miss') + expect(attributes['worktree.create.prepared_checkout_miss']).toBe('base_mismatch') + expect(attributes['worktree.create.prepared_checkout_retargeted']).toBeUndefined() + }) + + it('stays silent on paths that never consult the prepared checkout', () => { + const { attributes, span } = capture() + addWorktreeCreatePhaseAttributes(span, { totalDurationMs: 10, phases: [] }) + + expect(attributes['worktree.create.prepared_checkout']).toBeUndefined() + }) +}) diff --git a/src/main/observability/instrumentation.ts b/src/main/observability/instrumentation.ts index bab57b74f64..8569ab6ef04 100644 --- a/src/main/observability/instrumentation.ts +++ b/src/main/observability/instrumentation.ts @@ -21,6 +21,7 @@ // itself becomes a `noopSpan` that swallows all calls — call sites do not // need to branch on whether tracing is on. +import type { PreparedCheckoutOutcome } from '../../shared/worktree/create-types' import { startSpan, withSpan, type ActiveSpan } from './tracer' const GIT_FAST_SUCCESS_THRESHOLD_MS = 250 @@ -202,10 +203,11 @@ export type WorktreeSpanArgs = { readonly path?: string } -/** Wrap a worktree-setup phase in a `worktree.` span. */ +/** Wrap a worktree-setup phase in a `worktree.` span. The callback receives the span so a + * create can attach its own phase breakdown; the git children alone leave the waits invisible. */ export async function withWorktreeSpan( meta: WorktreeSpanArgs, - fn: () => Promise + fn: (span: ActiveSpan) => Promise ): Promise { return withSpan( `worktree.${meta.stage}`, @@ -214,12 +216,80 @@ export async function withWorktreeSpan( if (meta.path) { span.setAttribute('worktree.path', meta.path) } - return await fn() + return await fn(span) }, { attributes: { kind: 'worktree' } } ) } +type WorktreeCreatePhaseTiming = { + readonly phase: string + readonly startedAtMs: number + readonly durationMs: number +} + +/** Wall-clock span covered by at least one phase. Create runs some phases concurrently, so summing + * durations double-counts and would report overlap as coverage the phases never had. */ +function measuredWallClockMs(phases: readonly WorktreePhaseInterval[]): number { + const intervals = [...phases] + .map((phase) => [phase.startedAtMs, phase.startedAtMs + phase.durationMs] as const) + .sort((left, right) => left[0] - right[0]) + let covered = 0 + let openedAt: number | null = null + let closesAt = 0 + for (const [start, end] of intervals) { + if (openedAt === null) { + openedAt = start + closesAt = end + continue + } + if (start <= closesAt) { + closesAt = Math.max(closesAt, end) + continue + } + covered += closesAt - openedAt + openedAt = start + closesAt = end + } + return openedAt === null ? 0 : covered + (closesAt - openedAt) +} + +type WorktreePhaseInterval = Pick + +/** Records a create's phase breakdown on its span. Phase names are already a closed vocabulary in + * the recorder, so they are safe to key on; nothing here carries a branch name or a path. */ +export function addWorktreeCreatePhaseAttributes( + span: ActiveSpan, + timing: { + totalDurationMs: number + phases: readonly WorktreeCreatePhaseTiming[] + preparedCheckout?: PreparedCheckoutOutcome + } +): void { + span.setAttribute('worktree.create.total_ms', Math.round(timing.totalDurationMs)) + if (timing.preparedCheckout) { + span.setAttribute('worktree.create.prepared_checkout', timing.preparedCheckout.status) + if (timing.preparedCheckout.status === 'hit') { + // A retargeted hit still pays a reset, so it must not be read as a free hit. + span.setAttribute( + 'worktree.create.prepared_checkout_retargeted', + timing.preparedCheckout.retargeted + ) + } else { + span.setAttribute('worktree.create.prepared_checkout_miss', timing.preparedCheckout.reason) + } + } + for (const phase of timing.phases) { + span.setAttribute(`worktree.create.phase.${phase.phase}_ms`, Math.round(phase.durationMs)) + } + // What the phases do not cover is the number that matters when create feels slow for no visible + // reason, so name it rather than leaving it to subtraction. + span.setAttribute( + 'worktree.create.unattributed_ms', + Math.max(0, Math.round(timing.totalDurationMs - measuredWallClockMs(timing.phases))) + ) +} + /** Closed set so a typo can't silently mint an orphan span name. */ export type WorktreeRemoveStage = | 'archive_hook' diff --git a/src/main/orca-profiles/profile-project-worktree-identity.ts b/src/main/orca-profiles/profile-project-worktree-identity.ts index 1586a0e0120..8cf58dd1bd5 100644 --- a/src/main/orca-profiles/profile-project-worktree-identity.ts +++ b/src/main/orca-profiles/profile-project-worktree-identity.ts @@ -66,6 +66,24 @@ export function rekeyOwnerKey( return null } +/** + * Every worktree locator an owner key could name. + * + * Two readings, because one key can be both: with a repo literally named `worktree`, + * `worktree::/p` is a `::` locator AND parses as a `worktree:` workspace key naming + * repo `` (empty). `ownerKeyBelongsToRepo` accepts either, so a caller that reasons about a key + * without a repo id in hand has to consider both or it will disagree with the predicate. + */ +export function ownerKeyWorktreeIds(ownerKey: string): string[] { + const rawOwnerKey = isWorktreeHostIdentity(ownerKey) + ? getWorktreeIdFromHostIdentity(ownerKey) + : ownerKey + const scope = parseWorkspaceKey(ownerKey) + return scope?.type === 'worktree' && scope.worktreeId !== rawOwnerKey + ? [rawOwnerKey, scope.worktreeId] + : [rawOwnerKey] +} + export function ownerKeyBelongsToRepo(ownerKey: string, repoId: string): boolean { const rawOwnerKey = isWorktreeHostIdentity(ownerKey) ? getWorktreeIdFromHostIdentity(ownerKey) diff --git a/src/main/persistence-cohort-and-identity-migration.test.ts b/src/main/persistence-cohort-and-identity-migration.test.ts index 4081672c821..a894b8a4c63 100644 --- a/src/main/persistence-cohort-and-identity-migration.test.ts +++ b/src/main/persistence-cohort-and-identity-migration.test.ts @@ -397,6 +397,8 @@ describe('Store.migrateWorktreeIdentity', () => { it('moves persisted mobile selections across reloads', async () => { const store = await createStore() + // Registered on purpose: rows owned by an unregistered repo id are swept as orphans on load. + store.addRepo(makeRepo({ id: 'repo1', path: '/repo1' })) store.setMobileClientTabSelections({ 'device-a': { [OLD]: { activeTabId: 'tab-1', activeGroupId: null, activeTabIdByGroupId: {} } diff --git a/src/main/persistence-cross-host-pane-identity.test.ts b/src/main/persistence-cross-host-pane-identity.test.ts index 2b6a70da934..479d3727837 100644 --- a/src/main/persistence-cross-host-pane-identity.test.ts +++ b/src/main/persistence-cross-host-pane-identity.test.ts @@ -10,6 +10,7 @@ import { createStore, writeDataFile, readDataFile, + makeRepo, makeTerminalTab } from './persistence-test-harness' @@ -53,6 +54,11 @@ describe('cross-host pane identity migration', () => { it('refuses hostless alias and acknowledgement rewrites for a tab id two partitions share', async () => { writeDataFile({ schemaVersion: 1, + // Registered on purpose: rows owned by an unregistered repo id are swept as orphans on load. + repos: [ + makeRepo({ id: 'repo-local', path: '/repo-local' }), + makeRepo({ id: 'repo-a', path: '/repo-a' }) + ], workspaceSession: makeLegacyPaneSession('repo-local', 'local-pty'), workspaceSessionsByHostId: { 'ssh:host-a': makeLegacyPaneSession('repo-a', 'pty-a') diff --git a/src/main/persistence-deregistered-repo-residue.test.ts b/src/main/persistence-deregistered-repo-residue.test.ts new file mode 100644 index 00000000000..3a7a3372b3c --- /dev/null +++ b/src/main/persistence-deregistered-repo-residue.test.ts @@ -0,0 +1,240 @@ +// Why this file exists: deregistering a project used to strand every row it owned. No sweeper could +// reach them -- the missing-directory prune is gated on the repo still being registered, and a +// paired client's mirror of a remote host's rows is keyed by ids that client never registers, so the +// owning host's removal never reached it (#17776). +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { rmSync, mkdtempSync } from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { getDefaultWorkspaceSession } from '../shared/constants' +import { composeWorktreeHostIdentity } from '../shared/worktree/host-qualified-identity' +import { folderWorkspaceKey, worktreeWorkspaceKey } from '../shared/workspace-scope' +import type { PersistedState } from '../shared/persisted-state-types' +import { + testState, + createStore, + writeDataFile, + readDataFile, + makeRepo, + makeTerminalTab +} from './persistence-test-harness' + +vi.mock('./ssh/ssh-config-parser', () => ({ + loadUserSshConfig: vi.fn(), + sshConfigHostsToTargets: vi.fn() +})) + +vi.mock('electron', () => ({ + app: { getPath: () => testState.dir }, + safeStorage: { isEncryptionAvailable: () => false } +})) + +vi.mock('./telemetry/client', () => ({ track: vi.fn() })) +vi.mock('./telemetry/cohort-classifier', () => ({ getCohortAtEmit: vi.fn().mockReturnValue({}) })) + +const LIVE_REPO = 'live-repo' +const GONE_REPO = 'gone-repo' +const LIVE_WORKTREE = `${LIVE_REPO}::/workspace/live` +const GONE_WORKTREE = `${GONE_REPO}::/workspace/orphan` +const RUNTIME_HOST = 'runtime:env-a' + +const sleepingAgentFor = (worktreeId: string, tabId = 'tab-1') => ({ + [`${tabId}:leaf-1`]: { + paneKey: `${tabId}:leaf-1`, + tabId, + worktreeId, + agent: 'codex' as const, + providerSession: { key: 'session_id' as const, id: 'sess-1' }, + prompt: 'sleeping', + state: 'waiting' as const, + capturedAt: 1, + updatedAt: 1, + origin: 'worktree-sleep' as const + } +}) + +const sessionFor = (worktreeId: string, tabId = 'tab-1') => ({ + ...getDefaultWorkspaceSession(), + tabsByWorktree: { + [worktreeId]: [makeTerminalTab({ id: tabId, worktreeId })] + }, + activeTabTypeByWorktree: { [worktreeId]: 'terminal' as const }, + lastVisitedAtByWorktreeId: { [worktreeId]: 123 }, + // The residue `profile-project-session-field-disposition` flags as leaking on repo removal. + sleepingAgentSessionsByPaneKey: sleepingAgentFor(worktreeId, tabId) +}) + +describe('deregistered repo residue', () => { + beforeEach(() => { + testState.dir = mkdtempSync(join(tmpdir(), 'orca-orphan-sweep-')) + }) + + afterEach(() => { + rmSync(testState.dir, { recursive: true, force: true }) + }) + + it('drops metadata, identity rows and sessions owned by an unregistered repo id', async () => { + const seed = await createStore() + seed.addRepo(makeRepo({ id: LIVE_REPO, path: '/workspace/live' })) + seed.addRepo(makeRepo({ id: GONE_REPO, path: '/workspace/orphan' })) + seed.setWorktreeMetaForHost(LIVE_WORKTREE, 'local', { displayName: 'Live' }) + seed.setWorktreeMetaForHost(GONE_WORKTREE, 'local', { displayName: 'Orphan' }) + seed.setWorkspaceSession(sessionFor(GONE_WORKTREE), 'local') + seed.flush() + + // Deregister by hand: the point is that a row can outlive its repo however that happened. + const persisted = readDataFile() as PersistedState + persisted.repos = persisted.repos.filter((repo) => repo.id !== GONE_REPO) + writeDataFile(persisted) + + const reloaded = await createStore() + reloaded.flush() + const swept = readDataFile() as PersistedState + + expect(Object.keys(swept.worktreeMeta)).toEqual([LIVE_WORKTREE]) + expect(swept.worktreeIdentityAliases).not.toHaveProperty( + composeWorktreeHostIdentity('local', GONE_WORKTREE) + ) + expect(Object.keys(swept.worktreeMetaByIdentity ?? {})).toHaveLength(1) + const session = swept.workspaceSession + expect(session.tabsByWorktree).toEqual({}) + expect(session.lastVisitedAtByWorktreeId).toEqual({}) + expect(session.activeTabTypeByWorktree).toEqual({}) + expect(session.sleepingAgentSessionsByPaneKey ?? {}).toEqual({}) + }) + + it("sweeps a remote host's session partition the owning host's removal can never reach", async () => { + writeDataFile({ + schemaVersion: 1, + repos: [makeRepo({ id: LIVE_REPO, path: '/workspace/live' })], + worktreeMeta: {}, + workspaceSessionsByHostId: { + [RUNTIME_HOST]: sessionFor(GONE_WORKTREE) + } + }) + + const store = await createStore() + store.flush() + + const partition = store.getWorkspaceSession(RUNTIME_HOST) + expect(partition.tabsByWorktree).toEqual({}) + expect(partition.activeTabTypeByWorktree).toEqual({}) + }) + + it('keeps rows for every registered repo, on any execution host', async () => { + const remoteWorktree = `${LIVE_REPO}::/home/user/remote` + writeDataFile({ + schemaVersion: 1, + repos: [makeRepo({ id: LIVE_REPO, path: '/home/user/live', executionHostId: RUNTIME_HOST })], + worktreeMeta: { [remoteWorktree]: { hostId: RUNTIME_HOST, status: 'active' } }, + workspaceSessionsByHostId: { [RUNTIME_HOST]: sessionFor(remoteWorktree) } + }) + + const store = await createStore() + + expect(store.getWorktreeMeta(remoteWorktree)).toBeDefined() + const partition = store.getWorkspaceSession(RUNTIME_HOST) + expect(partition.tabsByWorktree[remoteWorktree]).toHaveLength(1) + // Also proves the sleeping-agent fixture is well-formed, so the sweep assertions above bite. + expect(Object.keys(partition.sleepingAgentSessionsByPaneKey ?? {})).toHaveLength(1) + }) + + it('leaves folder-workspace session rows alone: their keys name no repo', async () => { + const workspaceKey = folderWorkspaceKey('folder-1') + writeDataFile({ + schemaVersion: 1, + repos: [], + worktreeMeta: {}, + workspaceSession: { + ...getDefaultWorkspaceSession(), + lastVisitedAtByWorktreeId: { [workspaceKey]: 7 } + } + }) + + const store = await createStore() + + expect(store.getWorkspaceSession('local').lastVisitedAtByWorktreeId).toEqual({ + [workspaceKey]: 7 + }) + }) + + // Regression: the pane-keyed records are pruned by the worktreeId they name, not by their own key, + // so an orphan whose ONLY residue is a sleeping agent survived -- and re-seeded the sweep on every + // launch, so the store never self-cleared and every load scheduled another save. + it("drops a sleeping agent that is the orphan repo's only residue, and self-clears", async () => { + writeDataFile({ + schemaVersion: 1, + repos: [makeRepo({ id: LIVE_REPO, path: '/workspace/live' })], + worktreeMeta: {}, + workspaceSession: { + ...getDefaultWorkspaceSession(), + sleepingAgentSessionsByPaneKey: sleepingAgentFor(GONE_WORKTREE) + } + }) + + const store = await createStore() + store.flush() + expect(store.getWorkspaceSession('local').sleepingAgentSessionsByPaneKey ?? {}).toEqual({}) + // On disk, not just in memory: if the flush had not persisted the cleanup, the next load would + // silently redo it and the self-clearing assertion below would pass without meaning anything. + const persisted = readDataFile() as PersistedState + expect(persisted.workspaceSession.sleepingAgentSessionsByPaneKey ?? {}).toEqual({}) + + // Self-clearing: with the residue gone nothing re-seeds the orphan id, so the next launch has + // no work. Before the fix this stayed non-empty forever and every load scheduled another save. + const reloaded = await createStore() + expect(reloaded.sweepDeregisteredRepoResidue()).toEqual([]) + }) + + // The session scalars are pruned by bespoke rules, not by owner key, so no owner-key loop reaches + // them. Each has to be able to seed the sweep on its own or an orphan named only there is stuck. + it.each([ + { label: 'activeWorktreeId', session: { activeWorktreeId: GONE_WORKTREE } }, + // Canonical `worktree:` form, which needs unwrapping before the repo id is visible. + { + label: 'activeWorkspaceKey', + session: { activeWorkspaceKey: worktreeWorkspaceKey(GONE_WORKTREE) } + }, + { + label: 'activeWorktreeIdsOnShutdown', + session: { activeWorktreeIdsOnShutdown: [GONE_WORKTREE] } + } + ])("clears $label when it is the orphan repo's only residue", async ({ session }) => { + writeDataFile({ + schemaVersion: 1, + repos: [makeRepo({ id: LIVE_REPO, path: '/workspace/live' })], + worktreeMeta: {}, + workspaceSessionsByHostId: { + [RUNTIME_HOST]: { ...getDefaultWorkspaceSession(), ...session } + } + }) + + const store = await createStore() + store.flush() + + const partition = store.getWorkspaceSession(RUNTIME_HOST) + expect(partition.activeWorktreeId ?? null).toBeNull() + expect(partition.activeWorkspaceKey ?? null).toBeNull() + expect(partition.activeWorktreeIdsOnShutdown ?? []).toEqual([]) + const reloaded = await createStore() + expect(reloaded.sweepDeregisteredRepoResidue()).toEqual([]) + }) + + // Why: a sweep that dirtied every launch would rewrite the profile forever and mask real changes. + it('leaves a profile with no orphans byte-identical across reloads', async () => { + const seed = await createStore() + seed.addRepo(makeRepo({ id: LIVE_REPO, path: '/workspace/live' })) + seed.setWorktreeMetaForHost(LIVE_WORKTREE, 'local', { displayName: 'Live' }) + seed.setWorkspaceSession(sessionFor(LIVE_WORKTREE), 'local') + seed.flush() + + const canonicalizing = await createStore() + canonicalizing.flush() + const canonical = JSON.stringify(readDataFile()) + + const reloaded = await createStore() + reloaded.flush() + + expect(JSON.stringify(readDataFile())).toBe(canonical) + }) +}) diff --git a/src/main/persistence-host-partitioned-sessions.test.ts b/src/main/persistence-host-partitioned-sessions.test.ts index 023737ed386..aa2e46e52fd 100644 --- a/src/main/persistence-host-partitioned-sessions.test.ts +++ b/src/main/persistence-host-partitioned-sessions.test.ts @@ -123,6 +123,9 @@ describe('Store host-partitioned workspace sessions', () => { } }) + // Registered on purpose: rows owned by an unregistered repo id are swept as orphans on load. + const makeRepos = (...repoIds: string[]) => repoIds.map((id) => makeRepo({ id, path: `/${id}` })) + it('migrates a legacy workspaceSession blob into the local partition', async () => { writeDataFile({ schemaVersion: 1, @@ -194,6 +197,7 @@ describe('Store host-partitioned workspace sessions', () => { writeDataFile({ schemaVersion: 1, workspaceSession: makeHostSession('local-repo'), + repos: makeRepos('repo-ssh'), workspaceSessionsByHostId: { 'ssh:ssh-1': makeLegacyPaneHostSession('repo-ssh', 'remote-pty') }, @@ -224,6 +228,7 @@ describe('Store host-partitioned workspace sessions', () => { writeDataFile({ schemaVersion: 1, workspaceSession: makeHostSession('local-repo'), + repos: makeRepos('repo-a', 'repo-b'), workspaceSessionsByHostId: { 'ssh:host-a': makeLegacyPaneHostSession('repo-a', 'pty-a'), 'ssh:host-b': makeLegacyPaneHostSession('repo-b', 'pty-b') @@ -488,6 +493,7 @@ describe('Store host-partitioned workspace sessions', () => { it('removes one orphaned worktree with a host-scoped topology fence', async () => { const store = await createStore() + store.addRepo(makeRepo({ id: 'repo-gone', path: '/repo-gone' })) const worktreeId = 'repo-gone::/workspace/stale' const session = { ...makeHostSession('repo-gone'), @@ -728,6 +734,7 @@ describe('Store host-partitioned workspace sessions', () => { const worktreeId = 'repo-1::/worktree' writeDataFile({ schemaVersion: 1, + repos: makeRepos('repo-1'), workspaceSessionsByHostId: { 'runtime:good': makeHostSession('good-repo'), // activeRepoId must be string|null; a number fails the zod parse. @@ -753,6 +760,7 @@ describe('Store host-partitioned workspace sessions', () => { const worktreeId = 'repo-1::/worktree' writeDataFile({ schemaVersion: 1, + repos: makeRepos('repo-1'), workspaceSession: { ...makeHostSession('local-repo'), // A projected/truncated write can leave a top-level field the wrong type; @@ -813,6 +821,7 @@ describe('Store host-partitioned workspace sessions', () => { const worktreeId = 'repo-1::/worktree' const profile = await canonicalize({ schemaVersion: 1, + repos: makeRepos('repo-1'), workspaceSession: { ...makeHostSession('local-repo'), tabsByWorktree: { [worktreeId]: [makeTerminalTab({ id: 'tab-keep', worktreeId })] } @@ -845,6 +854,7 @@ describe('Store host-partitioned workspace sessions', () => { const worktreeId = 'repo-1::/worktree' const profile = await canonicalize({ schemaVersion: 1, + repos: makeRepos('repo-1'), workspaceSessionsByHostId: { 'runtime:env-a': { ...makeHostSession('runtime-repo'), diff --git a/src/main/persistence-initial-load.test.ts b/src/main/persistence-initial-load.test.ts index 6d0c0f11d5e..934ab44e1cb 100644 --- a/src/main/persistence-initial-load.test.ts +++ b/src/main/persistence-initial-load.test.ts @@ -150,6 +150,8 @@ describe('Store', () => { it('does not restore a terminal tab after its durable close flush returns', async () => { const store = await createStore() + // Registered on purpose: rows owned by an unregistered repo id are swept as orphans on load. + store.addRepo(makeRepo({ id: 'repo-1', path: '/repo-1' })) const worktreeId = 'repo-1::/tmp/worktree-1' const tabId = 'terminal-1' const session: WorkspaceSessionState = { diff --git a/src/main/persistence-native-chat-tab-view-mode.test.ts b/src/main/persistence-native-chat-tab-view-mode.test.ts index 3e3544c7495..9bcaab15494 100644 --- a/src/main/persistence-native-chat-tab-view-mode.test.ts +++ b/src/main/persistence-native-chat-tab-view-mode.test.ts @@ -58,7 +58,7 @@ describe('Store native-chat tab viewMode persistence', () => { const WORKTREE = 'repo1::/worktree' writeDataFile({ schemaVersion: 1, - repos: [makeRepo()], + repos: [makeRepo({ id: 'repo1', path: '/repo1' })], worktreeMeta: {}, settings: {}, ui: {}, diff --git a/src/main/persistence-repo-lifecycle.test.ts b/src/main/persistence-repo-lifecycle.test.ts index 1653f78297e..66f3fc2c32e 100644 --- a/src/main/persistence-repo-lifecycle.test.ts +++ b/src/main/persistence-repo-lifecycle.test.ts @@ -737,7 +737,10 @@ describe('Store', () => { it('reassignSshTargetId persists a worktree-meta-only re-point (no matching repo)', async () => { const store = await createStore() - // A meta on the old SSH host with no repo row — the re-point must still be persisted, not memory-only. + // A meta on the old SSH host with no repo row for that host — the re-point must still be + // persisted, not memory-only. The repo id stays registered so the load-time orphan sweep, + // which only reads repo ids, leaves the row alone. + store.addRepo(makeRepo({ id: 'r1', path: '/r1' })) store.setWorktreeMeta('r1::/remote/wt', { displayName: 'wt', hostId: 'ssh:ssh-old' }) const repoIds = store.reassignSshTargetId('ssh-old', 'ssh-new') @@ -787,6 +790,7 @@ describe('Store', () => { it('reassignSshTargetId re-keys a session partition stored under the old ssh host id', async () => { const store = await createStore() + store.addRepo(makeRepo({ id: 'r1', path: '/r1' })) store.setWorkspaceSession( { activeRepoId: null, diff --git a/src/main/persistence-settings-update.test.ts b/src/main/persistence-settings-update.test.ts index dc3a3c7ebb5..9af63ca7ccd 100644 --- a/src/main/persistence-settings-update.test.ts +++ b/src/main/persistence-settings-update.test.ts @@ -708,7 +708,7 @@ describe('Store', () => { } writeDataFile({ schemaVersion: 1, - repos: [makeRepo()], + repos: [makeRepo({ id: 'repo1', path: '/repo1' })], worktreeMeta: { 'repo1::/worktree-a': { status: 'active' }, 'repo1::/worktree-b': { status: 'active' } diff --git a/src/main/persistence-ssh-targets-and-pane-keys.test.ts b/src/main/persistence-ssh-targets-and-pane-keys.test.ts index 6c186ade077..c11ecbf0bc2 100644 --- a/src/main/persistence-ssh-targets-and-pane-keys.test.ts +++ b/src/main/persistence-ssh-targets-and-pane-keys.test.ts @@ -346,7 +346,7 @@ describe('Store', () => { const acknowledgedAt = 1_700_000_000_000 writeDataFile({ schemaVersion: 1, - repos: [makeRepo()], + repos: [makeRepo({ id: 'repo1', path: '/repo1' })], worktreeMeta: {}, settings: {}, ui: { @@ -408,7 +408,7 @@ describe('Store', () => { writeDataFile({ schemaVersion: 1, - repos: [makeRepo()], + repos: [makeRepo({ id: 'repo1', path: '/repo1' })], worktreeMeta: {}, settings: {}, ui: { diff --git a/src/main/persistence-worktree-lineage-and-backups.test.ts b/src/main/persistence-worktree-lineage-and-backups.test.ts index ef5e83745be..372e8b0e33b 100644 --- a/src/main/persistence-worktree-lineage-and-backups.test.ts +++ b/src/main/persistence-worktree-lineage-and-backups.test.ts @@ -166,6 +166,8 @@ describe('Store', () => { describe('mobileClientTabSelectionsByDeviceId', () => { it('persists device tab selections across reloads and drops malformed payloads', async () => { const store = await createStore() + // Registered on purpose: rows owned by an unregistered repo id are swept as orphans on load. + store.addRepo(makeRepo({ id: 'repo-1', path: '/repo-1' })) store.setMobileClientTabSelections({ 'device-a': { 'repo-1::/tmp/wt': { activeTabId: 'tab-1', activeGroupId: 'g1', activeTabIdByGroupId: {} } @@ -188,6 +190,7 @@ describe('Store', () => { it('prunes selections for a removed repo worktree', async () => { const store = await createStore() store.addRepo(makeRepo()) + store.addRepo(makeRepo({ id: 'other-repo', path: '/other-repo' })) store.setMobileClientTabSelections({ 'device-a': { 'r1::/tmp/wt': { diff --git a/src/main/persistence/loading-store/repo-lifecycle-operations.ts b/src/main/persistence/loading-store/repo-lifecycle-operations.ts index 095090ee1ca..c7bdbd9de37 100644 --- a/src/main/persistence/loading-store/repo-lifecycle-operations.ts +++ b/src/main/persistence/loading-store/repo-lifecycle-operations.ts @@ -12,10 +12,13 @@ import { import { mergeProjectHostSetupCompatibilityState } from '../tracking-repos/project-host-compatibility' import { RepoOrderPersistenceOperations } from '../tracking-repos/repo-order-operations' import { pruneWorktreeStateForRepo as pruneWorktreeStateForRepoOperation } from '../tracking-repos/repo-worktree-pruning' +import { collectDeregisteredRepoIds } from '../tracking-repos/deregistered-repo-residue' import { hydrateRepo as hydrateRepoOperation } from '../tracking-repos/repo-hydration' import { RepoUpdatePersistenceOperations } from '../tracking-repos/repo-update-operations' import { ProjectHostSetupPersistenceOperations } from '../tracking-repos/project-host-setup-update' import { bumpLocalWorktreeScanGeneration } from '../../local-worktree-scan-generation' +import type { PersistedState } from '../../../shared/persisted-state-types' +import { getRepoIdFromWorktreeId } from '../../../shared/worktree/id' import type { StoreRuntimeState } from './store-runtime-state' import type { WriteSchedulingOperations } from './write-scheduling' @@ -129,6 +132,33 @@ export class RepoLifecycleOperations { scheduleSave(this[repoLifecycleOperationsContext].scheduling) } + /** + * Drop every persisted row owned by a repo id that is no longer registered. + * + * Runs at load because no removal path can: `removeProject` only fires while the repo is still in + * `state.repos`, and a paired client's mirror of a remote host's rows is keyed by ids that client + * never registers, so the owning host's removal never reaches it (#17776). An orphan has no owner + * that could object, so this ignores the session-ownership and local-execution-host gates the + * missing-directory sweeper needs. + */ + sweepDeregisteredRepoResidue(): string[] { + const state = this[repoLifecycleOperationsContext].runtime.state + const orphanRepoIds = collectDeregisteredRepoIds(state) + if (orphanRepoIds.size === 0) { + return [] + } + for (const repoId of orphanRepoIds) { + pruneWorktreeStateForRepo(this, repoId, null) + state.workspaceSession = removeRepoFromWorkspaceSession(state.workspaceSession, repoId) + state.workspaceSessionsByHostId = removeRepoFromHostWorkspaceSessions( + state.workspaceSessionsByHostId, + repoId + ) + } + pruneDeregisteredRepoUiResidue(state.ui, orphanRepoIds) + return [...orphanRepoIds] + } + updateRepo( id: string, updates: Partial< @@ -212,6 +242,26 @@ export function pruneMobileClientTabSelections( } } +function pruneDeregisteredRepoUiResidue( + ui: PersistedState['ui'], + orphanRepoIds: ReadonlySet +): void { + const isOrphanWorktree = (worktreeId: string): boolean => + orphanRepoIds.has(getRepoIdFromWorktreeId(worktreeId)) + if (ui.lastActiveRepoId && orphanRepoIds.has(ui.lastActiveRepoId)) { + ui.lastActiveRepoId = null + } + if (ui.lastActiveWorktreeId && isOrphanWorktree(ui.lastActiveWorktreeId)) { + ui.lastActiveWorktreeId = null + } + ui.filterRepoIds = ui.filterRepoIds?.filter((repoId) => !orphanRepoIds.has(repoId)) ?? [] + for (const worktreeId of Object.keys(ui.showDotfilesByWorktree ?? {})) { + if (isOrphanWorktree(worktreeId)) { + delete ui.showDotfilesByWorktree?.[worktreeId] + } + } +} + export function getRepoUpdateOperations( owner: RepoLifecycleOperations ): RepoUpdatePersistenceOperations { diff --git a/src/main/persistence/loading-store/store.ts b/src/main/persistence/loading-store/store.ts index 888c0092ed2..017583e8f86 100644 --- a/src/main/persistence/loading-store/store.ts +++ b/src/main/persistence/loading-store/store.ts @@ -64,6 +64,9 @@ export class Store { ) const adaptedProjectGroups = this.domains.adaptation.adaptFlatFolderScanProjectGroups() this.domains.adaptation.hydrateFolderWorkspaceDiffComments() + // Load is the only place an orphaned repo id can be swept: every removal path needs the repo to + // still be registered, so rows outlive their owner without one (#17776). + const sweptRepoIds = this.domains.repos.sweepDeregisteredRepoResidue() for (const entry of normalized.migrationUnsupportedEntries) { setMigrationUnsupportedPty(entry) } @@ -78,7 +81,12 @@ export class Store { this.state.legacyPaneKeyAliasEntries = entries scheduleSave(this.domains.scheduling) }) - if (normalized.changed || this.runtime.loadNeedsSave || adaptedProjectGroups) { + if ( + normalized.changed || + this.runtime.loadNeedsSave || + adaptedProjectGroups || + sweptRepoIds.length > 0 + ) { scheduleSave(this.domains.scheduling) } } diff --git a/src/main/persistence/tracking-repos/deregistered-repo-residue.ts b/src/main/persistence/tracking-repos/deregistered-repo-residue.ts new file mode 100644 index 00000000000..c52bddbb712 --- /dev/null +++ b/src/main/persistence/tracking-repos/deregistered-repo-residue.ts @@ -0,0 +1,108 @@ +import type { PersistedState } from '../../../shared/persisted-state-types' +import { getWorktreeIdFromHostIdentity } from '../../../shared/worktree/host-qualified-identity' +import { splitWorktreeId } from '../../../shared/worktree/id' +import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' +import { SESSION_FIELDS_PRUNED_BY_OWNER_KEY } from '../../orca-profiles/profile-project-session-field-disposition' +import { ownerKeyWorktreeIds } from '../../orca-profiles/profile-project-worktree-identity' + +/** + * Repo ids that still own persisted rows but no longer appear in `state.repos`. + * + * Why nothing else finds them: every other sweeper is gated on the repo still being registered, so + * deregistering a project stranded the rows it owned permanently — including a paired client's + * mirror of a remote host's session partition, which no local repo removal can reach (#17776). + */ +export function collectDeregisteredRepoIds(state: PersistedState): Set { + const liveRepoIds = new Set(state.repos.map((repo) => repo.id)) + const orphanRepoIds = new Set() + // Only a full `::` locator seeds the set. A bare key -- a folder workspace id, a + // repo-keyed topology revision, a test-shaped locator -- cannot be told apart from a repo id, and + // guessing wrong here deletes live session state. + const addWorktreeId = (worktreeId: string | null | undefined): void => { + const repoId = worktreeId ? splitWorktreeId(worktreeId)?.repoId : undefined + if (repoId && !liveRepoIds.has(repoId)) { + orphanRepoIds.add(repoId) + } + } + /** + * Seed from an owner key, which can read as two different locators (see `ownerKeyWorktreeIds`). + * All or nothing: if either reading names a live repo the key is that repo's, and seeding the + * other reading would hand the removal pass -- which accepts either -- a live row to delete. + */ + const addOwnerKey = (ownerKey: string): void => { + const repoIds = ownerKeyWorktreeIds(ownerKey).flatMap((worktreeId) => { + const repoId = splitWorktreeId(worktreeId)?.repoId + return repoId ? [repoId] : [] + }) + if (repoIds.length > 0 && repoIds.every((repoId) => !liveRepoIds.has(repoId))) { + for (const repoId of repoIds) { + orphanRepoIds.add(repoId) + } + } + } + + // Deliberately not seeded from `sparsePresetsByRepo` or `retiredWorktreeNamesByRepo`: both are + // bounded, and dropping a retired-name row would let a re-added repo reissue a name onto a cwd + // that still holds a prior occupant's agent state. + for (const worktreeId of Object.keys(state.worktreeMeta)) { + addWorktreeId(worktreeId) + } + for (const alias of Object.keys(state.worktreeIdentityAliases ?? {})) { + addWorktreeId(getWorktreeIdFromHostIdentity(alias)) + } + for (const [childId, lineage] of Object.entries(state.worktreeLineageById)) { + addWorktreeId(childId) + addWorktreeId(lineage.parentWorktreeId) + } + for (const [childKey, lineage] of Object.entries(state.workspaceLineageByChildKey)) { + addOwnerKey(childKey) + addOwnerKey(lineage.parentWorkspaceKey) + } + for (const selections of Object.values(state.mobileClientTabSelectionsByDeviceId ?? {})) { + for (const worktreeId of Object.keys(selections)) { + addWorktreeId(worktreeId) + } + } + const sessions: (WorkspaceSessionState | undefined)[] = [ + state.workspaceSession, + ...Object.values(state.workspaceSessionsByHostId ?? {}) + ] + for (const session of sessions) { + if (!session) { + continue + } + for (const field of SESSION_FIELDS_PRUNED_BY_OWNER_KEY) { + for (const ownerKey of Object.keys( + (session[field] as Record | undefined) ?? {} + )) { + addOwnerKey(ownerKey) + } + } + for (const ownerKey of Object.keys(session.tabsByWorktree ?? {})) { + addOwnerKey(ownerKey) + } + for (const ownerKey of Object.keys(session.browserTabsByWorktree ?? {})) { + addOwnerKey(ownerKey) + } + // Pruned by bespoke rules rather than by owner key, so the loop above never reaches them. + for (const ownerKey of [ + session.activeWorktreeId, + session.activeWorkspaceKey, + ...(session.activeWorktreeIdsOnShutdown ?? []) + ]) { + if (ownerKey) { + addOwnerKey(ownerKey) + } + } + // Not seeded from `terminalTopologyRevisionByRepoId`: its keys are bare repo ids by contract, + // and a bare key is exactly what `addWorktreeId` refuses to trust. Rows there are removed once + // any locator seeds their repo id, which every repo that ever opened a terminal has. + for (const record of Object.values(session.sleepingAgentSessionsByPaneKey ?? {})) { + addWorktreeId(record.worktreeId) + } + for (const tombstone of Object.values(session.terminalSurfaceTombstonesByPaneKey ?? {})) { + addWorktreeId(tombstone.worktreeId) + } + } + return orphanRepoIds +} diff --git a/src/main/persistence/tracking-repos/repo-worktree-pruning.ts b/src/main/persistence/tracking-repos/repo-worktree-pruning.ts index f6d17b24aa2..a41f7c6319d 100644 --- a/src/main/persistence/tracking-repos/repo-worktree-pruning.ts +++ b/src/main/persistence/tracking-repos/repo-worktree-pruning.ts @@ -2,6 +2,7 @@ import type { WorkspaceKey } from '../../../shared/folder-workspace-types' import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../../shared/execution-host' import { parseWorkspaceKey } from '../../../shared/workspace-scope' import type { PersistedState } from '../../../shared/persisted-state-types' +import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' import { removeWorkspaceSessionOwners } from '../restoring-sessions/session-owner-removal' import { getExecutionHostIdFromWorktreeHostIdentity, @@ -58,10 +59,26 @@ export function pruneWorktreeStateForRepo( } } } - collectPrefixedKeys(Object.keys(state.worktreeMeta)) - collectPrefixedKeys(Object.keys(state.workspaceSession?.lastVisitedAtByWorktreeId ?? {})) - for (const session of Object.values(state.workspaceSessionsByHostId ?? {})) { + // Why the pane-keyed records contribute owner keys: they are pruned by the worktreeId they name, + // not by their own key, so a worktree with no meta and no visit row would otherwise keep its + // sleeping agents and tombstones forever -- and keep re-seeding the orphan sweep every load. + const collectScannedRecordOwners = (session: WorkspaceSessionState | undefined): void => { collectPrefixedKeys(Object.keys(session?.lastVisitedAtByWorktreeId ?? {})) + collectPrefixedKeys( + Object.values(session?.sleepingAgentSessionsByPaneKey ?? {}).map( + (record) => record.worktreeId + ) + ) + collectPrefixedKeys( + Object.values(session?.terminalSurfaceTombstonesByPaneKey ?? {}).map( + (tombstone) => tombstone.worktreeId + ) + ) + } + collectPrefixedKeys(Object.keys(state.worktreeMeta)) + collectScannedRecordOwners(state.workspaceSession) + for (const session of Object.values(state.workspaceSessionsByHostId ?? {})) { + collectScannedRecordOwners(session) } for (const key of Object.keys(state.worktreeMeta)) { diff --git a/src/main/quit-teardown-agent-browser-daemons.test.ts b/src/main/quit-teardown-agent-browser-daemons.test.ts index cc2c7d7a26e..315eba73aa0 100644 --- a/src/main/quit-teardown-agent-browser-daemons.test.ts +++ b/src/main/quit-teardown-agent-browser-daemons.test.ts @@ -8,7 +8,7 @@ import { describe, expect, it } from 'vitest' * hundreds of ms apiece. Left off the will-quit barrier, `app.quit()` fired first and * every open tab's daemon outlived the app (#16367). */ -const source = readFileSync(join(__dirname, 'index.ts'), 'utf8') +const source = readFileSync(join(__dirname, 'startup', 'main-process-quit.ts'), 'utf8') function teardownBarrierMembers(): string { const start = source.indexOf('settleTeardownWithinDeadline([') @@ -25,7 +25,7 @@ describe('quit teardown of agent-browser daemons', () => { it('captures the destroyAllSessions promise instead of firing and forgetting', () => { expect(source).toMatch( - /const browserShutdown = \(async \(\): Promise => \{[\s\S]*?await runtime\?\.getAgentBrowserBridge\(\)\?\.destroyAllSessions\(\)\s+\}\)\(\)/ + /const browserShutdown = \(async \(\): Promise => \{[\s\S]*?await state\.runtime\?\.getAgentBrowserBridge\(\)\?\.destroyAllSessions\(\)\s+\}\)\(\)/ ) // Why: a second, uncaptured call site is the pre-fix shape — it loses the race to app.quit(). expect(source.match(/getAgentBrowserBridge\(\)\?\.destroyAllSessions\(\)/g)).toHaveLength(1) diff --git a/src/main/rate-limits/service.ts b/src/main/rate-limits/service.ts index 8ce0b167a4a..5d12b979bc3 100644 --- a/src/main/rate-limits/service.ts +++ b/src/main/rate-limits/service.ts @@ -1,2182 +1,10 @@ -/* eslint-disable max-lines -- Why: centralizes polling, stale-data handling, account-switch fetch semantics, and renderer push coordination in one place */ -import type { BrowserWindow } from 'electron' -import type { - CodexRateLimitResetResult, - RateLimitState, - ProviderRateLimits, - InactiveAccountUsage, - RateLimitRuntimeTarget -} from '../../shared/rate-limit-types' -import { fetchClaudeRateLimits, fetchManagedAccountUsage } from './claude-fetcher' -import type { InactiveClaudeAccountInfo } from './claude-fetcher' -import { mapClaudeUsageWindow } from './claude-usage-window' -import type { ClaudeStatusLineRateLimits } from '../../shared/claude-statusline-rate-limits' -import { consumeCodexRateLimitResetCredit, fetchCodexRateLimits } from './codex-fetcher' -import type { ClaudeRuntimeAuthPreparation } from '../claude-accounts/runtime-auth-service' -import type { NetworkProxySettings } from '../../shared/network-proxy' -import { - normalizeClaudeAccountSelectionTarget, - type ClaudeAccountSelectionTarget, - type NormalizedClaudeAccountSelectionTarget -} from '../claude-accounts/runtime-selection' -import { fetchGeminiRateLimits } from './gemini-usage-fetcher' -import { deriveAntigravityRateLimits } from './antigravity-usage-mirror' -import { fetchKimiRateLimits } from './kimi-fetcher' -import type { KimiHomeResolution } from '../kimi/kimi-runtime-home' -import { fetchGrokRateLimits } from './grok-fetcher' -import { readGrokAuthSession } from './grok-auth' -import { hasMiniMaxSessionCookie } from '../minimax/minimax-cookie-store' -import { fetchMiniMaxRateLimits } from './minimax-fetcher' -import { fetchOpenCodeGoRateLimits } from './opencode-go-usage-fetcher' -import { - normalizeCodexAccountSelectionTarget, - type CodexAccountSelectionTarget, - type NormalizedCodexAccountSelectionTarget -} from '../codex-accounts/runtime-selection' -import type { CodexRateLimitHomeResolution } from '../codex-accounts/runtime-home-service' +import { RateLimitServiceConfiguration } from './service/service-configuration' -export type InactiveCodexAccountInfo = { - id: string - resolveHome: () => { kind: 'ready'; managedHomePath: string } | { kind: 'skip' } -} +export type { InactiveCodexAccountInfo } from './service/service-types' -type CodexHomePathResolver = (target?: CodexAccountSelectionTarget) => CodexRateLimitHomeResolution -type KimiHomeResolver = () => Promise -type ClaudeAuthPreparationResolver = ( - target?: ClaudeAccountSelectionTarget -) => Promise - -type OpenCodeGoRateLimitConfig = { - sessionCookie: string - workspaceIdOverride: string -} - -type MiniMaxRateLimitConfig = { - sessionCookie: string - groupId: string - models: string -} - -type MiniMaxResolvedConfig = { - config: MiniMaxRateLimitConfig - error: string | null -} - -type GeminiCliOAuthEnabledResolver = () => boolean -type ActiveRateLimitProvider = ProviderRateLimits['provider'] -type ActiveProviderState = { - provider: ActiveRateLimitProvider - limits: ProviderRateLimits | null -} -type ActiveWindowRefreshPlan = - | { kind: 'none' } - | { kind: 'full' } - | { kind: 'providers'; providers: ActiveRateLimitProvider[] } - -// Why: Claude's usage endpoint has a tight budget and quota is only informational; prefer a recent snapshot over polling into 429s. -const DEFAULT_POLL_MS = 15 * 60 * 1000 // 15 minutes -const MIN_POLL_MS = 30 * 1000 // 30 seconds — renderer input should never create a tight loop. -const MAX_POLL_MS = 2_147_483_647 // Max safe setInterval delay before Node clamps back to 1ms. -const MIN_REFETCH_MS = 5 * 60 * 1000 // 5 minutes — debounce resume/manual refresh bursts -const ACTIVE_FAILURE_REFETCH_MS = MIN_POLL_MS -// Why: retrying a persistent failure at the 30s floor hammers endpoints into 429s; back off per failure, capped at the poll cadence. -const MAX_ACTIVE_FAILURE_REFETCH_MS = DEFAULT_POLL_MS -const MAX_ACTIVE_FAILURE_STREAK = 8 -// Why: these providers have a dedicated fetch cycle, so an activation retry refreshes just the failing one; others force a full fetchAll. -const INDIVIDUALLY_REFRESHABLE_PROVIDERS: ReadonlySet = new Set([ - 'claude', - 'codex', - 'grok' -]) -const STALE_THRESHOLD_MS = 30 * 60 * 1000 // 30 minutes — after this, stale data is dropped -// Why: usage-endpoint 429 windows can outlast the generic threshold (Retry-After ~1h); quota is informational, so a stale snapshot beats a bare "Limited". -const RATE_LIMITED_STALE_THRESHOLD_MS = 24 * 60 * 60 * 1000 -// Why: statusline posts arrive on every turn; skip renderer pushes for identical windows so streaming sessions don't spam state updates. -const LIVE_CLAUDE_INGEST_DEDUPE_MS = 30 * 1000 -const INACTIVE_FETCH_DEBOUNCE_MS = 60 * 1000 // 60 seconds — debounce fetch-on-open -// Why: each inactive Codex probe spawns a real codex process inside that -// account's live credential home; pace them out instead of bursting every -// account the moment the switcher opens. -const INACTIVE_CODEX_PROBE_STAGGER_MS = 2_000 -const DEFERRED_STARTUP_ACTIVE_REFRESH_MS = 1000 - -// Why: inactive account arrays are derived from provider caches on demand in getState()/pushToRenderer(). -type InternalRateLimitState = { - claude: ProviderRateLimits | null - codex: ProviderRateLimits | null - gemini: ProviderRateLimits | null - opencodeGo: ProviderRateLimits | null - kimi: ProviderRateLimits | null - antigravity: ProviderRateLimits | null - minimax: ProviderRateLimits | null - grok: ProviderRateLimits | null -} - -function normalizePollingInterval(ms: number): number { - if (!Number.isFinite(ms)) { - return DEFAULT_POLL_MS - } - return Math.min(MAX_POLL_MS, Math.max(MIN_POLL_MS, ms)) -} - -function isSystemDefaultClaudeAuth( - authPreparation: ClaudeRuntimeAuthPreparation | undefined -): boolean { - // Why: fetch cycles treat missing Claude auth as system-default; align the PTY gate so refresh can't trigger auth flows. - if (!authPreparation) { - return true - } - const provenance = authPreparation?.provenance - return provenance === 'system' || Boolean(provenance?.endsWith(':system')) -} - -function toErrorMessage(error: unknown): string { - return error instanceof Error ? error.message : String(error) -} - -function normalizeClaudeConfigDir(dir: string | null | undefined): string | null { - // Why: normalize mixed Windows separators for path attribution; preserve Linux case sensitivity. - const trimmed = dir?.trim().replace(/\\/g, '/').replace(/\/+$/, '') - return trimmed || null -} - -function delayUnlessAborted(ms: number, signal: AbortSignal): Promise { - if (signal.aborted) { - return Promise.resolve() - } - return new Promise((resolve) => { - const onAbort = (): void => { - clearTimeout(timer) - resolve() - } - const timer = setTimeout(() => { - signal.removeEventListener('abort', onAbort) - resolve() - }, ms) - signal.addEventListener('abort', onAbort, { once: true }) - }) -} - -function isSameUsageWindow( - a: ProviderRateLimits['session'], - b: ProviderRateLimits['session'] -): boolean { - if (!a || !b) { - return a === b - } - return a.usedPercent === b.usedPercent && a.resetsAt === b.resetsAt -} - -export class RateLimitService { - private state: InternalRateLimitState = { - claude: null, - codex: null, - gemini: null, - opencodeGo: null, - kimi: null, - antigravity: null, - minimax: null, - grok: null - } - private grokAuthConfigured = readGrokAuthSession().status === 'ok' - private pollInterval: number = DEFAULT_POLL_MS - private timer: ReturnType | null = null - private deferredStartupRefreshTimer: ReturnType | null = null - // Why: throttle repeated focus/show/restore events so one outage doesn't create a tight provider retry loop. - private lastActiveFailureRetryAtByProvider: Record = { - claude: 0, - codex: 0, - gemini: 0, - 'opencode-go': 0, - kimi: 0, - minimax: 0, - grok: 0, - antigravity: 0 - } - // Why: consecutive failures drive exponential backoff of the fast activation-retry lane; reset on any success/unavailable result. - private activeFailureStreakByProvider: Record = { - claude: 0, - codex: 0, - gemini: 0, - 'opencode-go': 0, - kimi: 0, - minimax: 0, - grok: 0, - antigravity: 0 - } - private mainWindow: BrowserWindow | null = null - private detachWindowListeners: (() => void) | null = null - private isFetching = false - private fullFetchQueued = false - private codexOnlyFetchQueued = false - private claudeOnlyFetchQueued = false - private grokOnlyFetchQueued = false - private activeFetchAbortControllers = new Set() - private fetchIdleResolvers: (() => void)[] = [] - private codexFetchGeneration = 0 - private claudeFetchGeneration = 0 - // Why: statusline ingest must attribute live windows to the selected account without re-running the side-effectful auth sync per post. - private lastClaudeAuthSnapshot: { configDir: string | null; provenance: string } | null = null - private opencodeFetchGeneration = 0 - private minimaxFetchGeneration = 0 - private lastOpencodeConfigHash = '' - private lastMiniMaxConfigHash = '' - private codexHomePathResolver: CodexHomePathResolver | null = null - private codexFetchTarget: NormalizedCodexAccountSelectionTarget = { - runtime: 'host', - wslDistro: null - } - // Why: resolved per cycle — the local-account runtime policy can flip between fetches. - private kimiHomeResolver: KimiHomeResolver | null = null - private claudeAuthPreparationResolver: ClaudeAuthPreparationResolver | null = null - private claudeFetchTarget: NormalizedClaudeAccountSelectionTarget = { - runtime: 'host', - wslDistro: null - } - private openCodeGoConfigResolver: (() => OpenCodeGoRateLimitConfig) | null = null - private miniMaxConfigResolver: (() => MiniMaxRateLimitConfig) | null = null - private geminiCliOAuthEnabledResolver: GeminiCliOAuthEnabledResolver | null = null - private inactiveClaudeAccountsResolver: (() => InactiveClaudeAccountInfo[]) | null = null - private inactiveCodexAccountsResolver: (() => InactiveCodexAccountInfo[]) | null = null - private networkProxySettingsResolver: (() => NetworkProxySettings) | null = null - private inactiveClaudeCache = new Map() - private inactiveCodexCache = new Map() - private inactiveClaudeFetching = new Set() - private inactiveCodexFetching = new Set() - private inactiveCodexFetchInFlight = false - private lastInactiveClaudeFetchAt = 0 - private inactiveClaudeAccountsGeneration = 0 - private lastInactiveCodexFetchAt = 0 - private inactiveCodexAccountsGeneration = 0 - private stateListeners = new Set<(state: RateLimitState) => void>() - - constructor() {} - - onStateChange(listener: (state: RateLimitState) => void): () => void { - this.stateListeners.add(listener) - return () => { - this.stateListeners.delete(listener) - } - } - - setCodexHomePathResolver(resolver: CodexHomePathResolver): void { - this.codexHomePathResolver = resolver - } - - // Why: `skip` and a `ready` null are different answers — null still means the - // system-default lane, so it must never stand in for "don't fetch" (#STA-4422). - private resolveCodexHome(target?: CodexAccountSelectionTarget): { - skip: boolean - homePath: string | null - } { - const resolution = this.codexHomePathResolver?.(target) - if (!resolution) { - return { skip: false, homePath: null } - } - return resolution.kind === 'skip' - ? { skip: true, homePath: null } - : { skip: false, homePath: resolution.codexHomePath } - } - - setCodexFetchTarget(target?: CodexAccountSelectionTarget): void { - this.codexFetchTarget = normalizeCodexAccountSelectionTarget(target) - } - - setKimiHomeResolver(resolver: KimiHomeResolver): void { - this.kimiHomeResolver = resolver - } - - // Why: resolving a WSL home probes wsl.exe, so it must not run before the other - // providers' fetches are started; chaining keeps the no-resolver path immediate. - private fetchKimiWithResolvedHome(): Promise { - const pendingHome = this.kimiHomeResolver?.() - return pendingHome - ? pendingHome.then((home) => fetchKimiRateLimits({ home })) - : fetchKimiRateLimits({ home: undefined }) - } - - setClaudeAuthPreparationResolver(resolver: ClaudeAuthPreparationResolver): void { - this.claudeAuthPreparationResolver = resolver - } - - setClaudeFetchTarget(target?: ClaudeAccountSelectionTarget): void { - this.claudeFetchTarget = normalizeClaudeAccountSelectionTarget(target) - } - - setOpenCodeGoConfigResolver(resolver: () => OpenCodeGoRateLimitConfig): void { - this.openCodeGoConfigResolver = resolver - } - - setMiniMaxConfigResolver(resolver: () => MiniMaxRateLimitConfig): void { - this.miniMaxConfigResolver = resolver - } - - setGeminiCliOAuthEnabledResolver(resolver: GeminiCliOAuthEnabledResolver): void { - this.geminiCliOAuthEnabledResolver = resolver - } - - setNetworkProxySettingsResolver(resolver: () => NetworkProxySettings): void { - this.networkProxySettingsResolver = resolver - } - - setInactiveClaudeAccountsResolver(resolver: () => InactiveClaudeAccountInfo[]): void { - this.inactiveClaudeAccountsResolver = resolver - this.inactiveClaudeAccountsGeneration += 1 - } - - setInactiveCodexAccountsResolver(resolver: () => InactiveCodexAccountInfo[]): void { - this.inactiveCodexAccountsResolver = resolver - this.inactiveCodexAccountsGeneration += 1 - this.pruneInactiveCodexState() - } - - attach(mainWindow: BrowserWindow): void { - this.detachWindowListeners?.() - this.mainWindow = mainWindow - const refreshOnResume = (): void => { - void this.refreshIfWindowActive() - } - // Why: attach() can replace windows; remove the previous closed listener too, not only the focus listeners. - const detachWindowListeners = (): void => { - mainWindow.removeListener('focus', refreshOnResume) - mainWindow.removeListener('show', refreshOnResume) - mainWindow.removeListener('restore', refreshOnResume) - mainWindow.removeListener('closed', onClosed) - } - const onClosed = (): void => { - detachWindowListeners() - if (this.detachWindowListeners === detachWindowListeners) { - this.detachWindowListeners = null - } - if (this.mainWindow === mainWindow) { - this.mainWindow = null - } - } - mainWindow.on('focus', refreshOnResume) - mainWindow.on('show', refreshOnResume) - mainWindow.on('restore', refreshOnResume) - mainWindow.on('closed', onClosed) - this.detachWindowListeners = detachWindowListeners - } - - start(options: { fetchImmediately?: boolean } = {}): void { - if (options.fetchImmediately !== false) { - void this.fetchAll() - } else { - this.scheduleDeferredStartupRefresh() - } - this.startTimer() - } - - stop(): void { - this.abortActiveFetchCycle() - this.clearQueuedFetches() - this.inactiveClaudeFetching.clear() - this.inactiveCodexFetching.clear() - this.resolveAndClearFetchIdleWaiters() - this.stopTimer() - this.clearDeferredStartupRefresh() - this.detachWindowListeners?.() - this.detachWindowListeners = null - this.mainWindow = null - } - - getState(): RateLimitState { - this.pruneInactiveClaudeState() - this.pruneInactiveCodexState() - return { - ...this.state, - // Why: the cookie lives on the filesystem, not GlobalSettings; surface its presence so the renderer keeps the MiniMax bar across reloads. - minimaxCookieConfigured: hasMiniMaxSessionCookie(), - grokAuthConfigured: this.grokAuthConfigured, - claudeTarget: this.claudeFetchTarget, - codexTarget: this.codexFetchTarget, - inactiveClaudeAccounts: this.buildInactiveArray( - this.inactiveClaudeCache, - this.inactiveClaudeFetching - ), - inactiveCodexAccounts: this.buildInactiveArray( - this.inactiveCodexCache, - this.inactiveCodexFetching - ) - } - } - - async refresh(): Promise { - // Why: this user-directed refresh must bypass the poll throttle, else the click can no-op after wake/focus and feel broken. - await this.fetchAll({ force: true }) - return this.getState() - } - - async refreshIfStale(): Promise { - // Why: reconnecting mobile subscribers need fresh backgrounded-desktop data, but replaying a subscription must not queue another forced fetch. - const plan = this.getActiveWindowRefreshPlan(Date.now()) - await this.runActiveWindowRefreshPlan(plan) - return this.getState() - } - - async refreshGrok(): Promise { - await this.fetchGrokOnly({ force: true }) - return this.getState() - } - - invalidateMiniMaxCredentialState(): void { - this.minimaxFetchGeneration += 1 - // Why: saving/forgetting the cookie can race an in-flight fetch; clear the visible snapshot before any old-cookie result returns. - this.updateState({ - ...this.state, - minimax: this.withFetchingStatus(null, 'minimax') - }) - } - - async refreshForCodexAccountChange( - outgoingAccountId?: string | null, - target?: CodexAccountSelectionTarget - ): Promise { - const nextTarget = normalizeCodexAccountSelectionTarget(target) - // Why: weekly-only plans report no session window, so gating on session alone - // dropped their snapshot and left the switcher's inline bars empty. - if ( - outgoingAccountId && - (this.state.codex?.session || this.state.codex?.weekly) && - this.isSameCodexTarget(this.codexFetchTarget, nextTarget) - ) { - this.inactiveCodexCache.set(outgoingAccountId, this.state.codex) - } - this.codexFetchTarget = nextTarget - this.codexFetchGeneration += 1 - // Why: a new account/target starts with a clean retry schedule. - this.activeFailureStreakByProvider.codex = 0 - this.inactiveCodexAccountsGeneration += 1 - this.pruneInactiveCodexState() - // Why: the switch must NOT reset the inactive-fetch debounce — re-probing - // every inactive account per switch spawns codex in each credential home - // and endangers rotating refresh tokens; the switcher shows the cached - // snapshot (seeded above for the outgoing account) until the debounce ends. - // Why: clear the old Codex view immediately, else the previous account's limits show under the newly selected identity until the next poll. - this.updateState({ - ...this.state, - codex: this.withFetchingStatus(null, 'codex') - }) - await this.fetchCodexOnly({ force: true }) - return this.getState() - } - - async refreshCodexForTarget(target?: CodexAccountSelectionTarget): Promise { - const nextTarget = normalizeCodexAccountSelectionTarget(target) - const targetChanged = !this.isSameCodexTarget(this.codexFetchTarget, nextTarget) - this.codexFetchTarget = nextTarget - this.codexFetchGeneration += 1 - this.activeFailureStreakByProvider.codex = 0 - this.updateState({ - ...this.state, - codex: this.withFetchingStatus(targetChanged ? null : this.state.codex, 'codex') - }) - await this.fetchCodexOnly({ force: true }) - return this.getState() - } - - async consumeCodexRateLimitResetCredit(options: { - idempotencyKey: string - target: RateLimitRuntimeTarget - codexHomePath: string | null - }): Promise { - const codexTarget = normalizeCodexAccountSelectionTarget(options.target) - const codexHomePath = options.codexHomePath - const scopedStateBeforeReset = this.getState() - const missingWslCodexHome = codexHomePath - ? null - : this.getMissingWslCodexHomeResult(codexTarget) - if (missingWslCodexHome) { - if (this.isSameCodexTarget(this.codexFetchTarget, codexTarget)) { - await this.fetchCodexOnly({ force: true }) - } - throw new Error(missingWslCodexHome.error ?? 'Codex home unavailable') - } - try { - const outcome = await consumeCodexRateLimitResetCredit({ - codexHomePath, - idempotencyKey: options.idempotencyKey - }) - const state = await this.fetchCodexResetResultState( - codexTarget, - codexHomePath, - scopedStateBeforeReset - ) - return { outcome, state } - } catch (error) { - if (this.isSameCodexTarget(this.codexFetchTarget, codexTarget)) { - await this.fetchCodexOnly({ force: true }) - } - throw error - } - } - - async refreshForClaudeAccountChange( - outgoingAccountId?: string | null, - target?: ClaudeAccountSelectionTarget - ): Promise { - const nextTarget = normalizeClaudeAccountSelectionTarget(target) - // Why: snapshot the outgoing account's usage before clearing so the switcher's inline bars can show last-known data immediately. - if ( - outgoingAccountId && - this.state.claude?.session && - this.isSameClaudeTarget(this.claudeFetchTarget, nextTarget) - ) { - this.inactiveClaudeCache.set(outgoingAccountId, this.state.claude) - } - this.claudeFetchTarget = nextTarget - this.inactiveClaudeAccountsGeneration += 1 - this.pruneInactiveClaudeState() - this.claudeFetchGeneration += 1 - // Why: a new account/target starts with a clean retry schedule. - this.activeFailureStreakByProvider.claude = 0 - // Why: statusline posts from the outgoing account's sessions must not land on the incoming account's bar mid-switch. - this.lastClaudeAuthSnapshot = null - this.lastInactiveClaudeFetchAt = 0 - this.updateState({ - ...this.state, - claude: this.withFetchingStatus(null, 'claude') - }) - await this.fetchClaudeOnly({ force: true }) - return this.getState() - } - - async refreshClaudeForTarget(target?: ClaudeAccountSelectionTarget): Promise { - const nextTarget = normalizeClaudeAccountSelectionTarget(target) - const targetChanged = !this.isSameClaudeTarget(this.claudeFetchTarget, nextTarget) - this.claudeFetchTarget = nextTarget - this.claudeFetchGeneration += 1 - this.activeFailureStreakByProvider.claude = 0 - if (targetChanged) { - // Why: statusline posts from the outgoing target's sessions must not land on the incoming target's bar mid-switch. - this.lastClaudeAuthSnapshot = null - } - this.updateState({ - ...this.state, - claude: this.withFetchingStatus(targetChanged ? null : this.state.claude, 'claude') - }) - await this.fetchClaudeOnly({ force: true }) - return this.getState() - } - - async refreshAfterClaudeLivePtysDrained(): Promise { - // Why: "Waiting for Claude session" can only recover once no live claude - // owns the credentials. Refetch on the last PTY exit instead of leaving - // the stale terminal error up until the failure backoff elapses. - if (!this.state.claude?.usageMetadata?.deferredByLiveClaudeSession) { - return - } - this.activeFailureStreakByProvider.claude = 0 - await this.fetchClaudeOnly({ force: true }) - } - - async fetchInactiveClaudeAccountsOnOpen(): Promise { - if (Date.now() - this.lastInactiveClaudeFetchAt < INACTIVE_FETCH_DEBOUNCE_MS) { - return - } - this.pruneInactiveClaudeState() - if (this.inactiveClaudeFetching.size > 0) { - return - } - const accounts = this.inactiveClaudeAccountsResolver?.() ?? [] - if (accounts.length === 0) { - return - } - const fetchGeneration = this.inactiveClaudeAccountsGeneration - const controller = this.beginFetchCycle() - const signal = controller.signal - - for (const account of accounts) { - this.inactiveClaudeFetching.add(account.id) - } - this.pushToRenderer() - - try { - for (const account of accounts) { - if ( - signal.aborted || - fetchGeneration !== this.inactiveClaudeAccountsGeneration || - !this.isCurrentInactiveClaudeAccount(account.id) - ) { - this.inactiveClaudeFetching.delete(account.id) - if (!this.isCurrentInactiveClaudeAccount(account.id)) { - this.inactiveClaudeCache.delete(account.id) - } - this.pushToRenderer() - continue - } - try { - const fresh = await fetchManagedAccountUsage(account, { - allowUsagePanelSupplement: this.shouldAllowClaudeUsagePanelSupplement(), - networkProxySettings: this.networkProxySettingsResolver?.(), - signal - }) - if ( - signal.aborted || - fetchGeneration !== this.inactiveClaudeAccountsGeneration || - !this.isCurrentInactiveClaudeAccount(account.id) - ) { - this.inactiveClaudeFetching.delete(account.id) - if (!this.isCurrentInactiveClaudeAccount(account.id)) { - this.inactiveClaudeCache.delete(account.id) - } - this.pushToRenderer() - continue - } - const cached = this.inactiveClaudeCache.get(account.id) ?? null - this.inactiveClaudeCache.set(account.id, this.applyStalePolicy(fresh, cached)) - } catch { - // Why: per-account try/catch keeps one Keychain/network error from aborting the remaining accounts in the batch. - if ( - signal.aborted || - fetchGeneration !== this.inactiveClaudeAccountsGeneration || - !this.isCurrentInactiveClaudeAccount(account.id) - ) { - this.inactiveClaudeCache.delete(account.id) - } - } - this.inactiveClaudeFetching.delete(account.id) - this.pushToRenderer() - } - - if (!signal.aborted && fetchGeneration === this.inactiveClaudeAccountsGeneration) { - this.lastInactiveClaudeFetchAt = Date.now() - } - } finally { - this.finishFetchCycle(controller) - } - } - - async fetchInactiveCodexAccountsOnOpen(): Promise { - if (Date.now() - this.lastInactiveCodexFetchAt < INACTIVE_FETCH_DEBOUNCE_MS) { - return - } - this.pruneInactiveCodexState() - if (this.inactiveCodexFetchInFlight) { - return - } - const accounts = this.inactiveCodexAccountsResolver?.() ?? [] - if (accounts.length === 0) { - return - } - // Why: account switching can activate a previewed account while its RPC-only fetch is still in flight; ignore stale results. - const fetchGeneration = this.inactiveCodexAccountsGeneration - const controller = this.beginFetchCycle() - const signal = controller.signal - this.inactiveCodexFetchInFlight = true - - let staggerNextProbe = false - try { - for (const account of accounts) { - if ( - signal.aborted || - fetchGeneration !== this.inactiveCodexAccountsGeneration || - !this.isCurrentInactiveCodexAccount(account.id) - ) { - this.inactiveCodexFetching.delete(account.id) - if (!this.isCurrentInactiveCodexAccount(account.id)) { - this.inactiveCodexCache.delete(account.id) - } - this.pushToRenderer() - continue - } - if (staggerNextProbe) { - await delayUnlessAborted(INACTIVE_CODEX_PROBE_STAGGER_MS, signal) - // Why: the account set can change while the stagger delay runs. - if ( - signal.aborted || - fetchGeneration !== this.inactiveCodexAccountsGeneration || - !this.isCurrentInactiveCodexAccount(account.id) - ) { - this.inactiveCodexFetching.delete(account.id) - if (!this.isCurrentInactiveCodexAccount(account.id)) { - this.inactiveCodexCache.delete(account.id) - } - this.pushToRenderer() - continue - } - } - const home = account.resolveHome() - if (home.kind === 'skip') { - continue - } - staggerNextProbe = true - this.inactiveCodexFetching.add(account.id) - this.pushToRenderer() - try { - // Why: point fetchCodexRateLimits at the managed home directly, avoiding materializing credentials into the shared runtime location. - // Why: no PTY fallback — the switcher preview shouldn't spawn hidden PTYs per account (can crash ConPTY on Windows); RPC-only is enough. - const fresh = await fetchCodexRateLimits({ - codexHomePath: home.managedHomePath, - allowPtyFallback: false, - signal - }) - if ( - signal.aborted || - fetchGeneration !== this.inactiveCodexAccountsGeneration || - !this.isCurrentInactiveCodexAccount(account.id) - ) { - this.inactiveCodexFetching.delete(account.id) - if (!this.isCurrentInactiveCodexAccount(account.id)) { - this.inactiveCodexCache.delete(account.id) - } - this.pushToRenderer() - continue - } - const cached = this.inactiveCodexCache.get(account.id) ?? null - this.inactiveCodexCache.set(account.id, this.applyStalePolicy(fresh, cached)) - } catch { - // Why: per-account try/catch prevents one failure from aborting the batch. - if ( - signal.aborted || - fetchGeneration !== this.inactiveCodexAccountsGeneration || - !this.isCurrentInactiveCodexAccount(account.id) - ) { - this.inactiveCodexCache.delete(account.id) - } - } - this.inactiveCodexFetching.delete(account.id) - this.pushToRenderer() - } - - if (!signal.aborted && fetchGeneration === this.inactiveCodexAccountsGeneration) { - this.lastInactiveCodexFetchAt = Date.now() - } - } finally { - this.inactiveCodexFetchInFlight = false - this.finishFetchCycle(controller) - } - } - - evictInactiveClaudeCache(accountId: string): void { - this.inactiveClaudeAccountsGeneration += 1 - this.inactiveClaudeCache.delete(accountId) - this.inactiveClaudeFetching.delete(accountId) - this.pushToRenderer() - } - - private isCurrentInactiveClaudeAccount(accountId: string): boolean { - return (this.inactiveClaudeAccountsResolver?.() ?? []).some( - (account) => account.id === accountId - ) - } - - private isCurrentInactiveCodexAccount(accountId: string): boolean { - return (this.inactiveCodexAccountsResolver?.() ?? []).some( - (account) => account.id === accountId - ) - } - - private pruneInactiveClaudeState(): void { - const currentIds = new Set( - (this.inactiveClaudeAccountsResolver?.() ?? []).map((account) => account.id) - ) - for (const accountId of this.inactiveClaudeCache.keys()) { - if (!currentIds.has(accountId)) { - this.inactiveClaudeCache.delete(accountId) - } - } - for (const accountId of this.inactiveClaudeFetching) { - if (!currentIds.has(accountId)) { - this.inactiveClaudeFetching.delete(accountId) - } - } - } - - private pruneInactiveCodexState(): void { - const currentIds = new Set( - (this.inactiveCodexAccountsResolver?.() ?? []).map((account) => account.id) - ) - for (const accountId of this.inactiveCodexCache.keys()) { - if (!currentIds.has(accountId)) { - this.inactiveCodexCache.delete(accountId) - } - } - for (const accountId of this.inactiveCodexFetching) { - if (!currentIds.has(accountId)) { - this.inactiveCodexFetching.delete(accountId) - } - } - } - - evictInactiveCodexCache(accountId: string): void { - // Why: clear only this account, not the generation — bumping it would discard sibling fetches still in flight and their fresh results. - this.inactiveCodexCache.delete(accountId) - this.inactiveCodexFetching.delete(accountId) - this.pushToRenderer() - } - - setPollingInterval(ms: number): void { - this.pollInterval = normalizePollingInterval(ms) - if (this.timer) { - this.stopTimer() - this.startTimer() - } - } - - // --------------------------------------------------------------------------- - // Internal - // --------------------------------------------------------------------------- - - private startTimer(): void { - this.stopTimer() - this.timer = setInterval(() => { - if (!this.shouldBackgroundPoll()) { - return - } - void this.fetchAll() - }, this.pollInterval) - } - - private stopTimer(): void { - if (this.timer) { - clearInterval(this.timer) - this.timer = null - } - } - - private scheduleDeferredStartupRefresh(): void { - this.clearDeferredStartupRefresh() - this.deferredStartupRefreshTimer = setTimeout(() => { - this.deferredStartupRefreshTimer = null - void this.refreshIfWindowActive() - }, DEFERRED_STARTUP_ACTIVE_REFRESH_MS) - } - - private clearDeferredStartupRefresh(): void { - if (this.deferredStartupRefreshTimer) { - clearTimeout(this.deferredStartupRefreshTimer) - this.deferredStartupRefreshTimer = null - } - } - - private shouldBackgroundPoll(): boolean { - if (!this.mainWindow || this.mainWindow.isDestroyed()) { - return false - } - // Why: these fetches only power in-app UI; skip polling when hidden/minimized/unfocused to save CLI/API budget (refresh on activate). - if (!this.mainWindow.isVisible() || this.mainWindow.isMinimized()) { - return false - } - return this.mainWindow.isFocused() - } - - private getActiveProviderState(): ActiveProviderState[] { - // Why: key by provider so a new provider is compile-forced an entry — a missing one silently never recovers from a startup error. - const byProvider: Record = { - claude: this.state.claude, - codex: this.state.codex, - gemini: this.state.gemini, - 'opencode-go': this.state.opencodeGo, - kimi: this.state.kimi, - minimax: this.state.minimax, - grok: this.state.grok, - antigravity: this.state.antigravity - } - return Object.entries(byProvider).map(([provider, limits]) => ({ - provider: provider as ActiveRateLimitProvider, - limits - })) - } - - private getActiveWindowRefreshPlan(now: number): ActiveWindowRefreshPlan { - const retryableFailures: ActiveRateLimitProvider[] = [] - for (const { provider, limits } of this.getActiveProviderState()) { - if (!limits || limits.status === 'idle' || limits.status === 'fetching') { - return { kind: 'full' } - } - if (limits.status === 'ok' || limits.status === 'unavailable') { - if (now - limits.updatedAt >= MIN_REFETCH_MS) { - return { kind: 'full' } - } - continue - } - // Why: a failed startup read is not fresh data; keep it eligible for activation recovery, throttled per provider. - if (limits.status === 'error') { - // Why: the server told us when to come back (Retry-After); retrying earlier burns the endpoint's budget and keeps the 429 alive. - if (this.isRetryAfterActive(limits)) { - continue - } - const lastRetryAt = this.lastActiveFailureRetryAtByProvider[provider] - const throttleMs = INDIVIDUALLY_REFRESHABLE_PROVIDERS.has(provider) - ? Math.min( - ACTIVE_FAILURE_REFETCH_MS * - 2 ** Math.max(0, this.activeFailureStreakByProvider[provider] - 1), - MAX_ACTIVE_FAILURE_REFETCH_MS - ) - : MIN_REFETCH_MS - if (now - lastRetryAt >= throttleMs) { - retryableFailures.push(provider) - } - } - } - - if (retryableFailures.length === 0) { - return { kind: 'none' } - } - return { kind: 'providers', providers: retryableFailures } - } - - private async runActiveWindowRefreshPlan(plan: ActiveWindowRefreshPlan): Promise { - if (plan.kind === 'none') { - return - } - if (plan.kind === 'full') { - // Why: a full fetch retries failing providers too; restart their retry clocks so the individual failure lane doesn't fire ahead of backoff. - // Why: gated on !isFetching — the fetchAll below no-ops mid-flight, so don't consume the retry throttle for free. - if (!this.isFetching) { - const now = Date.now() - for (const { provider, limits } of this.getActiveProviderState()) { - if (limits?.status === 'error') { - this.lastActiveFailureRetryAtByProvider[provider] = now - } - } - } - await this.fetchAll() - return - } - - // Why: an in-flight fetch will refresh these; skip without consuming the per-provider retry throttle so the next activation retries. - if (this.isFetching) { - return - } - - const now = Date.now() - for (const provider of plan.providers) { - this.lastActiveFailureRetryAtByProvider[provider] = now - } - - const canRefreshIndividually = plan.providers.every((provider) => - INDIVIDUALLY_REFRESHABLE_PROVIDERS.has(provider) - ) - if (!canRefreshIndividually) { - await this.fetchAll() - return - } - - // Why: recover partial failures of dedicated-fetch providers without re-reading healthy providers still inside their debounce. - if (plan.providers.includes('claude')) { - await this.fetchClaudeOnly() - } - if (plan.providers.includes('codex')) { - await this.fetchCodexOnly() - } - if (plan.providers.includes('grok')) { - await this.fetchGrokOnly() - } - } - - private async refreshIfWindowActive(): Promise { - if (!this.shouldBackgroundPoll()) { - return - } - const plan = this.getActiveWindowRefreshPlan(Date.now()) - await this.runActiveWindowRefreshPlan(plan) - } - - private async fetchAll(options?: { force?: boolean }): Promise { - if (this.isFetching) { - if (options?.force) { - this.fullFetchQueued = true - return this.waitForFetchIdle() - } - return - } - this.isFetching = true - - try { - let shouldContinue = true - // Why: only user-directed (force) fetches may bypass a provider's Retry-After gate; queued reruns inherit force because only forced calls queue them. - let cycleForce = options?.force ?? false - while (shouldContinue) { - const signal = await this.runWithFetchAbortSignal((fetchSignal) => - this.runFetchAllCycle(fetchSignal, { force: cycleForce }) - ) - shouldContinue = false - cycleForce = true - if (signal.aborted) { - break - } - if (this.fullFetchQueued) { - this.fullFetchQueued = false - shouldContinue = true - continue - } - if (this.codexOnlyFetchQueued) { - this.codexOnlyFetchQueued = false - const codexSignal = await this.runWithFetchAbortSignal((fetchSignal) => - this.runFetchCodexOnlyCycle(fetchSignal) - ) - if (codexSignal.aborted) { - break - } - } - if (this.claudeOnlyFetchQueued) { - this.claudeOnlyFetchQueued = false - const claudeSignal = await this.runWithFetchAbortSignal((fetchSignal) => - this.runFetchClaudeOnlyCycle(fetchSignal, { force: true }) - ) - if (claudeSignal.aborted) { - break - } - } - if (this.grokOnlyFetchQueued) { - this.grokOnlyFetchQueued = false - const grokSignal = await this.runWithFetchAbortSignal((fetchSignal) => - this.runFetchGrokOnlyCycle(fetchSignal) - ) - if (grokSignal.aborted) { - break - } - } - } - } finally { - this.isFetching = false - this.resolveFetchIdleWaiters() - } - } - - private async fetchCodexOnly(options?: { force?: boolean }): Promise { - if (this.isFetching) { - if (options?.force) { - this.codexOnlyFetchQueued = true - return this.waitForFetchIdle() - } - return - } - this.isFetching = true - - try { - let shouldContinue = true - while (shouldContinue) { - const signal = await this.runWithFetchAbortSignal((fetchSignal) => - this.runFetchCodexOnlyCycle(fetchSignal) - ) - shouldContinue = false - if (signal.aborted) { - break - } - if (this.fullFetchQueued) { - this.fullFetchQueued = false - const fullSignal = await this.runWithFetchAbortSignal((fetchSignal) => - this.runFetchAllCycle(fetchSignal, { force: true }) - ) - if (fullSignal.aborted) { - break - } - continue - } - if (this.codexOnlyFetchQueued) { - this.codexOnlyFetchQueued = false - shouldContinue = true - } - if (this.claudeOnlyFetchQueued) { - this.claudeOnlyFetchQueued = false - const claudeSignal = await this.runWithFetchAbortSignal((fetchSignal) => - this.runFetchClaudeOnlyCycle(fetchSignal, { force: true }) - ) - if (claudeSignal.aborted) { - break - } - } - if (this.grokOnlyFetchQueued) { - this.grokOnlyFetchQueued = false - const grokSignal = await this.runWithFetchAbortSignal((fetchSignal) => - this.runFetchGrokOnlyCycle(fetchSignal) - ) - if (grokSignal.aborted) { - break - } - } - } - } finally { - this.isFetching = false - this.resolveFetchIdleWaiters() - } - } - - private async fetchClaudeOnly(options?: { force?: boolean }): Promise { - if (this.isFetching) { - if (options?.force) { - this.claudeOnlyFetchQueued = true - return this.waitForFetchIdle() - } - return - } - this.isFetching = true - - try { - let shouldContinue = true - // Why: only user-directed (force) fetches may bypass a provider's Retry-After gate; queued reruns inherit force because only forced calls queue them. - let cycleForce = options?.force ?? false - while (shouldContinue) { - const signal = await this.runWithFetchAbortSignal((fetchSignal) => - this.runFetchClaudeOnlyCycle(fetchSignal, { force: cycleForce }) - ) - shouldContinue = false - cycleForce = true - if (signal.aborted) { - break - } - if (this.fullFetchQueued) { - this.fullFetchQueued = false - const fullSignal = await this.runWithFetchAbortSignal((fetchSignal) => - this.runFetchAllCycle(fetchSignal, { force: true }) - ) - if (fullSignal.aborted) { - break - } - continue - } - if (this.claudeOnlyFetchQueued) { - this.claudeOnlyFetchQueued = false - shouldContinue = true - } - if (this.codexOnlyFetchQueued) { - this.codexOnlyFetchQueued = false - const codexSignal = await this.runWithFetchAbortSignal((fetchSignal) => - this.runFetchCodexOnlyCycle(fetchSignal) - ) - if (codexSignal.aborted) { - break - } - } - if (this.grokOnlyFetchQueued) { - this.grokOnlyFetchQueued = false - const grokSignal = await this.runWithFetchAbortSignal((fetchSignal) => - this.runFetchGrokOnlyCycle(fetchSignal) - ) - if (grokSignal.aborted) { - break - } - } - } - } finally { - this.isFetching = false - this.resolveFetchIdleWaiters() - } - } - - private async fetchGrokOnly(options?: { force?: boolean }): Promise { - if (this.isFetching) { - if (options?.force) { - this.grokOnlyFetchQueued = true - return this.waitForFetchIdle() - } - return - } - this.isFetching = true - - try { - let shouldContinue = true - while (shouldContinue) { - const signal = await this.runWithFetchAbortSignal((fetchSignal) => - this.runFetchGrokOnlyCycle(fetchSignal) - ) - shouldContinue = false - if (signal.aborted) { - break - } - if (this.fullFetchQueued) { - this.fullFetchQueued = false - const fullSignal = await this.runWithFetchAbortSignal((fetchSignal) => - this.runFetchAllCycle(fetchSignal, { force: true }) - ) - if (fullSignal.aborted) { - break - } - continue - } - if (this.grokOnlyFetchQueued) { - this.grokOnlyFetchQueued = false - shouldContinue = true - } - if (this.codexOnlyFetchQueued) { - this.codexOnlyFetchQueued = false - const codexSignal = await this.runWithFetchAbortSignal((fetchSignal) => - this.runFetchCodexOnlyCycle(fetchSignal) - ) - if (codexSignal.aborted) { - break - } - } - if (this.claudeOnlyFetchQueued) { - this.claudeOnlyFetchQueued = false - const claudeSignal = await this.runWithFetchAbortSignal((fetchSignal) => - this.runFetchClaudeOnlyCycle(fetchSignal, { force: true }) - ) - if (claudeSignal.aborted) { - break - } - } - } - } finally { - this.isFetching = false - this.resolveFetchIdleWaiters() - } - } - - private waitForFetchIdle(): Promise { - if ( - !this.isFetching && - !this.fullFetchQueued && - !this.codexOnlyFetchQueued && - !this.claudeOnlyFetchQueued && - !this.grokOnlyFetchQueued - ) { - return Promise.resolve() - } - // Why: explicit-refresh callers must await the queued follow-up cycle when a poll is in flight, else the UI stops spinning early. - return new Promise((resolve) => { - this.fetchIdleResolvers.push(resolve) - }) - } - - private resolveFetchIdleWaiters(): void { - if ( - this.isFetching || - this.fullFetchQueued || - this.codexOnlyFetchQueued || - this.claudeOnlyFetchQueued || - this.grokOnlyFetchQueued - ) { - return - } - const resolvers = this.fetchIdleResolvers - this.fetchIdleResolvers = [] - for (const resolve of resolvers) { - resolve() - } - } - - private beginFetchCycle(): AbortController { - const controller = new AbortController() - this.activeFetchAbortControllers.add(controller) - return controller - } - - private finishFetchCycle(controller: AbortController): void { - this.activeFetchAbortControllers.delete(controller) - } - - private async runWithFetchAbortSignal( - fn: (signal: AbortSignal) => Promise - ): Promise { - const controller = this.beginFetchCycle() - try { - await fn(controller.signal) - return controller.signal - } finally { - this.finishFetchCycle(controller) - } - } - - private abortActiveFetchCycle(): void { - for (const controller of this.activeFetchAbortControllers) { - controller.abort() - } - this.activeFetchAbortControllers.clear() - } - - private clearQueuedFetches(): void { - this.fullFetchQueued = false - this.codexOnlyFetchQueued = false - this.claudeOnlyFetchQueued = false - this.grokOnlyFetchQueued = false - } - - private resolveAndClearFetchIdleWaiters(): void { - const resolvers = this.fetchIdleResolvers - this.fetchIdleResolvers = [] - for (const resolve of resolvers) { - resolve() - } - } - - private isSameCodexTarget( - left: NormalizedCodexAccountSelectionTarget, - right: NormalizedCodexAccountSelectionTarget - ): boolean { - return left.runtime === right.runtime && left.wslDistro === right.wslDistro - } - - private isSameClaudeTarget( - left: NormalizedClaudeAccountSelectionTarget, - right: NormalizedClaudeAccountSelectionTarget - ): boolean { - return left.runtime === right.runtime && left.wslDistro === right.wslDistro - } - - private getCodexProvenance( - target: NormalizedCodexAccountSelectionTarget, - codexHomePath: string | null - ): string { - const targetKey = target.runtime === 'wsl' ? `wsl:${target.wslDistro ?? '__default__'}` : 'host' - return codexHomePath ? `${targetKey}:managed:${codexHomePath}` : `${targetKey}:system` - } - - private getMissingWslCodexHomeResult( - target: NormalizedCodexAccountSelectionTarget - ): ProviderRateLimits | null { - if (target.runtime !== 'wsl') { - return null - } - return { - provider: 'codex', - session: null, - weekly: null, - updatedAt: Date.now(), - error: `WSL Codex home unavailable for ${target.wslDistro ?? 'default distro'}`, - status: 'error' - } - } - - private async fetchCodexResetResultState( - target: NormalizedCodexAccountSelectionTarget, - codexHomePath: string | null, - stateBeforeReset: RateLimitState - ): Promise { - const controller = this.beginFetchCycle() - let fresh: ProviderRateLimits - try { - fresh = await fetchCodexRateLimits({ - codexHomePath, - allowPtyFallback: this.shouldAllowCodexPtyFallback(), - signal: controller.signal - }) - } catch (error) { - fresh = { - provider: 'codex', - session: null, - weekly: null, - updatedAt: Date.now(), - error: toErrorMessage(error), - status: 'error' - } - } finally { - this.finishFetchCycle(controller) - } - - const scopedCodex = this.applyStalePolicy(fresh, stateBeforeReset.codex) - const currentCodexHome = this.resolveCodexHome(target) - // Why: a skip has no provenance to compare, so treat it as no longer active - // rather than publishing this result against the system-default lane. - const stillActive = - !currentCodexHome.skip && - this.isSameCodexTarget(this.codexFetchTarget, target) && - this.getCodexProvenance(target, currentCodexHome.homePath) === - this.getCodexProvenance(target, codexHomePath) - if (stillActive) { - // Why: this post-redemption read is newer than every Codex fetch that - // started before it, so invalidate those results before publishing it. - this.codexFetchGeneration += 1 - this.trackActiveFailureStreak('codex', fresh) - this.updateState({ - ...this.state, - codex: this.applyStalePolicy(fresh, this.state.codex) - }) - } - - // Why: the caller must receive the redeemed target even if the global UI - // switched targets while the provider mutation was in flight. - return { ...stateBeforeReset, codex: scopedCodex, codexTarget: target } - } - - private shouldAllowCodexPtyFallback(): boolean { - // Why: hidden PTY fallback can crash inside ConPTY on Windows; prefer RPC-only degradation there for background quota refresh. - return process.platform !== 'win32' - } - - private shouldAllowClaudePtyFallback( - authPreparation: ClaudeRuntimeAuthPreparation | undefined - ): boolean { - // Why: Windows hidden PTY support is less reliable than host/WSL shells. - if (process.platform === 'win32') { - return false - } - // Why: system-default Claude isn't Orca-managed; refresh may read existing OAuth but must not launch Claude and trigger auth/browser flows. - return !isSystemDefaultClaudeAuth(authPreparation) - } - - private shouldAllowClaudeUsagePanelSupplement(): boolean { - // Why: keep this supplement off on Windows where hidden PTYs are still less reliable. - return process.platform !== 'win32' - } - - private resolveMiniMaxConfig(): MiniMaxResolvedConfig { - try { - return { - config: this.miniMaxConfigResolver?.() ?? { - sessionCookie: '', - groupId: '', - models: 'general' - }, - error: null - } - } catch (error) { - // Why: one unreadable cookie must not abort every provider's refresh; surface it as MiniMax-only state instead. - return { - config: { - sessionCookie: '', - groupId: '', - models: 'general' - }, - error: toErrorMessage(error) - } - } - } - - private getMiniMaxCredentialError(message: string): ProviderRateLimits { - return { - provider: 'minimax', - session: null, - weekly: null, - updatedAt: Date.now(), - error: message, - status: 'error', - usageMetadata: { failureKind: 'keychain-unavailable', source: 'web' } - } - } - - // Why: hitting a usage endpoint before its Retry-After expires burns the budget for nothing and keeps the 429 window alive. - private isRetryAfterActive(limits: ProviderRateLimits | null): boolean { - return Boolean( - limits?.status === 'error' && - limits.usageMetadata?.retryAtMs && - limits.usageMetadata.retryAtMs > Date.now() - ) - } - - // Why: a live Claude session already streams fresh usage windows; spending the OAuth usage endpoint's tight budget on the same data invites 429s. - private isLiveClaudeUsageFresh(limits: ProviderRateLimits | null): boolean { - return Boolean( - limits?.status === 'ok' && - limits.usageMetadata?.source === 'live-session' && - Date.now() - limits.updatedAt < MIN_REFETCH_MS - ) - } - - private shouldSkipAutomatedClaudeFetch(limits: ProviderRateLimits | null): boolean { - return this.isRetryAfterActive(limits) || this.isLiveClaudeUsageFresh(limits) - } - - private resolveClaudeFetchApply( - fresh: ProviderRateLimits, - previous: ProviderRateLimits | null - ): ProviderRateLimits { - // Why: a live statusline post can land while an OAuth cycle is in flight; a failed fetch must not - // roll the bar back to the pre-cycle snapshot or flip the just-refreshed live data to error. - const current = this.state.claude - if (fresh.status !== 'ok' && current && this.isLiveClaudeUsageFresh(current)) { - return current - } - return this.applyStalePolicy(fresh, previous) - } - - private rememberClaudeAuthSnapshot( - authPreparation: ClaudeRuntimeAuthPreparation | undefined, - claudeGeneration: number, - claudeTarget: NormalizedClaudeAccountSelectionTarget - ): void { - // Why: an account switch during the resolver await already cleared the snapshot; restoring the outgoing account's configDir here would cross-attribute its live posts to the new bar. - if ( - claudeGeneration !== this.claudeFetchGeneration || - !this.isSameClaudeTarget(claudeTarget, this.claudeFetchTarget) - ) { - return - } - this.lastClaudeAuthSnapshot = { - configDir: normalizeClaudeConfigDir(authPreparation?.envPatch.CLAUDE_CONFIG_DIR), - provenance: authPreparation?.provenance ?? 'system' - } - } - - /** Live usage windows forwarded from a Claude session's statusLine command. */ - ingestLiveClaudeRateLimits(event: ClaudeStatusLineRateLimits): void { - // Why: attribution needs the selected account's config dir; until a fetch cycle captures it, drop posts rather than guess the account. - const snapshot = this.lastClaudeAuthSnapshot - if (!snapshot) { - // Why: breadcrumbs make a silently dark live feed diagnosable — dropped posts are otherwise invisible. - console.debug('[rate-limits] dropped live Claude usage: no auth snapshot yet', { - eventConfigDir: event.configDir - }) - return - } - // Why: sessions of other accounts (or other runtimes) report their own quota; mixing them into the active account's bar would lie. - if (normalizeClaudeConfigDir(event.configDir) !== snapshot.configDir) { - console.debug('[rate-limits] dropped live Claude usage: configDir mismatch', { - eventConfigDir: event.configDir, - snapshotConfigDir: snapshot.configDir - }) - return - } - const freshSession = mapClaudeUsageWindow(event.fiveHour ?? undefined, 300) - const freshWeekly = mapClaudeUsageWindow(event.sevenDay ?? undefined, 10080) - if (!freshSession && !freshWeekly) { - return - } - const previous = this.state.claude - // Why: statusline payloads can carry a single window; an absent one means "no update", not "cleared" — keep the other bar populated. - const session = freshSession ?? previous?.session ?? null - const weekly = freshWeekly ?? previous?.weekly ?? null - if ( - previous?.status === 'ok' && - previous.usageMetadata?.source === 'live-session' && - Date.now() - previous.updatedAt < LIVE_CLAUDE_INGEST_DEDUPE_MS && - isSameUsageWindow(previous.session, session) && - isSameUsageWindow(previous.weekly, weekly) - ) { - return - } - this.activeFailureStreakByProvider.claude = 0 - this.updateState({ - ...this.state, - claude: { - provider: 'claude', - session, - weekly, - // Why: the statusline payload has no Fable scoped window; keep the last OAuth-provided one visible. - // Tradeoff: while live posts keep the OAuth poll gated, fableWeekly stays frozen until the session idles past the freshness window. - fableWeekly: previous?.fableWeekly ?? null, - updatedAt: Date.now(), - error: null, - status: 'ok', - usageMetadata: { - source: 'live-session', - lastSuccessfulSource: 'live-session', - credentialSource: previous?.usageMetadata?.credentialSource, - authProvenance: snapshot.provenance - } - } - }) - } - - private trackActiveFailureStreak( - provider: ActiveRateLimitProvider, - fresh: ProviderRateLimits - ): void { - if (fresh.status === 'error') { - this.activeFailureStreakByProvider[provider] = Math.min( - this.activeFailureStreakByProvider[provider] + 1, - MAX_ACTIVE_FAILURE_STREAK - ) - return - } - if (fresh.status === 'ok' || fresh.status === 'unavailable') { - this.activeFailureStreakByProvider[provider] = 0 - } - } - - private withFetchingStatus( - current: ProviderRateLimits | null, - provider: - | 'claude' - | 'codex' - | 'gemini' - | 'opencode-go' - | 'kimi' - | 'minimax' - | 'grok' - | 'antigravity' - ): ProviderRateLimits { - if (!current) { - return { - provider, - session: null, - weekly: null, - updatedAt: 0, - error: null, - status: 'fetching' - } - } - // Why: keep a settled chip visible during background refetch so a persistently failing provider doesn't flash "…" → error each cycle. - if (current.status === 'ok' || current.status === 'error' || current.status === 'unavailable') { - return current - } - return { ...current, status: 'fetching' } - } - - private async runFetchAllCycle( - signal: AbortSignal, - options?: { force?: boolean } - ): Promise { - if (signal.aborted) { - return - } - const claudeTarget = this.claudeFetchTarget - // Why: capture before the resolver await so an account switch during it invalidates both the snapshot and the state apply. - const claudeGeneration = this.claudeFetchGeneration - const claudeAuthPreparation = await this.claudeAuthPreparationResolver?.(claudeTarget) - if (signal.aborted) { - return - } - this.rememberClaudeAuthSnapshot(claudeAuthPreparation, claudeGeneration, claudeTarget) - const claudeProvenance = claudeAuthPreparation?.provenance ?? 'system' - const codexTarget = this.codexFetchTarget - const previousState = this.state - // Why: a skipped Codex poll must not stop the other providers' cycle, so gate - // only the Codex slot instead of returning early (#STA-4422). - const codexHome = this.resolveCodexHome(codexTarget) - const codexFetchGated = codexHome.skip - const codexHomePath = codexHome.homePath - const codexStateBeforeFetch = - previousState.codex?.status === 'fetching' ? null : previousState.codex - const codexProvenance = codexFetchGated - ? null - : this.getCodexProvenance(codexTarget, codexHomePath) - const codexGeneration = this.codexFetchGeneration - const openCodeGoConfig = this.openCodeGoConfigResolver?.() - const cookie = openCodeGoConfig?.sessionCookie ?? '' - const workspaceIdOverride = openCodeGoConfig?.workspaceIdOverride ?? '' - const miniMaxConfigResult = this.resolveMiniMaxConfig() - const miniMaxCookie = miniMaxConfigResult.config.sessionCookie - const miniMaxGroupId = miniMaxConfigResult.config.groupId - const miniMaxModels = miniMaxConfigResult.config.models - const geminiCliOAuthEnabled = this.geminiCliOAuthEnabledResolver?.() ?? false - // Why: getState() is hot (renderer pushes + mobile snapshots); keep Grok's sync auth-file probe on fetch cycles instead. - const grokAuthReadResult = readGrokAuthSession() - this.grokAuthConfigured = grokAuthReadResult.status === 'ok' - - // Discard stale data on config change — it belongs to a different session/workspace. - const currentConfigHash = `${cookie}|${workspaceIdOverride}` - const opencodeConfigChanged = currentConfigHash !== this.lastOpencodeConfigHash - if (opencodeConfigChanged) { - this.lastOpencodeConfigHash = currentConfigHash - this.opencodeFetchGeneration += 1 - } - const opencodeGeneration = this.opencodeFetchGeneration - - const currentMiniMaxConfigHash = `${miniMaxCookie}|${miniMaxGroupId}|${miniMaxModels}|${miniMaxConfigResult.error ?? ''}` - const miniMaxConfigChanged = currentMiniMaxConfigHash !== this.lastMiniMaxConfigHash - if (miniMaxConfigChanged) { - this.lastMiniMaxConfigHash = currentMiniMaxConfigHash - this.minimaxFetchGeneration += 1 - } - const miniMaxGeneration = this.minimaxFetchGeneration - - // Mark all providers fetching while keeping previous data visible (Codex is cleared separately on account change). - this.updateState({ - ...previousState, - claude: this.withFetchingStatus(previousState.claude, 'claude'), - // Why: a gated Codex cycle makes no attempt; a "fetching" chip would never settle. - codex: codexFetchGated - ? codexStateBeforeFetch - : this.withFetchingStatus(previousState.codex, 'codex'), - gemini: this.withFetchingStatus(previousState.gemini, 'gemini'), - opencodeGo: opencodeConfigChanged - ? this.withFetchingStatus(null, 'opencode-go') - : this.withFetchingStatus(previousState.opencodeGo, 'opencode-go'), - kimi: this.withFetchingStatus(previousState.kimi, 'kimi'), - antigravity: this.withFetchingStatus(previousState.antigravity, 'antigravity'), - minimax: miniMaxConfigChanged - ? this.withFetchingStatus(null, 'minimax') - : this.withFetchingStatus(previousState.minimax, 'minimax'), - grok: this.withFetchingStatus(previousState.grok, 'grok') - }) - - const missingWslCodexHome = - codexFetchGated || codexHomePath ? null : this.getMissingWslCodexHomeResult(codexTarget) - const grokResultPromise = fetchGrokRateLimits({ - signal, - authReadResult: grokAuthReadResult - }).then( - (value) => ({ status: 'fulfilled', value }) as const, - (reason) => ({ status: 'rejected', reason }) as const - ) - - // Why: skip automated Claude fetches while a Retry-After window is open or a live session feed is fresher than the OAuth poll would be. - const claudeFetchGated = - !options?.force && this.shouldSkipAutomatedClaudeFetch(previousState.claude) - - const [claudeResult, codexResult, geminiResult, opencodeGoResult, kimiResult, miniMaxResult] = - await Promise.allSettled([ - claudeFetchGated - ? Promise.resolve(previousState.claude as ProviderRateLimits) - : fetchClaudeRateLimits({ - authPreparation: claudeAuthPreparation, - allowPtyFallback: this.shouldAllowClaudePtyFallback(claudeAuthPreparation), - allowUsagePanelSupplement: this.shouldAllowClaudeUsagePanelSupplement(), - networkProxySettings: this.networkProxySettingsResolver?.(), - signal - }), - codexFetchGated - ? Promise.resolve(previousState.codex as ProviderRateLimits) - : (missingWslCodexHome ?? - fetchCodexRateLimits({ - codexHomePath, - allowPtyFallback: this.shouldAllowCodexPtyFallback(), - signal - })), - fetchGeminiRateLimits(geminiCliOAuthEnabled), - fetchOpenCodeGoRateLimits( - cookie, - workspaceIdOverride || undefined, - this.networkProxySettingsResolver?.() - ), - this.fetchKimiWithResolvedHome(), - miniMaxConfigResult.error - ? Promise.resolve(this.getMiniMaxCredentialError(miniMaxConfigResult.error)) - : fetchMiniMaxRateLimits({ - cookie: miniMaxCookie, - groupId: miniMaxGroupId, - models: miniMaxModels - }) - ]) - - if (signal.aborted) { - return - } - - const claude = - claudeResult.status === 'fulfilled' - ? claudeResult.value - : ({ - provider: 'claude', - session: null, - weekly: null, - updatedAt: Date.now(), - error: - claudeResult.reason instanceof Error ? claudeResult.reason.message : 'Unknown error', - status: 'error' - } satisfies ProviderRateLimits) - - const codex = - codexResult.status === 'fulfilled' - ? codexResult.value - : ({ - provider: 'codex', - session: null, - weekly: null, - updatedAt: Date.now(), - error: - codexResult.reason instanceof Error ? codexResult.reason.message : 'Unknown error', - status: 'error' - } satisfies ProviderRateLimits) - - const gemini = - geminiResult.status === 'fulfilled' - ? geminiResult.value - : ({ - provider: 'gemini', - session: null, - weekly: null, - updatedAt: Date.now(), - error: - geminiResult.reason instanceof Error ? geminiResult.reason.message : 'Unknown error', - status: 'error' - } satisfies ProviderRateLimits) - - // Why: Antigravity can only borrow a *successful* Gemini read; a Gemini failure is not an Antigravity failure. - const antigravity = deriveAntigravityRateLimits(gemini) - - const opencodeGo = - opencodeGoResult.status === 'fulfilled' - ? opencodeGoResult.value - : ({ - provider: 'opencode-go', - session: null, - weekly: null, - monthly: null, - updatedAt: Date.now(), - error: - opencodeGoResult.reason instanceof Error - ? opencodeGoResult.reason.message - : 'Unknown error', - status: 'error' - } satisfies ProviderRateLimits) - - const kimi = - kimiResult.status === 'fulfilled' - ? kimiResult.value - : ({ - provider: 'kimi', - session: null, - weekly: null, - updatedAt: Date.now(), - error: kimiResult.reason instanceof Error ? kimiResult.reason.message : 'Unknown error', - status: 'error' - } satisfies ProviderRateLimits) - - const miniMax = - miniMaxResult.status === 'fulfilled' - ? miniMaxResult.value - : ({ - provider: 'minimax', - session: null, - weekly: null, - updatedAt: Date.now(), - error: - miniMaxResult.reason instanceof Error - ? miniMaxResult.reason.message - : 'Unknown error', - status: 'error' - } satisfies ProviderRateLimits) - - const latestCodexHome = this.resolveCodexHome(codexTarget) - const latestClaudeAuthPreparation = await this.claudeAuthPreparationResolver?.(claudeTarget) - if (signal.aborted) { - return - } - const latestClaudeProvenance = latestClaudeAuthPreparation?.provenance ?? 'system' - // Why: a finishing skip has no provenance, so an in-flight result must never be - // applied as though the target had become the system default (#STA-4422). - const shouldApplyCodex = - !codexFetchGated && - !latestCodexHome.skip && - codexGeneration === this.codexFetchGeneration && - codexProvenance === this.getCodexProvenance(codexTarget, latestCodexHome.homePath) - const codexBecameUnavailable = - !codexFetchGated && latestCodexHome.skip && codexGeneration === this.codexFetchGeneration - // Why: a gated cycle made no Claude attempt; applying its passthrough result would grow the failure streak and reset stale-policy clocks for free. - const shouldApplyClaude = - !claudeFetchGated && - claudeGeneration === this.claudeFetchGeneration && - claudeProvenance === latestClaudeProvenance && - this.isSameClaudeTarget(claudeTarget, this.claudeFetchTarget) - const shouldApplyOpencode = opencodeGeneration === this.opencodeFetchGeneration - const shouldApplyMiniMax = miniMaxGeneration === this.minimaxFetchGeneration - - if (shouldApplyClaude) { - this.trackActiveFailureStreak('claude', claude) - } - if (shouldApplyCodex) { - this.trackActiveFailureStreak('codex', codex) - } - this.trackActiveFailureStreak('gemini', gemini) - this.trackActiveFailureStreak('antigravity', antigravity) - if (shouldApplyOpencode) { - this.trackActiveFailureStreak('opencode-go', opencodeGo) - } - this.trackActiveFailureStreak('kimi', kimi) - if (shouldApplyMiniMax) { - this.trackActiveFailureStreak('minimax', miniMax) - } - - // Why: apply a Codex result only when provenance and generation still match, else a raced in-flight fetch overwrites the new account. - this.updateState({ - ...this.state, - claude: shouldApplyClaude - ? this.resolveClaudeFetchApply(claude, previousState.claude) - : this.state.claude, - codex: shouldApplyCodex - ? this.applyStalePolicy(codex, previousState.codex) - : codexBecameUnavailable - ? codexStateBeforeFetch - : this.state.codex, - gemini: this.applyStalePolicy(gemini, previousState.gemini), - opencodeGo: shouldApplyOpencode - ? opencodeConfigChanged - ? opencodeGo - : this.applyStalePolicy(opencodeGo, previousState.opencodeGo) - : this.state.opencodeGo, - kimi: this.applyStalePolicy(kimi, previousState.kimi), - antigravity: this.applyStalePolicy(antigravity, previousState.antigravity), - minimax: shouldApplyMiniMax - ? miniMaxConfigChanged - ? miniMax - : this.applyStalePolicy(miniMax, previousState.minimax) - : this.state.minimax - }) - - const grokResult = await grokResultPromise - if (signal.aborted) { - return - } - const grok = - grokResult.status === 'fulfilled' - ? grokResult.value - : ({ - provider: 'grok', - session: null, - weekly: null, - updatedAt: Date.now(), - error: grokResult.reason instanceof Error ? grokResult.reason.message : 'Unknown error', - status: 'error' - } satisfies ProviderRateLimits) - this.trackActiveFailureStreak('grok', grok) - this.updateState({ - ...this.state, - grok: this.applyStalePolicy(grok, previousState.grok) - }) - } - - private async runFetchCodexOnlyCycle(signal: AbortSignal): Promise { - if (signal.aborted) { - return - } - const codexTarget = this.codexFetchTarget - const codexGeneration = this.codexFetchGeneration - const codexHome = this.resolveCodexHome(codexTarget) - // Why: return before the "fetching" mark — a skipped cycle never settles it (#STA-4422). - if (codexHome.skip) { - if ( - codexGeneration === this.codexFetchGeneration && - this.state.codex?.status === 'fetching' - ) { - this.updateState({ ...this.state, codex: null }) - } - return - } - const codexHomePath = codexHome.homePath - const codexProvenance = this.getCodexProvenance(codexTarget, codexHomePath) - const previousState = this.state - - this.updateState({ - ...previousState, - codex: this.withFetchingStatus(previousState.codex, 'codex') - }) - - const missingWslCodexHome = codexHomePath - ? null - : this.getMissingWslCodexHomeResult(codexTarget) - const codex = await ( - missingWslCodexHome - ? Promise.resolve(missingWslCodexHome) - : fetchCodexRateLimits({ - codexHomePath, - allowPtyFallback: this.shouldAllowCodexPtyFallback(), - signal - }) - ).catch((err): ProviderRateLimits => ({ - provider: 'codex', - session: null, - weekly: null, - updatedAt: Date.now(), - error: err instanceof Error ? err.message : 'Unknown error', - status: 'error' - })) - - if (signal.aborted) { - return - } - - const latestCodexHome = this.resolveCodexHome(codexTarget) - if (latestCodexHome.skip && codexGeneration === this.codexFetchGeneration) { - this.updateState({ - ...this.state, - codex: previousState.codex?.status === 'fetching' ? null : previousState.codex - }) - return - } - const shouldApplyCodex = - !latestCodexHome.skip && - codexGeneration === this.codexFetchGeneration && - codexProvenance === this.getCodexProvenance(codexTarget, latestCodexHome.homePath) - - if (shouldApplyCodex) { - this.trackActiveFailureStreak('codex', codex) - } - this.updateState({ - ...this.state, - codex: shouldApplyCodex ? this.applyStalePolicy(codex, previousState.codex) : this.state.codex - }) - } - - private async runFetchClaudeOnlyCycle( - signal: AbortSignal, - options?: { force?: boolean } - ): Promise { - if (signal.aborted) { - return - } - // Why: skip automated Claude fetches while a Retry-After window is open or a live session feed is fresher than the OAuth poll would be. - if (!options?.force && this.shouldSkipAutomatedClaudeFetch(this.state.claude)) { - return - } - const claudeTarget = this.claudeFetchTarget - // Why: capture before the resolver await so an account switch during it invalidates both the snapshot and the state apply. - const claudeGeneration = this.claudeFetchGeneration - const claudeAuthPreparation = await this.claudeAuthPreparationResolver?.(claudeTarget) - if (signal.aborted) { - return - } - this.rememberClaudeAuthSnapshot(claudeAuthPreparation, claudeGeneration, claudeTarget) - const claudeProvenance = claudeAuthPreparation?.provenance ?? 'system' - const previousState = this.state - - this.updateState({ - ...previousState, - claude: this.withFetchingStatus(previousState.claude, 'claude') - }) - - const claude = await fetchClaudeRateLimits({ - authPreparation: claudeAuthPreparation, - allowPtyFallback: this.shouldAllowClaudePtyFallback(claudeAuthPreparation), - allowUsagePanelSupplement: this.shouldAllowClaudeUsagePanelSupplement(), - networkProxySettings: this.networkProxySettingsResolver?.(), - signal - }).catch((err): ProviderRateLimits => ({ - provider: 'claude', - session: null, - weekly: null, - updatedAt: Date.now(), - error: err instanceof Error ? err.message : 'Unknown error', - status: 'error' - })) - - if (signal.aborted) { - return - } - - const latestClaudeAuthPreparation = await this.claudeAuthPreparationResolver?.(claudeTarget) - if (signal.aborted) { - return - } - const latestClaudeProvenance = latestClaudeAuthPreparation?.provenance ?? 'system' - const shouldApplyClaude = - claudeGeneration === this.claudeFetchGeneration && - claudeProvenance === latestClaudeProvenance && - this.isSameClaudeTarget(claudeTarget, this.claudeFetchTarget) - - if (shouldApplyClaude) { - this.trackActiveFailureStreak('claude', claude) - } - this.updateState({ - ...this.state, - claude: shouldApplyClaude - ? this.resolveClaudeFetchApply(claude, previousState.claude) - : this.state.claude - }) - } - - private async runFetchGrokOnlyCycle(signal: AbortSignal): Promise { - if (signal.aborted) { - return - } - const previousState = this.state - const grokAuthReadResult = readGrokAuthSession() - this.grokAuthConfigured = grokAuthReadResult.status === 'ok' - - this.updateState({ - ...previousState, - grok: this.withFetchingStatus(previousState.grok, 'grok') - }) - - const grok = await fetchGrokRateLimits({ - signal, - authReadResult: grokAuthReadResult - }).catch((err): ProviderRateLimits => ({ - provider: 'grok', - session: null, - weekly: null, - updatedAt: Date.now(), - error: err instanceof Error ? err.message : 'Unknown error', - status: 'error' - })) - - if (signal.aborted) { - return - } - - this.trackActiveFailureStreak('grok', grok) - this.updateState({ - ...this.state, - grok: this.applyStalePolicy(grok, previousState.grok) - }) - } - - private applyStalePolicy( - fresh: ProviderRateLimits, - previous: ProviderRateLimits | null - ): ProviderRateLimits { - // Fresh data is fine — use it - if (fresh.status === 'ok') { - return { - ...fresh, - usageMetadata: { - ...fresh.usageMetadata, - lastSuccessfulSource: - fresh.usageMetadata?.source ?? fresh.usageMetadata?.lastSuccessfulSource - } - } - } - - // Explicitly unavailable (e.g. setting cleared): discard stale data so the UI shows the provider as disabled/unconfigured. - if (fresh.status === 'unavailable') { - return fresh - } - - const previousHasData = Boolean( - previous?.session || - previous?.weekly || - previous?.fableWeekly || - previous?.monthly || - (previous?.buckets && previous.buckets.length > 0) - ) - - // No previous data to fall back on - if (!previous || !previousHasData) { - return fresh - } - - // Previous data is too old — don't show stale data - const staleThresholdMs = - fresh.usageMetadata?.failureKind === 'rate-limited' - ? RATE_LIMITED_STALE_THRESHOLD_MS - : STALE_THRESHOLD_MS - if (Date.now() - previous.updatedAt > staleThresholdMs) { - return fresh - } - - // Why: keep showing a recent snapshot through repeated transient failures until it ages out, so the bar doesn't flap to empty. - return { - ...previous, - error: fresh.error, - status: 'error', - usageMetadata: { - ...previous.usageMetadata, - ...fresh.usageMetadata, - lastSuccessfulSource: - previous.usageMetadata?.lastSuccessfulSource ?? previous.usageMetadata?.source - } - } - } - - private buildInactiveArray( - cache: Map, - fetching: Set - ): InactiveAccountUsage[] { - const result: InactiveAccountUsage[] = [] - for (const [accountId, limits] of cache) { - result.push({ - accountId, - rateLimits: limits, - updatedAt: limits.updatedAt, - isFetching: fetching.has(accountId) - }) - } - // Why: include fetching-but-uncached accounts so the renderer shows a loading indicator for newly added accounts. - for (const accountId of fetching) { - if (!cache.has(accountId)) { - result.push({ - accountId, - rateLimits: null, - updatedAt: 0, - isFetching: true - }) - } - } - return result - } - - private updateState(next: InternalRateLimitState): void { - this.state = next - this.pushToRenderer() - } - - private pushToRenderer(): void { - const state = this.getState() - for (const listener of this.stateListeners) { - try { - listener(state) - } catch { - // ignore — one bad listener must not break the others - } - } - if (!this.mainWindow || this.mainWindow.isDestroyed()) { - return - } - this.mainWindow.webContents.send('rateLimits:update', state) - } -} +/** + * Coordinates provider quota polling and publishes a stable rate-limit snapshot. + * The implementation is layered by lifecycle, account selection, and fetch policy + * so each module stays small while this path remains the public integration seam. + */ +export class RateLimitService extends RateLimitServiceConfiguration {} diff --git a/src/main/rate-limits/service/service-account-refresh.ts b/src/main/rate-limits/service/service-account-refresh.ts new file mode 100644 index 00000000000..9e758a98518 --- /dev/null +++ b/src/main/rate-limits/service/service-account-refresh.ts @@ -0,0 +1,182 @@ +import { consumeCodexRateLimitResetCredit } from '../codex-fetcher' +import { RateLimitServiceInactiveAccounts } from './service-inactive-accounts' +import { + normalizeCodexAccountSelectionTarget, + normalizeClaudeAccountSelectionTarget, + type CodexAccountSelectionTarget, + type ClaudeAccountSelectionTarget, + type RateLimitRuntimeTarget, + type RateLimitState, + type CodexRateLimitResetResult +} from './service-types' + +export abstract class RateLimitServiceAccountRefresh extends RateLimitServiceInactiveAccounts { + async refresh(): Promise { + // Why: this user-directed refresh must bypass the poll throttle, else the click can no-op after wake/focus and feel broken. + await this.fetchAll({ force: true }) + return this.getState() + } + + async refreshIfStale(): Promise { + // Why: reconnecting mobile subscribers need fresh backgrounded-desktop data, but replaying a subscription must not queue another forced fetch. + const plan = this.getActiveWindowRefreshPlan(Date.now()) + await this.runActiveWindowRefreshPlan(plan) + return this.getState() + } + + async refreshGrok(): Promise { + await this.fetchGrokOnly({ force: true }) + return this.getState() + } + + invalidateMiniMaxCredentialState(): void { + this.minimaxFetchGeneration += 1 + // Why: saving/forgetting the cookie can race an in-flight fetch; clear the visible snapshot before any old-cookie result returns. + this.updateState({ + ...this.state, + minimax: this.withFetchingStatus(null, 'minimax') + }) + } + + async refreshForCodexAccountChange( + outgoingAccountId?: string | null, + target?: CodexAccountSelectionTarget + ): Promise { + const nextTarget = normalizeCodexAccountSelectionTarget(target) + // Why: weekly-only plans report no session window, so gating on session alone + // dropped their snapshot and left the switcher's inline bars empty. + if ( + outgoingAccountId && + (this.state.codex?.session || this.state.codex?.weekly) && + this.isSameCodexTarget(this.codexFetchTarget, nextTarget) + ) { + this.inactiveCodexCache.set(outgoingAccountId, this.state.codex) + } + this.codexFetchTarget = nextTarget + this.codexFetchGeneration += 1 + // Why: a new account/target starts with a clean retry schedule. + this.activeFailureStreakByProvider.codex = 0 + this.inactiveCodexAccountsGeneration += 1 + this.pruneInactiveCodexState() + // Why: the switch must NOT reset the inactive-fetch debounce — re-probing + // every inactive account per switch spawns codex in each credential home + // and endangers rotating refresh tokens; the switcher shows the cached + // snapshot (seeded above for the outgoing account) until the debounce ends. + // Why: clear the old Codex view immediately, else the previous account's limits show under the newly selected identity until the next poll. + this.updateState({ + ...this.state, + codex: this.withFetchingStatus(null, 'codex') + }) + await this.fetchCodexOnly({ force: true }) + return this.getState() + } + + async refreshCodexForTarget(target?: CodexAccountSelectionTarget): Promise { + const nextTarget = normalizeCodexAccountSelectionTarget(target) + const targetChanged = !this.isSameCodexTarget(this.codexFetchTarget, nextTarget) + this.codexFetchTarget = nextTarget + this.codexFetchGeneration += 1 + this.activeFailureStreakByProvider.codex = 0 + this.updateState({ + ...this.state, + codex: this.withFetchingStatus(targetChanged ? null : this.state.codex, 'codex') + }) + await this.fetchCodexOnly({ force: true }) + return this.getState() + } + + async consumeCodexRateLimitResetCredit(options: { + idempotencyKey: string + target: RateLimitRuntimeTarget + codexHomePath: string | null + }): Promise { + const codexTarget = normalizeCodexAccountSelectionTarget(options.target) + const codexHomePath = options.codexHomePath + const scopedStateBeforeReset = this.getState() + const missingWslCodexHome = codexHomePath + ? null + : this.getMissingWslCodexHomeResult(codexTarget) + if (missingWslCodexHome) { + if (this.isSameCodexTarget(this.codexFetchTarget, codexTarget)) { + await this.fetchCodexOnly({ force: true }) + } + throw new Error(missingWslCodexHome.error ?? 'Codex home unavailable') + } + try { + const outcome = await consumeCodexRateLimitResetCredit({ + codexHomePath, + idempotencyKey: options.idempotencyKey + }) + const state = await this.fetchCodexResetResultState( + codexTarget, + codexHomePath, + scopedStateBeforeReset + ) + return { outcome, state } + } catch (error) { + if (this.isSameCodexTarget(this.codexFetchTarget, codexTarget)) { + await this.fetchCodexOnly({ force: true }) + } + throw error + } + } + + async refreshForClaudeAccountChange( + outgoingAccountId?: string | null, + target?: ClaudeAccountSelectionTarget + ): Promise { + const nextTarget = normalizeClaudeAccountSelectionTarget(target) + // Why: snapshot the outgoing account's usage before clearing so the switcher's inline bars can show last-known data immediately. + if ( + outgoingAccountId && + this.state.claude?.session && + this.isSameClaudeTarget(this.claudeFetchTarget, nextTarget) + ) { + this.inactiveClaudeCache.set(outgoingAccountId, this.state.claude) + } + this.claudeFetchTarget = nextTarget + this.inactiveClaudeAccountsGeneration += 1 + this.pruneInactiveClaudeState() + this.claudeFetchGeneration += 1 + // Why: a new account/target starts with a clean retry schedule. + this.activeFailureStreakByProvider.claude = 0 + // Why: statusline posts from the outgoing account's sessions must not land on the incoming account's bar mid-switch. + this.lastClaudeAuthSnapshot = null + this.lastInactiveClaudeFetchAt = 0 + this.updateState({ + ...this.state, + claude: this.withFetchingStatus(null, 'claude') + }) + await this.fetchClaudeOnly({ force: true }) + return this.getState() + } + + async refreshClaudeForTarget(target?: ClaudeAccountSelectionTarget): Promise { + const nextTarget = normalizeClaudeAccountSelectionTarget(target) + const targetChanged = !this.isSameClaudeTarget(this.claudeFetchTarget, nextTarget) + this.claudeFetchTarget = nextTarget + this.claudeFetchGeneration += 1 + this.activeFailureStreakByProvider.claude = 0 + if (targetChanged) { + // Why: statusline posts from the outgoing target's sessions must not land on the incoming target's bar mid-switch. + this.lastClaudeAuthSnapshot = null + } + this.updateState({ + ...this.state, + claude: this.withFetchingStatus(targetChanged ? null : this.state.claude, 'claude') + }) + await this.fetchClaudeOnly({ force: true }) + return this.getState() + } + + async refreshAfterClaudeLivePtysDrained(): Promise { + // Why: "Waiting for Claude session" can only recover once no live claude + // owns the credentials. Refetch on the last PTY exit instead of leaving + // the stale terminal error up until the failure backoff elapses. + if (!this.state.claude?.usageMetadata?.deferredByLiveClaudeSession) { + return + } + this.activeFailureStreakByProvider.claude = 0 + await this.fetchClaudeOnly({ force: true }) + } +} diff --git a/src/main/rate-limits/service/service-configuration.ts b/src/main/rate-limits/service/service-configuration.ts new file mode 100644 index 00000000000..52aa02ccddd --- /dev/null +++ b/src/main/rate-limits/service/service-configuration.ts @@ -0,0 +1,139 @@ +import type { BrowserWindow } from 'electron' +import { hasMiniMaxSessionCookie } from '../../minimax/minimax-cookie-store' +import { RateLimitServiceAccountRefresh } from './service-account-refresh' +import { + type CodexAccountSelectionTarget, + type CodexHomePathResolver, + type KimiHomeResolver, + type ClaudeAccountSelectionTarget, + type ClaudeAuthPreparationResolver, + type OpenCodeGoRateLimitConfig, + type MiniMaxRateLimitConfig, + type GeminiCliOAuthEnabledResolver, + type InactiveCodexAccountInfo, + type InactiveClaudeAccountInfo, + type RateLimitState, + normalizeCodexAccountSelectionTarget, + normalizeClaudeAccountSelectionTarget, + type NetworkProxySettings +} from './service-types' + +export abstract class RateLimitServiceConfiguration extends RateLimitServiceAccountRefresh { + setCodexHomePathResolver(resolver: CodexHomePathResolver): void { + this.codexHomePathResolver = resolver + } + + setCodexFetchTarget(target?: CodexAccountSelectionTarget): void { + this.codexFetchTarget = normalizeCodexAccountSelectionTarget(target) + } + + setKimiHomeResolver(resolver: KimiHomeResolver): void { + this.kimiHomeResolver = resolver + } + + setClaudeAuthPreparationResolver(resolver: ClaudeAuthPreparationResolver): void { + this.claudeAuthPreparationResolver = resolver + } + + setClaudeFetchTarget(target?: ClaudeAccountSelectionTarget): void { + this.claudeFetchTarget = normalizeClaudeAccountSelectionTarget(target) + } + + setOpenCodeGoConfigResolver(resolver: () => OpenCodeGoRateLimitConfig): void { + this.openCodeGoConfigResolver = resolver + } + + setMiniMaxConfigResolver(resolver: () => MiniMaxRateLimitConfig): void { + this.miniMaxConfigResolver = resolver + } + + setGeminiCliOAuthEnabledResolver(resolver: GeminiCliOAuthEnabledResolver): void { + this.geminiCliOAuthEnabledResolver = resolver + } + + setNetworkProxySettingsResolver(resolver: () => NetworkProxySettings): void { + this.networkProxySettingsResolver = resolver + } + + setInactiveClaudeAccountsResolver(resolver: () => InactiveClaudeAccountInfo[]): void { + this.inactiveClaudeAccountsResolver = resolver + this.inactiveClaudeAccountsGeneration += 1 + } + + setInactiveCodexAccountsResolver(resolver: () => InactiveCodexAccountInfo[]): void { + this.inactiveCodexAccountsResolver = resolver + this.inactiveCodexAccountsGeneration += 1 + this.pruneInactiveCodexState() + } + attach(mainWindow: BrowserWindow): void { + this.detachWindowListeners?.() + this.mainWindow = mainWindow + const refreshOnResume = (): void => { + void this.refreshIfWindowActive() + } + // Why: attach() can replace windows; remove the previous closed listener too, not only the focus listeners. + const detachWindowListeners = (): void => { + mainWindow.removeListener('focus', refreshOnResume) + mainWindow.removeListener('show', refreshOnResume) + mainWindow.removeListener('restore', refreshOnResume) + mainWindow.removeListener('closed', onClosed) + } + const onClosed = (): void => { + detachWindowListeners() + if (this.detachWindowListeners === detachWindowListeners) { + this.detachWindowListeners = null + } + if (this.mainWindow === mainWindow) { + this.mainWindow = null + } + } + mainWindow.on('focus', refreshOnResume) + mainWindow.on('show', refreshOnResume) + mainWindow.on('restore', refreshOnResume) + mainWindow.on('closed', onClosed) + this.detachWindowListeners = detachWindowListeners + } + + start(options: { fetchImmediately?: boolean } = {}): void { + if (options.fetchImmediately !== false) { + void this.fetchAll() + } else { + this.scheduleDeferredStartupRefresh() + } + this.startTimer() + } + + stop(): void { + this.abortActiveFetchCycle() + this.clearQueuedFetches() + this.inactiveClaudeFetching.clear() + this.inactiveCodexFetching.clear() + this.resolveAndClearFetchIdleWaiters() + this.stopTimer() + this.clearDeferredStartupRefresh() + this.detachWindowListeners?.() + this.detachWindowListeners = null + this.mainWindow = null + } + + getState(): RateLimitState { + this.pruneInactiveClaudeState() + this.pruneInactiveCodexState() + return { + ...this.state, + // Why: the cookie lives on the filesystem, not GlobalSettings; surface its presence so the renderer keeps the MiniMax bar across reloads. + minimaxCookieConfigured: hasMiniMaxSessionCookie(), + grokAuthConfigured: this.grokAuthConfigured, + claudeTarget: this.claudeFetchTarget, + codexTarget: this.codexFetchTarget, + inactiveClaudeAccounts: this.buildInactiveArray( + this.inactiveClaudeCache, + this.inactiveClaudeFetching + ), + inactiveCodexAccounts: this.buildInactiveArray( + this.inactiveCodexCache, + this.inactiveCodexFetching + ) + } + } +} diff --git a/src/main/rate-limits/service/service-fetch-control.ts b/src/main/rate-limits/service/service-fetch-control.ts new file mode 100644 index 00000000000..cab01551def --- /dev/null +++ b/src/main/rate-limits/service/service-fetch-control.ts @@ -0,0 +1,80 @@ +import { RateLimitServiceState } from './service-state' + +export abstract class RateLimitServiceFetchControl extends RateLimitServiceState { + protected waitForFetchIdle(): Promise { + if ( + !this.isFetching && + !this.fullFetchQueued && + !this.codexOnlyFetchQueued && + !this.claudeOnlyFetchQueued && + !this.grokOnlyFetchQueued + ) { + return Promise.resolve() + } + // Why: explicit-refresh callers must await the queued follow-up cycle when a poll is in flight, else the UI stops spinning early. + return new Promise((resolve) => { + this.fetchIdleResolvers.push(resolve) + }) + } + + protected resolveFetchIdleWaiters(): void { + if ( + this.isFetching || + this.fullFetchQueued || + this.codexOnlyFetchQueued || + this.claudeOnlyFetchQueued || + this.grokOnlyFetchQueued + ) { + return + } + const resolvers = this.fetchIdleResolvers + this.fetchIdleResolvers = [] + for (const resolve of resolvers) { + resolve() + } + } + + protected beginFetchCycle(): AbortController { + const controller = new AbortController() + this.activeFetchAbortControllers.add(controller) + return controller + } + + protected finishFetchCycle(controller: AbortController): void { + this.activeFetchAbortControllers.delete(controller) + } + + protected async runWithFetchAbortSignal( + fn: (signal: AbortSignal) => Promise + ): Promise { + const controller = this.beginFetchCycle() + try { + await fn(controller.signal) + return controller.signal + } finally { + this.finishFetchCycle(controller) + } + } + + protected abortActiveFetchCycle(): void { + for (const controller of this.activeFetchAbortControllers) { + controller.abort() + } + this.activeFetchAbortControllers.clear() + } + + protected clearQueuedFetches(): void { + this.fullFetchQueued = false + this.codexOnlyFetchQueued = false + this.claudeOnlyFetchQueued = false + this.grokOnlyFetchQueued = false + } + + protected resolveAndClearFetchIdleWaiters(): void { + const resolvers = this.fetchIdleResolvers + this.fetchIdleResolvers = [] + for (const resolve of resolvers) { + resolve() + } + } +} diff --git a/src/main/rate-limits/service/service-fetch-policy.ts b/src/main/rate-limits/service/service-fetch-policy.ts new file mode 100644 index 00000000000..2c28c519a14 --- /dev/null +++ b/src/main/rate-limits/service/service-fetch-policy.ts @@ -0,0 +1,139 @@ +import { RateLimitServiceFetchTargets } from './service-fetch-targets' +import { + LIVE_CLAUDE_INGEST_DEDUPE_MS, + MIN_REFETCH_MS, + isSameUsageWindow, + normalizeClaudeConfigDir, + type ClaudeRuntimeAuthPreparation, + type ClaudeStatusLineRateLimits, + type NormalizedClaudeAccountSelectionTarget, + type ProviderRateLimits +} from './service-types' +import { mapClaudeUsageWindow } from '../claude-usage-window' + +export abstract class RateLimitServiceFetchPolicy extends RateLimitServiceFetchTargets { + protected getMiniMaxCredentialError(message: string): ProviderRateLimits { + return { + provider: 'minimax', + session: null, + weekly: null, + updatedAt: Date.now(), + error: message, + status: 'error', + usageMetadata: { failureKind: 'keychain-unavailable', source: 'web' } + } + } + + // Why: hitting a usage endpoint before its Retry-After expires burns the budget for nothing and keeps the 429 window alive. + protected isRetryAfterActive(limits: ProviderRateLimits | null): boolean { + return Boolean( + limits?.status === 'error' && + limits.usageMetadata?.retryAtMs && + limits.usageMetadata.retryAtMs > Date.now() + ) + } + + // Why: a live Claude session already streams fresh usage windows; spending the OAuth usage endpoint's tight budget on the same data invites 429s. + protected isLiveClaudeUsageFresh(limits: ProviderRateLimits | null): boolean { + return Boolean( + limits?.status === 'ok' && + limits.usageMetadata?.source === 'live-session' && + Date.now() - limits.updatedAt < MIN_REFETCH_MS + ) + } + + protected shouldSkipAutomatedClaudeFetch(limits: ProviderRateLimits | null): boolean { + return this.isRetryAfterActive(limits) || this.isLiveClaudeUsageFresh(limits) + } + + protected resolveClaudeFetchApply( + fresh: ProviderRateLimits, + previous: ProviderRateLimits | null + ): ProviderRateLimits { + // Why: a live statusline post can land while an OAuth cycle is in flight; a failed fetch must not + // roll the bar back to the pre-cycle snapshot or flip the just-refreshed live data to error. + const current = this.state.claude + if (fresh.status !== 'ok' && current && this.isLiveClaudeUsageFresh(current)) { + return current + } + return this.applyStalePolicy(fresh, previous) + } + + protected rememberClaudeAuthSnapshot( + authPreparation: ClaudeRuntimeAuthPreparation | undefined, + claudeGeneration: number, + claudeTarget: NormalizedClaudeAccountSelectionTarget + ): void { + // Why: an account switch during the resolver await already cleared the snapshot; restoring the outgoing account's configDir here would cross-attribute its live posts to the new bar. + if ( + claudeGeneration !== this.claudeFetchGeneration || + !this.isSameClaudeTarget(claudeTarget, this.claudeFetchTarget) + ) { + return + } + this.lastClaudeAuthSnapshot = { + configDir: normalizeClaudeConfigDir(authPreparation?.envPatch.CLAUDE_CONFIG_DIR), + provenance: authPreparation?.provenance ?? 'system' + } + } + + /** Live usage windows forwarded from a Claude session's statusLine command. */ + ingestLiveClaudeRateLimits(event: ClaudeStatusLineRateLimits): void { + // Why: attribution needs the selected account's config dir; until a fetch cycle captures it, drop posts rather than guess the account. + const snapshot = this.lastClaudeAuthSnapshot + if (!snapshot) { + // Why: breadcrumbs make a silently dark live feed diagnosable — dropped posts are otherwise invisible. + console.debug('[rate-limits] dropped live Claude usage: no auth snapshot yet', { + eventConfigDir: event.configDir + }) + return + } + // Why: sessions of other accounts (or other runtimes) report their own quota; mixing them into the active account's bar would lie. + if (normalizeClaudeConfigDir(event.configDir) !== snapshot.configDir) { + console.debug('[rate-limits] dropped live Claude usage: configDir mismatch', { + eventConfigDir: event.configDir, + snapshotConfigDir: snapshot.configDir + }) + return + } + const freshSession = mapClaudeUsageWindow(event.fiveHour ?? undefined, 300) + const freshWeekly = mapClaudeUsageWindow(event.sevenDay ?? undefined, 10080) + if (!freshSession && !freshWeekly) { + return + } + const previous = this.state.claude + // Why: statusline payloads can carry a single window; an absent one means "no update", not "cleared" — keep the other bar populated. + const session = freshSession ?? previous?.session ?? null + const weekly = freshWeekly ?? previous?.weekly ?? null + if ( + previous?.status === 'ok' && + previous.usageMetadata?.source === 'live-session' && + Date.now() - previous.updatedAt < LIVE_CLAUDE_INGEST_DEDUPE_MS && + isSameUsageWindow(previous.session, session) && + isSameUsageWindow(previous.weekly, weekly) + ) { + return + } + this.activeFailureStreakByProvider.claude = 0 + this.updateState({ + ...this.state, + claude: { + provider: 'claude', + session, + weekly, + // Why: the statusline payload has no Fable scoped window; keep the last OAuth-provided one visible. + // Tradeoff: while live posts keep the OAuth poll gated, fableWeekly stays frozen until the session idles past the freshness window. + fableWeekly: previous?.fableWeekly ?? null, + updatedAt: Date.now(), + error: null, + status: 'ok', + usageMetadata: { + source: 'live-session', + lastSuccessfulSource: 'live-session', + credentialSource: previous?.usageMetadata?.credentialSource, + authProvenance: snapshot.provenance + } + } + }) + } +} diff --git a/src/main/rate-limits/service/service-fetch-queue.ts b/src/main/rate-limits/service/service-fetch-queue.ts new file mode 100644 index 00000000000..49531ee3fb1 --- /dev/null +++ b/src/main/rate-limits/service/service-fetch-queue.ts @@ -0,0 +1,245 @@ +import { RateLimitServiceProviderCycles } from './service-provider-cycles' + +export abstract class RateLimitServiceFetchQueue extends RateLimitServiceProviderCycles { + protected async fetchAll(options?: { force?: boolean }): Promise { + if (this.isFetching) { + if (options?.force) { + this.fullFetchQueued = true + return this.waitForFetchIdle() + } + return + } + this.isFetching = true + + try { + let shouldContinue = true + // Why: only user-directed (force) fetches may bypass a provider's Retry-After gate; queued reruns inherit force because only forced calls queue them. + let cycleForce = options?.force ?? false + while (shouldContinue) { + const signal = await this.runWithFetchAbortSignal((fetchSignal) => + this.runFetchAllCycle(fetchSignal, { force: cycleForce }) + ) + shouldContinue = false + cycleForce = true + if (signal.aborted) { + break + } + if (this.fullFetchQueued) { + this.fullFetchQueued = false + shouldContinue = true + continue + } + if (this.codexOnlyFetchQueued) { + this.codexOnlyFetchQueued = false + const codexSignal = await this.runWithFetchAbortSignal((fetchSignal) => + this.runFetchCodexOnlyCycle(fetchSignal) + ) + if (codexSignal.aborted) { + break + } + } + if (this.claudeOnlyFetchQueued) { + this.claudeOnlyFetchQueued = false + const claudeSignal = await this.runWithFetchAbortSignal((fetchSignal) => + this.runFetchClaudeOnlyCycle(fetchSignal, { force: true }) + ) + if (claudeSignal.aborted) { + break + } + } + if (this.grokOnlyFetchQueued) { + this.grokOnlyFetchQueued = false + const grokSignal = await this.runWithFetchAbortSignal((fetchSignal) => + this.runFetchGrokOnlyCycle(fetchSignal) + ) + if (grokSignal.aborted) { + break + } + } + } + } finally { + this.isFetching = false + this.resolveFetchIdleWaiters() + } + } + + protected async fetchCodexOnly(options?: { force?: boolean }): Promise { + if (this.isFetching) { + if (options?.force) { + this.codexOnlyFetchQueued = true + return this.waitForFetchIdle() + } + return + } + this.isFetching = true + + try { + let shouldContinue = true + while (shouldContinue) { + const signal = await this.runWithFetchAbortSignal((fetchSignal) => + this.runFetchCodexOnlyCycle(fetchSignal) + ) + shouldContinue = false + if (signal.aborted) { + break + } + if (this.fullFetchQueued) { + this.fullFetchQueued = false + const fullSignal = await this.runWithFetchAbortSignal((fetchSignal) => + this.runFetchAllCycle(fetchSignal, { force: true }) + ) + if (fullSignal.aborted) { + break + } + continue + } + if (this.codexOnlyFetchQueued) { + this.codexOnlyFetchQueued = false + shouldContinue = true + } + if (this.claudeOnlyFetchQueued) { + this.claudeOnlyFetchQueued = false + const claudeSignal = await this.runWithFetchAbortSignal((fetchSignal) => + this.runFetchClaudeOnlyCycle(fetchSignal, { force: true }) + ) + if (claudeSignal.aborted) { + break + } + } + if (this.grokOnlyFetchQueued) { + this.grokOnlyFetchQueued = false + const grokSignal = await this.runWithFetchAbortSignal((fetchSignal) => + this.runFetchGrokOnlyCycle(fetchSignal) + ) + if (grokSignal.aborted) { + break + } + } + } + } finally { + this.isFetching = false + this.resolveFetchIdleWaiters() + } + } + + protected async fetchClaudeOnly(options?: { force?: boolean }): Promise { + if (this.isFetching) { + if (options?.force) { + this.claudeOnlyFetchQueued = true + return this.waitForFetchIdle() + } + return + } + this.isFetching = true + + try { + let shouldContinue = true + // Why: only user-directed (force) fetches may bypass a provider's Retry-After gate; queued reruns inherit force because only forced calls queue them. + let cycleForce = options?.force ?? false + while (shouldContinue) { + const signal = await this.runWithFetchAbortSignal((fetchSignal) => + this.runFetchClaudeOnlyCycle(fetchSignal, { force: cycleForce }) + ) + shouldContinue = false + cycleForce = true + if (signal.aborted) { + break + } + if (this.fullFetchQueued) { + this.fullFetchQueued = false + const fullSignal = await this.runWithFetchAbortSignal((fetchSignal) => + this.runFetchAllCycle(fetchSignal, { force: true }) + ) + if (fullSignal.aborted) { + break + } + continue + } + if (this.claudeOnlyFetchQueued) { + this.claudeOnlyFetchQueued = false + shouldContinue = true + } + if (this.codexOnlyFetchQueued) { + this.codexOnlyFetchQueued = false + const codexSignal = await this.runWithFetchAbortSignal((fetchSignal) => + this.runFetchCodexOnlyCycle(fetchSignal) + ) + if (codexSignal.aborted) { + break + } + } + if (this.grokOnlyFetchQueued) { + this.grokOnlyFetchQueued = false + const grokSignal = await this.runWithFetchAbortSignal((fetchSignal) => + this.runFetchGrokOnlyCycle(fetchSignal) + ) + if (grokSignal.aborted) { + break + } + } + } + } finally { + this.isFetching = false + this.resolveFetchIdleWaiters() + } + } + + protected async fetchGrokOnly(options?: { force?: boolean }): Promise { + if (this.isFetching) { + if (options?.force) { + this.grokOnlyFetchQueued = true + return this.waitForFetchIdle() + } + return + } + this.isFetching = true + + try { + let shouldContinue = true + while (shouldContinue) { + const signal = await this.runWithFetchAbortSignal((fetchSignal) => + this.runFetchGrokOnlyCycle(fetchSignal) + ) + shouldContinue = false + if (signal.aborted) { + break + } + if (this.fullFetchQueued) { + this.fullFetchQueued = false + const fullSignal = await this.runWithFetchAbortSignal((fetchSignal) => + this.runFetchAllCycle(fetchSignal, { force: true }) + ) + if (fullSignal.aborted) { + break + } + continue + } + if (this.grokOnlyFetchQueued) { + this.grokOnlyFetchQueued = false + shouldContinue = true + } + if (this.codexOnlyFetchQueued) { + this.codexOnlyFetchQueued = false + const codexSignal = await this.runWithFetchAbortSignal((fetchSignal) => + this.runFetchCodexOnlyCycle(fetchSignal) + ) + if (codexSignal.aborted) { + break + } + } + if (this.claudeOnlyFetchQueued) { + this.claudeOnlyFetchQueued = false + const claudeSignal = await this.runWithFetchAbortSignal((fetchSignal) => + this.runFetchClaudeOnlyCycle(fetchSignal, { force: true }) + ) + if (claudeSignal.aborted) { + break + } + } + } + } finally { + this.isFetching = false + this.resolveFetchIdleWaiters() + } + } +} diff --git a/src/main/rate-limits/service/service-fetch-targets.ts b/src/main/rate-limits/service/service-fetch-targets.ts new file mode 100644 index 00000000000..2d316294dbc --- /dev/null +++ b/src/main/rate-limits/service/service-fetch-targets.ts @@ -0,0 +1,171 @@ +import { RateLimitServiceResultPolicy } from './service-result-policy' +import { fetchCodexRateLimits } from '../codex-fetcher' +import { fetchKimiRateLimits } from '../kimi-fetcher' +import { + isSystemDefaultClaudeAuth, + type ClaudeRuntimeAuthPreparation, + type CodexAccountSelectionTarget, + type MiniMaxResolvedConfig, + type NormalizedCodexAccountSelectionTarget, + type NormalizedClaudeAccountSelectionTarget, + type ProviderRateLimits, + type RateLimitState, + toErrorMessage +} from './service-types' + +export abstract class RateLimitServiceFetchTargets extends RateLimitServiceResultPolicy { + protected resolveCodexHome(target?: CodexAccountSelectionTarget): { + skip: boolean + homePath: string | null + } { + const resolution = this.codexHomePathResolver?.(target) + if (!resolution) { + return { skip: false, homePath: null } + } + return resolution.kind === 'skip' + ? { skip: true, homePath: null } + : { skip: false, homePath: resolution.codexHomePath } + } + + // Why: resolving a WSL home probes wsl.exe, so it must not run before the other + // providers' fetches are started; chaining keeps the no-resolver path immediate. + protected fetchKimiWithResolvedHome(): Promise { + const pendingHome = this.kimiHomeResolver?.() + return pendingHome + ? pendingHome.then((home) => fetchKimiRateLimits({ home })) + : fetchKimiRateLimits({ home: undefined }) + } + + protected isSameCodexTarget( + left: NormalizedCodexAccountSelectionTarget, + right: NormalizedCodexAccountSelectionTarget + ): boolean { + return left.runtime === right.runtime && left.wslDistro === right.wslDistro + } + + protected isSameClaudeTarget( + left: NormalizedClaudeAccountSelectionTarget, + right: NormalizedClaudeAccountSelectionTarget + ): boolean { + return left.runtime === right.runtime && left.wslDistro === right.wslDistro + } + + protected getCodexProvenance( + target: NormalizedCodexAccountSelectionTarget, + codexHomePath: string | null + ): string { + const targetKey = target.runtime === 'wsl' ? `wsl:${target.wslDistro ?? '__default__'}` : 'host' + return codexHomePath ? `${targetKey}:managed:${codexHomePath}` : `${targetKey}:system` + } + + protected getMissingWslCodexHomeResult( + target: NormalizedCodexAccountSelectionTarget + ): ProviderRateLimits | null { + if (target.runtime !== 'wsl') { + return null + } + return { + provider: 'codex', + session: null, + weekly: null, + updatedAt: Date.now(), + error: `WSL Codex home unavailable for ${target.wslDistro ?? 'default distro'}`, + status: 'error' + } + } + + protected async fetchCodexResetResultState( + target: NormalizedCodexAccountSelectionTarget, + codexHomePath: string | null, + stateBeforeReset: RateLimitState + ): Promise { + const controller = this.beginFetchCycle() + let fresh: ProviderRateLimits + try { + fresh = await fetchCodexRateLimits({ + codexHomePath, + allowPtyFallback: this.shouldAllowCodexPtyFallback(), + signal: controller.signal + }) + } catch (error) { + fresh = { + provider: 'codex', + session: null, + weekly: null, + updatedAt: Date.now(), + error: toErrorMessage(error), + status: 'error' + } + } finally { + this.finishFetchCycle(controller) + } + + const scopedCodex = this.applyStalePolicy(fresh, stateBeforeReset.codex) + const currentCodexHome = this.resolveCodexHome(target) + // Why: a skip has no provenance to compare, so treat it as no longer active + // rather than publishing this result against the system-default lane. + const stillActive = + !currentCodexHome.skip && + this.isSameCodexTarget(this.codexFetchTarget, target) && + this.getCodexProvenance(target, currentCodexHome.homePath) === + this.getCodexProvenance(target, codexHomePath) + if (stillActive) { + // Why: this post-redemption read is newer than every Codex fetch that + // started before it, so invalidate those results before publishing it. + this.codexFetchGeneration += 1 + this.trackActiveFailureStreak('codex', fresh) + this.updateState({ + ...this.state, + codex: this.applyStalePolicy(fresh, this.state.codex) + }) + } + + // Why: the caller must receive the redeemed target even if the global UI + // switched targets while the provider mutation was in flight. + return { ...stateBeforeReset, codex: scopedCodex, codexTarget: target } + } + + protected shouldAllowCodexPtyFallback(): boolean { + // Why: hidden PTY fallback can crash inside ConPTY on Windows; prefer RPC-only degradation there for background quota refresh. + return process.platform !== 'win32' + } + + protected shouldAllowClaudePtyFallback( + authPreparation: ClaudeRuntimeAuthPreparation | undefined + ): boolean { + // Why: Windows hidden PTY support is less reliable than host/WSL shells. + if (process.platform === 'win32') { + return false + } + // Why: system-default Claude isn't Orca-managed; refresh may read existing OAuth but must not launch Claude and trigger auth/browser flows. + return !isSystemDefaultClaudeAuth(authPreparation) + } + + protected shouldAllowClaudeUsagePanelSupplement(): boolean { + // Why: keep this supplement off on Windows where hidden PTYs are still less reliable. + return process.platform !== 'win32' + } + + protected resolveMiniMaxConfig(): MiniMaxResolvedConfig { + try { + return { + config: this.miniMaxConfigResolver?.() ?? { + sessionCookie: '', + groupId: '', + models: 'general' + }, + error: null + } + } catch (error) { + // Why: one unreadable cookie must not abort every provider's refresh; surface it as MiniMax-only state instead. + return { + config: { + sessionCookie: '', + groupId: '', + models: 'general' + }, + error: toErrorMessage(error) + } + } + } +} diff --git a/src/main/rate-limits/service/service-full-cycle-application.ts b/src/main/rate-limits/service/service-full-cycle-application.ts new file mode 100644 index 00000000000..7104face38f --- /dev/null +++ b/src/main/rate-limits/service/service-full-cycle-application.ts @@ -0,0 +1,215 @@ +import { RateLimitServiceFullCyclePreparation } from './service-full-cycle-preparation' +import { deriveAntigravityRateLimits } from '../antigravity-usage-mirror' +import type { ProviderRateLimits } from './service-types' + +export abstract class RateLimitServiceFullCycleApplication extends RateLimitServiceFullCyclePreparation { + protected async runFetchAllCycle( + signal: AbortSignal, + options?: { force?: boolean } + ): Promise { + const prepared = await this.prepareFetchAllCycle(signal, options) + if (!prepared) { + return + } + const { + claudeTarget, + claudeGeneration, + claudeProvenance, + codexTarget, + previousState, + codexFetchGated, + codexStateBeforeFetch, + codexProvenance, + codexGeneration, + opencodeConfigChanged, + opencodeGeneration, + miniMaxConfigChanged, + miniMaxGeneration, + claudeFetchGated, + results: [ + claudeResult, + codexResult, + geminiResult, + opencodeGoResult, + kimiResult, + miniMaxResult + ], + grokResultPromise + } = prepared + if (signal.aborted) { + return + } + + const claude = + claudeResult.status === 'fulfilled' + ? claudeResult.value + : ({ + provider: 'claude', + session: null, + weekly: null, + updatedAt: Date.now(), + error: + claudeResult.reason instanceof Error ? claudeResult.reason.message : 'Unknown error', + status: 'error' + } satisfies ProviderRateLimits) + + const codex = + codexResult.status === 'fulfilled' + ? codexResult.value + : ({ + provider: 'codex', + session: null, + weekly: null, + updatedAt: Date.now(), + error: + codexResult.reason instanceof Error ? codexResult.reason.message : 'Unknown error', + status: 'error' + } satisfies ProviderRateLimits) + + const gemini = + geminiResult.status === 'fulfilled' + ? geminiResult.value + : ({ + provider: 'gemini', + session: null, + weekly: null, + updatedAt: Date.now(), + error: + geminiResult.reason instanceof Error ? geminiResult.reason.message : 'Unknown error', + status: 'error' + } satisfies ProviderRateLimits) + + // Why: Antigravity can only borrow a *successful* Gemini read; a Gemini failure is not an Antigravity failure. + const antigravity = deriveAntigravityRateLimits(gemini) + + const opencodeGo = + opencodeGoResult.status === 'fulfilled' + ? opencodeGoResult.value + : ({ + provider: 'opencode-go', + session: null, + weekly: null, + monthly: null, + updatedAt: Date.now(), + error: + opencodeGoResult.reason instanceof Error + ? opencodeGoResult.reason.message + : 'Unknown error', + status: 'error' + } satisfies ProviderRateLimits) + + const kimi = + kimiResult.status === 'fulfilled' + ? kimiResult.value + : ({ + provider: 'kimi', + session: null, + weekly: null, + updatedAt: Date.now(), + error: kimiResult.reason instanceof Error ? kimiResult.reason.message : 'Unknown error', + status: 'error' + } satisfies ProviderRateLimits) + + const miniMax = + miniMaxResult.status === 'fulfilled' + ? miniMaxResult.value + : ({ + provider: 'minimax', + session: null, + weekly: null, + updatedAt: Date.now(), + error: + miniMaxResult.reason instanceof Error + ? miniMaxResult.reason.message + : 'Unknown error', + status: 'error' + } satisfies ProviderRateLimits) + + const latestCodexHome = this.resolveCodexHome(codexTarget) + const latestClaudeAuthPreparation = await this.claudeAuthPreparationResolver?.(claudeTarget) + if (signal.aborted) { + return + } + const latestClaudeProvenance = latestClaudeAuthPreparation?.provenance ?? 'system' + // Why: a finishing skip has no provenance, so an in-flight result must never be + // applied as though the target had become the system default (#STA-4422). + const shouldApplyCodex = + !codexFetchGated && + !latestCodexHome.skip && + codexGeneration === this.codexFetchGeneration && + codexProvenance === this.getCodexProvenance(codexTarget, latestCodexHome.homePath) + const codexBecameUnavailable = + !codexFetchGated && latestCodexHome.skip && codexGeneration === this.codexFetchGeneration + // Why: a gated cycle made no Claude attempt; applying its passthrough result would grow the failure streak and reset stale-policy clocks for free. + const shouldApplyClaude = + !claudeFetchGated && + claudeGeneration === this.claudeFetchGeneration && + claudeProvenance === latestClaudeProvenance && + this.isSameClaudeTarget(claudeTarget, this.claudeFetchTarget) + const shouldApplyOpencode = opencodeGeneration === this.opencodeFetchGeneration + const shouldApplyMiniMax = miniMaxGeneration === this.minimaxFetchGeneration + + if (shouldApplyClaude) { + this.trackActiveFailureStreak('claude', claude) + } + if (shouldApplyCodex) { + this.trackActiveFailureStreak('codex', codex) + } + this.trackActiveFailureStreak('gemini', gemini) + this.trackActiveFailureStreak('antigravity', antigravity) + if (shouldApplyOpencode) { + this.trackActiveFailureStreak('opencode-go', opencodeGo) + } + this.trackActiveFailureStreak('kimi', kimi) + if (shouldApplyMiniMax) { + this.trackActiveFailureStreak('minimax', miniMax) + } + + // Why: apply a Codex result only when provenance and generation still match, else a raced in-flight fetch overwrites the new account. + this.updateState({ + ...this.state, + claude: shouldApplyClaude + ? this.resolveClaudeFetchApply(claude, previousState.claude) + : this.state.claude, + codex: shouldApplyCodex + ? this.applyStalePolicy(codex, previousState.codex) + : codexBecameUnavailable + ? codexStateBeforeFetch + : this.state.codex, + gemini: this.applyStalePolicy(gemini, previousState.gemini), + opencodeGo: shouldApplyOpencode + ? opencodeConfigChanged + ? opencodeGo + : this.applyStalePolicy(opencodeGo, previousState.opencodeGo) + : this.state.opencodeGo, + kimi: this.applyStalePolicy(kimi, previousState.kimi), + antigravity: this.applyStalePolicy(antigravity, previousState.antigravity), + minimax: shouldApplyMiniMax + ? miniMaxConfigChanged + ? miniMax + : this.applyStalePolicy(miniMax, previousState.minimax) + : this.state.minimax + }) + + const grokResult = await grokResultPromise + if (signal.aborted) { + return + } + const grok = + grokResult.status === 'fulfilled' + ? grokResult.value + : ({ + provider: 'grok', + session: null, + weekly: null, + updatedAt: Date.now(), + error: grokResult.reason instanceof Error ? grokResult.reason.message : 'Unknown error', + status: 'error' + } satisfies ProviderRateLimits) + this.trackActiveFailureStreak('grok', grok) + this.updateState({ + ...this.state, + grok: this.applyStalePolicy(grok, previousState.grok) + }) + } +} diff --git a/src/main/rate-limits/service/service-full-cycle-preparation.ts b/src/main/rate-limits/service/service-full-cycle-preparation.ts new file mode 100644 index 00000000000..1bbf3bf497d --- /dev/null +++ b/src/main/rate-limits/service/service-full-cycle-preparation.ts @@ -0,0 +1,204 @@ +import { fetchClaudeRateLimits } from '../claude-fetcher' +import { fetchCodexRateLimits } from '../codex-fetcher' +import { fetchGeminiRateLimits } from '../gemini-usage-fetcher' +import { fetchGrokRateLimits } from '../grok-fetcher' +import { readGrokAuthSession } from '../grok-auth' +import { fetchMiniMaxRateLimits } from '../minimax-fetcher' +import { fetchOpenCodeGoRateLimits } from '../opencode-go-usage-fetcher' +import { RateLimitServiceFetchPolicy } from './service-fetch-policy' +import type { + ClaudeRuntimeAuthPreparation, + InternalRateLimitState, + NormalizedClaudeAccountSelectionTarget, + NormalizedCodexAccountSelectionTarget, + ProviderRateLimits +} from './service-types' + +export type FetchAllCyclePrepared = { + claudeTarget: NormalizedClaudeAccountSelectionTarget + claudeGeneration: number + claudeAuthPreparation: ClaudeRuntimeAuthPreparation | undefined + claudeProvenance: string + codexTarget: NormalizedCodexAccountSelectionTarget + previousState: InternalRateLimitState + codexFetchGated: boolean + codexStateBeforeFetch: ProviderRateLimits | null + codexProvenance: string | null + codexGeneration: number + opencodeConfigChanged: boolean + opencodeGeneration: number + miniMaxConfigChanged: boolean + miniMaxGeneration: number + claudeFetchGated: boolean + results: [ + PromiseSettledResult, + PromiseSettledResult, + PromiseSettledResult, + PromiseSettledResult, + PromiseSettledResult, + PromiseSettledResult + ] + grokResultPromise: Promise< + { status: 'fulfilled'; value: ProviderRateLimits } | { status: 'rejected'; reason: unknown } + > +} + +export abstract class RateLimitServiceFullCyclePreparation extends RateLimitServiceFetchPolicy { + protected async prepareFetchAllCycle( + signal: AbortSignal, + options?: { force?: boolean } + ): Promise { + if (signal.aborted) { + return null + } + const claudeTarget = this.claudeFetchTarget + // Why: capture before the resolver await so an account switch during it invalidates both the snapshot and the state apply. + const claudeGeneration = this.claudeFetchGeneration + const claudeAuthPreparation = await this.claudeAuthPreparationResolver?.(claudeTarget) + if (signal.aborted) { + return null + } + this.rememberClaudeAuthSnapshot(claudeAuthPreparation, claudeGeneration, claudeTarget) + const claudeProvenance = claudeAuthPreparation?.provenance ?? 'system' + const codexTarget = this.codexFetchTarget + const previousState = this.state + // Why: a skipped Codex poll must not stop the other providers' cycle, so gate + // only the Codex slot instead of returning early (#STA-4422). + const codexHome = this.resolveCodexHome(codexTarget) + const codexFetchGated = codexHome.skip + const codexHomePath = codexHome.homePath + const codexStateBeforeFetch = + previousState.codex?.status === 'fetching' ? null : previousState.codex + const codexProvenance = codexFetchGated + ? null + : this.getCodexProvenance(codexTarget, codexHomePath) + const codexGeneration = this.codexFetchGeneration + const openCodeGoConfig = this.openCodeGoConfigResolver?.() + const cookie = openCodeGoConfig?.sessionCookie ?? '' + const workspaceIdOverride = openCodeGoConfig?.workspaceIdOverride ?? '' + const miniMaxConfigResult = this.resolveMiniMaxConfig() + const miniMaxCookie = miniMaxConfigResult.config.sessionCookie + const miniMaxGroupId = miniMaxConfigResult.config.groupId + const miniMaxModels = miniMaxConfigResult.config.models + const geminiCliOAuthEnabled = this.geminiCliOAuthEnabledResolver?.() ?? false + // Why: getState() is hot (renderer pushes + mobile snapshots); keep Grok's sync auth-file probe on fetch cycles instead. + const grokAuthReadResult = readGrokAuthSession() + this.grokAuthConfigured = grokAuthReadResult.status === 'ok' + + // Discard stale data on config change — it belongs to a different session/workspace. + const currentConfigHash = `${cookie}|${workspaceIdOverride}` + const opencodeConfigChanged = currentConfigHash !== this.lastOpencodeConfigHash + if (opencodeConfigChanged) { + this.lastOpencodeConfigHash = currentConfigHash + this.opencodeFetchGeneration += 1 + } + const opencodeGeneration = this.opencodeFetchGeneration + + const currentMiniMaxConfigHash = `${miniMaxCookie}|${miniMaxGroupId}|${miniMaxModels}|${miniMaxConfigResult.error ?? ''}` + const miniMaxConfigChanged = currentMiniMaxConfigHash !== this.lastMiniMaxConfigHash + if (miniMaxConfigChanged) { + this.lastMiniMaxConfigHash = currentMiniMaxConfigHash + this.minimaxFetchGeneration += 1 + } + const miniMaxGeneration = this.minimaxFetchGeneration + + // Mark all providers fetching while keeping previous data visible (Codex is cleared separately on account change). + this.updateState({ + ...previousState, + claude: this.withFetchingStatus(previousState.claude, 'claude'), + // Why: a gated Codex cycle makes no attempt; a "fetching" chip would never settle. + codex: codexFetchGated + ? codexStateBeforeFetch + : this.withFetchingStatus(previousState.codex, 'codex'), + gemini: this.withFetchingStatus(previousState.gemini, 'gemini'), + opencodeGo: opencodeConfigChanged + ? this.withFetchingStatus(null, 'opencode-go') + : this.withFetchingStatus(previousState.opencodeGo, 'opencode-go'), + kimi: this.withFetchingStatus(previousState.kimi, 'kimi'), + antigravity: this.withFetchingStatus(previousState.antigravity, 'antigravity'), + minimax: miniMaxConfigChanged + ? this.withFetchingStatus(null, 'minimax') + : this.withFetchingStatus(previousState.minimax, 'minimax'), + grok: this.withFetchingStatus(previousState.grok, 'grok') + }) + + const missingWslCodexHome = + codexFetchGated || codexHomePath ? null : this.getMissingWslCodexHomeResult(codexTarget) + const grokResultPromise = fetchGrokRateLimits({ + signal, + authReadResult: grokAuthReadResult + }).then( + (value) => ({ status: 'fulfilled', value }) as const, + (reason) => ({ status: 'rejected', reason }) as const + ) + + // Why: skip automated Claude fetches while a Retry-After window is open or a live session feed is fresher than the OAuth poll would be. + const claudeFetchGated = + !options?.force && this.shouldSkipAutomatedClaudeFetch(previousState.claude) + + const [claudeResult, codexResult, geminiResult, opencodeGoResult, kimiResult, miniMaxResult] = + await Promise.allSettled([ + claudeFetchGated + ? Promise.resolve(previousState.claude as ProviderRateLimits) + : fetchClaudeRateLimits({ + authPreparation: claudeAuthPreparation, + allowPtyFallback: this.shouldAllowClaudePtyFallback(claudeAuthPreparation), + allowUsagePanelSupplement: this.shouldAllowClaudeUsagePanelSupplement(), + networkProxySettings: this.networkProxySettingsResolver?.(), + signal + }), + codexFetchGated + ? Promise.resolve(previousState.codex as ProviderRateLimits) + : (missingWslCodexHome ?? + fetchCodexRateLimits({ + codexHomePath, + allowPtyFallback: this.shouldAllowCodexPtyFallback(), + signal + })), + fetchGeminiRateLimits(geminiCliOAuthEnabled), + fetchOpenCodeGoRateLimits( + cookie, + workspaceIdOverride || undefined, + this.networkProxySettingsResolver?.() + ), + this.fetchKimiWithResolvedHome(), + miniMaxConfigResult.error + ? Promise.resolve(this.getMiniMaxCredentialError(miniMaxConfigResult.error)) + : fetchMiniMaxRateLimits({ + cookie: miniMaxCookie, + groupId: miniMaxGroupId, + models: miniMaxModels + }) + ]) + + if (signal.aborted) { + return null + } + return { + claudeTarget, + claudeGeneration, + claudeAuthPreparation, + claudeProvenance, + codexTarget, + previousState, + codexFetchGated, + codexStateBeforeFetch, + codexProvenance, + codexGeneration, + opencodeConfigChanged, + opencodeGeneration, + miniMaxConfigChanged, + miniMaxGeneration, + claudeFetchGated, + results: [ + claudeResult, + codexResult, + geminiResult, + opencodeGoResult, + kimiResult, + miniMaxResult + ], + grokResultPromise + } + } +} diff --git a/src/main/rate-limits/service/service-inactive-accounts.ts b/src/main/rate-limits/service/service-inactive-accounts.ts new file mode 100644 index 00000000000..5d977991af4 --- /dev/null +++ b/src/main/rate-limits/service/service-inactive-accounts.ts @@ -0,0 +1,246 @@ +import { fetchManagedAccountUsage } from '../claude-fetcher' +import { fetchCodexRateLimits } from '../codex-fetcher' +import { RateLimitServicePolling } from './service-polling' +import { + INACTIVE_CODEX_PROBE_STAGGER_MS, + INACTIVE_FETCH_DEBOUNCE_MS, + delayUnlessAborted +} from './service-types' + +export abstract class RateLimitServiceInactiveAccounts extends RateLimitServicePolling { + async fetchInactiveClaudeAccountsOnOpen(): Promise { + if (Date.now() - this.lastInactiveClaudeFetchAt < INACTIVE_FETCH_DEBOUNCE_MS) { + return + } + this.pruneInactiveClaudeState() + if (this.inactiveClaudeFetching.size > 0) { + return + } + const accounts = this.inactiveClaudeAccountsResolver?.() ?? [] + if (accounts.length === 0) { + return + } + const fetchGeneration = this.inactiveClaudeAccountsGeneration + const controller = this.beginFetchCycle() + const signal = controller.signal + + for (const account of accounts) { + this.inactiveClaudeFetching.add(account.id) + } + this.pushToRenderer() + + try { + for (const account of accounts) { + if ( + signal.aborted || + fetchGeneration !== this.inactiveClaudeAccountsGeneration || + !this.isCurrentInactiveClaudeAccount(account.id) + ) { + this.inactiveClaudeFetching.delete(account.id) + if (!this.isCurrentInactiveClaudeAccount(account.id)) { + this.inactiveClaudeCache.delete(account.id) + } + this.pushToRenderer() + continue + } + try { + const fresh = await fetchManagedAccountUsage(account, { + allowUsagePanelSupplement: this.shouldAllowClaudeUsagePanelSupplement(), + networkProxySettings: this.networkProxySettingsResolver?.(), + signal + }) + if ( + signal.aborted || + fetchGeneration !== this.inactiveClaudeAccountsGeneration || + !this.isCurrentInactiveClaudeAccount(account.id) + ) { + this.inactiveClaudeFetching.delete(account.id) + if (!this.isCurrentInactiveClaudeAccount(account.id)) { + this.inactiveClaudeCache.delete(account.id) + } + this.pushToRenderer() + continue + } + const cached = this.inactiveClaudeCache.get(account.id) ?? null + this.inactiveClaudeCache.set(account.id, this.applyStalePolicy(fresh, cached)) + } catch { + // Why: per-account try/catch keeps one Keychain/network error from aborting the remaining accounts in the batch. + if ( + signal.aborted || + fetchGeneration !== this.inactiveClaudeAccountsGeneration || + !this.isCurrentInactiveClaudeAccount(account.id) + ) { + this.inactiveClaudeCache.delete(account.id) + } + } + this.inactiveClaudeFetching.delete(account.id) + this.pushToRenderer() + } + + if (!signal.aborted && fetchGeneration === this.inactiveClaudeAccountsGeneration) { + this.lastInactiveClaudeFetchAt = Date.now() + } + } finally { + this.finishFetchCycle(controller) + } + } + + async fetchInactiveCodexAccountsOnOpen(): Promise { + if (Date.now() - this.lastInactiveCodexFetchAt < INACTIVE_FETCH_DEBOUNCE_MS) { + return + } + this.pruneInactiveCodexState() + if (this.inactiveCodexFetchInFlight) { + return + } + const accounts = this.inactiveCodexAccountsResolver?.() ?? [] + if (accounts.length === 0) { + return + } + // Why: account switching can activate a previewed account while its RPC-only fetch is still in flight; ignore stale results. + const fetchGeneration = this.inactiveCodexAccountsGeneration + const controller = this.beginFetchCycle() + const signal = controller.signal + this.inactiveCodexFetchInFlight = true + + let staggerNextProbe = false + try { + for (const account of accounts) { + if ( + signal.aborted || + fetchGeneration !== this.inactiveCodexAccountsGeneration || + !this.isCurrentInactiveCodexAccount(account.id) + ) { + this.inactiveCodexFetching.delete(account.id) + if (!this.isCurrentInactiveCodexAccount(account.id)) { + this.inactiveCodexCache.delete(account.id) + } + this.pushToRenderer() + continue + } + if (staggerNextProbe) { + await delayUnlessAborted(INACTIVE_CODEX_PROBE_STAGGER_MS, signal) + // Why: the account set can change while the stagger delay runs. + if ( + signal.aborted || + fetchGeneration !== this.inactiveCodexAccountsGeneration || + !this.isCurrentInactiveCodexAccount(account.id) + ) { + this.inactiveCodexFetching.delete(account.id) + if (!this.isCurrentInactiveCodexAccount(account.id)) { + this.inactiveCodexCache.delete(account.id) + } + this.pushToRenderer() + continue + } + } + const home = account.resolveHome() + if (home.kind === 'skip') { + continue + } + staggerNextProbe = true + this.inactiveCodexFetching.add(account.id) + this.pushToRenderer() + try { + // Why: point fetchCodexRateLimits at the managed home directly, avoiding materializing credentials into the shared runtime location. + // Why: no PTY fallback — the switcher preview shouldn't spawn hidden PTYs per account (can crash ConPTY on Windows); RPC-only is enough. + const fresh = await fetchCodexRateLimits({ + codexHomePath: home.managedHomePath, + allowPtyFallback: false, + signal + }) + if ( + signal.aborted || + fetchGeneration !== this.inactiveCodexAccountsGeneration || + !this.isCurrentInactiveCodexAccount(account.id) + ) { + this.inactiveCodexFetching.delete(account.id) + if (!this.isCurrentInactiveCodexAccount(account.id)) { + this.inactiveCodexCache.delete(account.id) + } + this.pushToRenderer() + continue + } + const cached = this.inactiveCodexCache.get(account.id) ?? null + this.inactiveCodexCache.set(account.id, this.applyStalePolicy(fresh, cached)) + } catch { + // Why: per-account try/catch prevents one failure from aborting the batch. + if ( + signal.aborted || + fetchGeneration !== this.inactiveCodexAccountsGeneration || + !this.isCurrentInactiveCodexAccount(account.id) + ) { + this.inactiveCodexCache.delete(account.id) + } + } + this.inactiveCodexFetching.delete(account.id) + this.pushToRenderer() + } + + if (!signal.aborted && fetchGeneration === this.inactiveCodexAccountsGeneration) { + this.lastInactiveCodexFetchAt = Date.now() + } + } finally { + this.inactiveCodexFetchInFlight = false + this.finishFetchCycle(controller) + } + } + + evictInactiveClaudeCache(accountId: string): void { + this.inactiveClaudeAccountsGeneration += 1 + this.inactiveClaudeCache.delete(accountId) + this.inactiveClaudeFetching.delete(accountId) + this.pushToRenderer() + } + + protected isCurrentInactiveClaudeAccount(accountId: string): boolean { + return (this.inactiveClaudeAccountsResolver?.() ?? []).some( + (account) => account.id === accountId + ) + } + + protected isCurrentInactiveCodexAccount(accountId: string): boolean { + return (this.inactiveCodexAccountsResolver?.() ?? []).some( + (account) => account.id === accountId + ) + } + + protected pruneInactiveClaudeState(): void { + const currentIds = new Set( + (this.inactiveClaudeAccountsResolver?.() ?? []).map((account) => account.id) + ) + for (const accountId of this.inactiveClaudeCache.keys()) { + if (!currentIds.has(accountId)) { + this.inactiveClaudeCache.delete(accountId) + } + } + for (const accountId of this.inactiveClaudeFetching) { + if (!currentIds.has(accountId)) { + this.inactiveClaudeFetching.delete(accountId) + } + } + } + + protected pruneInactiveCodexState(): void { + const currentIds = new Set( + (this.inactiveCodexAccountsResolver?.() ?? []).map((account) => account.id) + ) + for (const accountId of this.inactiveCodexCache.keys()) { + if (!currentIds.has(accountId)) { + this.inactiveCodexCache.delete(accountId) + } + } + for (const accountId of this.inactiveCodexFetching) { + if (!currentIds.has(accountId)) { + this.inactiveCodexFetching.delete(accountId) + } + } + } + + evictInactiveCodexCache(accountId: string): void { + // Why: clear only this account, not the generation — bumping it would discard sibling fetches still in flight and their fresh results. + this.inactiveCodexCache.delete(accountId) + this.inactiveCodexFetching.delete(accountId) + this.pushToRenderer() + } +} diff --git a/src/main/rate-limits/service/service-polling.ts b/src/main/rate-limits/service/service-polling.ts new file mode 100644 index 00000000000..c861726cc52 --- /dev/null +++ b/src/main/rate-limits/service/service-polling.ts @@ -0,0 +1,183 @@ +import { RateLimitServiceFetchQueue } from './service-fetch-queue' +import { + ACTIVE_FAILURE_REFETCH_MS, + DEFERRED_STARTUP_ACTIVE_REFRESH_MS, + INDIVIDUALLY_REFRESHABLE_PROVIDERS, + MAX_ACTIVE_FAILURE_REFETCH_MS, + MIN_REFETCH_MS, + normalizePollingInterval, + type ActiveProviderState, + type ActiveRateLimitProvider, + type ActiveWindowRefreshPlan, + type ProviderRateLimits +} from './service-types' + +export abstract class RateLimitServicePolling extends RateLimitServiceFetchQueue { + setPollingInterval(ms: number): void { + this.pollInterval = normalizePollingInterval(ms) + if (this.timer) { + this.stopTimer() + this.startTimer() + } + } + + // --------------------------------------------------------------------------- + // Internal + // --------------------------------------------------------------------------- + + protected startTimer(): void { + this.stopTimer() + this.timer = setInterval(() => { + if (!this.shouldBackgroundPoll()) { + return + } + void this.fetchAll() + }, this.pollInterval) + } + + protected stopTimer(): void { + if (this.timer) { + clearInterval(this.timer) + this.timer = null + } + } + + protected scheduleDeferredStartupRefresh(): void { + this.clearDeferredStartupRefresh() + this.deferredStartupRefreshTimer = setTimeout(() => { + this.deferredStartupRefreshTimer = null + void this.refreshIfWindowActive() + }, DEFERRED_STARTUP_ACTIVE_REFRESH_MS) + } + + protected clearDeferredStartupRefresh(): void { + if (this.deferredStartupRefreshTimer) { + clearTimeout(this.deferredStartupRefreshTimer) + this.deferredStartupRefreshTimer = null + } + } + + protected shouldBackgroundPoll(): boolean { + if (!this.mainWindow || this.mainWindow.isDestroyed()) { + return false + } + // Why: these fetches only power in-app UI; skip polling when hidden/minimized/unfocused to save CLI/API budget (refresh on activate). + if (!this.mainWindow.isVisible() || this.mainWindow.isMinimized()) { + return false + } + return this.mainWindow.isFocused() + } + + protected getActiveProviderState(): ActiveProviderState[] { + // Why: key by provider so a new provider is compile-forced an entry — a missing one silently never recovers from a startup error. + const byProvider: Record = { + claude: this.state.claude, + codex: this.state.codex, + gemini: this.state.gemini, + 'opencode-go': this.state.opencodeGo, + kimi: this.state.kimi, + minimax: this.state.minimax, + grok: this.state.grok, + antigravity: this.state.antigravity + } + return Object.entries(byProvider).map(([provider, limits]) => ({ + provider: provider as ActiveRateLimitProvider, + limits + })) + } + + protected getActiveWindowRefreshPlan(now: number): ActiveWindowRefreshPlan { + const retryableFailures: ActiveRateLimitProvider[] = [] + for (const { provider, limits } of this.getActiveProviderState()) { + if (!limits || limits.status === 'idle' || limits.status === 'fetching') { + return { kind: 'full' } + } + if (limits.status === 'ok' || limits.status === 'unavailable') { + if (now - limits.updatedAt >= MIN_REFETCH_MS) { + return { kind: 'full' } + } + continue + } + // Why: a failed startup read is not fresh data; keep it eligible for activation recovery, throttled per provider. + if (limits.status === 'error') { + // Why: the server told us when to come back (Retry-After); retrying earlier burns the endpoint's budget and keeps the 429 alive. + if (this.isRetryAfterActive(limits)) { + continue + } + const lastRetryAt = this.lastActiveFailureRetryAtByProvider[provider] + const throttleMs = INDIVIDUALLY_REFRESHABLE_PROVIDERS.has(provider) + ? Math.min( + ACTIVE_FAILURE_REFETCH_MS * + 2 ** Math.max(0, this.activeFailureStreakByProvider[provider] - 1), + MAX_ACTIVE_FAILURE_REFETCH_MS + ) + : MIN_REFETCH_MS + if (now - lastRetryAt >= throttleMs) { + retryableFailures.push(provider) + } + } + } + + if (retryableFailures.length === 0) { + return { kind: 'none' } + } + return { kind: 'providers', providers: retryableFailures } + } + + protected async runActiveWindowRefreshPlan(plan: ActiveWindowRefreshPlan): Promise { + if (plan.kind === 'none') { + return + } + if (plan.kind === 'full') { + // Why: a full fetch retries failing providers too; restart their retry clocks so the individual failure lane doesn't fire ahead of backoff. + // Why: gated on !isFetching — the fetchAll below no-ops mid-flight, so don't consume the retry throttle for free. + if (!this.isFetching) { + const now = Date.now() + for (const { provider, limits } of this.getActiveProviderState()) { + if (limits?.status === 'error') { + this.lastActiveFailureRetryAtByProvider[provider] = now + } + } + } + await this.fetchAll() + return + } + + // Why: an in-flight fetch will refresh these; skip without consuming the per-provider retry throttle so the next activation retries. + if (this.isFetching) { + return + } + + const now = Date.now() + for (const provider of plan.providers) { + this.lastActiveFailureRetryAtByProvider[provider] = now + } + + const canRefreshIndividually = plan.providers.every((provider) => + INDIVIDUALLY_REFRESHABLE_PROVIDERS.has(provider) + ) + if (!canRefreshIndividually) { + await this.fetchAll() + return + } + + // Why: recover partial failures of dedicated-fetch providers without re-reading healthy providers still inside their debounce. + if (plan.providers.includes('claude')) { + await this.fetchClaudeOnly() + } + if (plan.providers.includes('codex')) { + await this.fetchCodexOnly() + } + if (plan.providers.includes('grok')) { + await this.fetchGrokOnly() + } + } + + protected async refreshIfWindowActive(): Promise { + if (!this.shouldBackgroundPoll()) { + return + } + const plan = this.getActiveWindowRefreshPlan(Date.now()) + await this.runActiveWindowRefreshPlan(plan) + } +} diff --git a/src/main/rate-limits/service/service-provider-cycles.ts b/src/main/rate-limits/service/service-provider-cycles.ts new file mode 100644 index 00000000000..75c56d96e67 --- /dev/null +++ b/src/main/rate-limits/service/service-provider-cycles.ts @@ -0,0 +1,183 @@ +import { RateLimitServiceFullCycleApplication } from './service-full-cycle-application' +import { fetchClaudeRateLimits } from '../claude-fetcher' +import { fetchCodexRateLimits } from '../codex-fetcher' +import { fetchGrokRateLimits } from '../grok-fetcher' +import { readGrokAuthSession } from '../grok-auth' +import type { ProviderRateLimits } from './service-types' + +export abstract class RateLimitServiceProviderCycles extends RateLimitServiceFullCycleApplication { + protected async runFetchCodexOnlyCycle(signal: AbortSignal): Promise { + if (signal.aborted) { + return + } + const codexTarget = this.codexFetchTarget + const codexGeneration = this.codexFetchGeneration + const codexHome = this.resolveCodexHome(codexTarget) + // Why: return before the "fetching" mark — a skipped cycle never settles it (#STA-4422). + if (codexHome.skip) { + if ( + codexGeneration === this.codexFetchGeneration && + this.state.codex?.status === 'fetching' + ) { + this.updateState({ ...this.state, codex: null }) + } + return + } + const codexHomePath = codexHome.homePath + const codexProvenance = this.getCodexProvenance(codexTarget, codexHomePath) + const previousState = this.state + + this.updateState({ + ...previousState, + codex: this.withFetchingStatus(previousState.codex, 'codex') + }) + + const missingWslCodexHome = codexHomePath + ? null + : this.getMissingWslCodexHomeResult(codexTarget) + const codex = await ( + missingWslCodexHome + ? Promise.resolve(missingWslCodexHome) + : fetchCodexRateLimits({ + codexHomePath, + allowPtyFallback: this.shouldAllowCodexPtyFallback(), + signal + }) + ).catch((err): ProviderRateLimits => ({ + provider: 'codex', + session: null, + weekly: null, + updatedAt: Date.now(), + error: err instanceof Error ? err.message : 'Unknown error', + status: 'error' + })) + + if (signal.aborted) { + return + } + + const latestCodexHome = this.resolveCodexHome(codexTarget) + if (latestCodexHome.skip && codexGeneration === this.codexFetchGeneration) { + this.updateState({ + ...this.state, + codex: previousState.codex?.status === 'fetching' ? null : previousState.codex + }) + return + } + const shouldApplyCodex = + !latestCodexHome.skip && + codexGeneration === this.codexFetchGeneration && + codexProvenance === this.getCodexProvenance(codexTarget, latestCodexHome.homePath) + + if (shouldApplyCodex) { + this.trackActiveFailureStreak('codex', codex) + } + this.updateState({ + ...this.state, + codex: shouldApplyCodex ? this.applyStalePolicy(codex, previousState.codex) : this.state.codex + }) + } + + protected async runFetchClaudeOnlyCycle( + signal: AbortSignal, + options?: { force?: boolean } + ): Promise { + if (signal.aborted) { + return + } + // Why: skip automated Claude fetches while a Retry-After window is open or a live session feed is fresher than the OAuth poll would be. + if (!options?.force && this.shouldSkipAutomatedClaudeFetch(this.state.claude)) { + return + } + const claudeTarget = this.claudeFetchTarget + // Why: capture before the resolver await so an account switch during it invalidates both the snapshot and the state apply. + const claudeGeneration = this.claudeFetchGeneration + const claudeAuthPreparation = await this.claudeAuthPreparationResolver?.(claudeTarget) + if (signal.aborted) { + return + } + this.rememberClaudeAuthSnapshot(claudeAuthPreparation, claudeGeneration, claudeTarget) + const claudeProvenance = claudeAuthPreparation?.provenance ?? 'system' + const previousState = this.state + + this.updateState({ + ...previousState, + claude: this.withFetchingStatus(previousState.claude, 'claude') + }) + + const claude = await fetchClaudeRateLimits({ + authPreparation: claudeAuthPreparation, + allowPtyFallback: this.shouldAllowClaudePtyFallback(claudeAuthPreparation), + allowUsagePanelSupplement: this.shouldAllowClaudeUsagePanelSupplement(), + networkProxySettings: this.networkProxySettingsResolver?.(), + signal + }).catch((err): ProviderRateLimits => ({ + provider: 'claude', + session: null, + weekly: null, + updatedAt: Date.now(), + error: err instanceof Error ? err.message : 'Unknown error', + status: 'error' + })) + + if (signal.aborted) { + return + } + + const latestClaudeAuthPreparation = await this.claudeAuthPreparationResolver?.(claudeTarget) + if (signal.aborted) { + return + } + const latestClaudeProvenance = latestClaudeAuthPreparation?.provenance ?? 'system' + const shouldApplyClaude = + claudeGeneration === this.claudeFetchGeneration && + claudeProvenance === latestClaudeProvenance && + this.isSameClaudeTarget(claudeTarget, this.claudeFetchTarget) + + if (shouldApplyClaude) { + this.trackActiveFailureStreak('claude', claude) + } + this.updateState({ + ...this.state, + claude: shouldApplyClaude + ? this.resolveClaudeFetchApply(claude, previousState.claude) + : this.state.claude + }) + } + + protected async runFetchGrokOnlyCycle(signal: AbortSignal): Promise { + if (signal.aborted) { + return + } + const previousState = this.state + const grokAuthReadResult = readGrokAuthSession() + this.grokAuthConfigured = grokAuthReadResult.status === 'ok' + + this.updateState({ + ...previousState, + grok: this.withFetchingStatus(previousState.grok, 'grok') + }) + + const grok = await fetchGrokRateLimits({ + signal, + authReadResult: grokAuthReadResult + }).catch((err): ProviderRateLimits => ({ + provider: 'grok', + session: null, + weekly: null, + updatedAt: Date.now(), + error: err instanceof Error ? err.message : 'Unknown error', + status: 'error' + })) + + if (signal.aborted) { + return + } + + this.trackActiveFailureStreak('grok', grok) + this.updateState({ + ...this.state, + grok: this.applyStalePolicy(grok, previousState.grok) + }) + } +} diff --git a/src/main/rate-limits/service/service-result-policy.ts b/src/main/rate-limits/service/service-result-policy.ts new file mode 100644 index 00000000000..db00053fdcf --- /dev/null +++ b/src/main/rate-limits/service/service-result-policy.ts @@ -0,0 +1,112 @@ +import { RateLimitServiceFetchControl } from './service-fetch-control' +import { + MAX_ACTIVE_FAILURE_STREAK, + RATE_LIMITED_STALE_THRESHOLD_MS, + STALE_THRESHOLD_MS, + type ActiveRateLimitProvider, + type ProviderRateLimits +} from './service-types' + +export abstract class RateLimitServiceResultPolicy extends RateLimitServiceFetchControl { + protected applyStalePolicy( + fresh: ProviderRateLimits, + previous: ProviderRateLimits | null + ): ProviderRateLimits { + // Fresh data is fine — use it + if (fresh.status === 'ok') { + return { + ...fresh, + usageMetadata: { + ...fresh.usageMetadata, + lastSuccessfulSource: + fresh.usageMetadata?.source ?? fresh.usageMetadata?.lastSuccessfulSource + } + } + } + + // Explicitly unavailable (e.g. setting cleared): discard stale data so the UI shows the provider as disabled/unconfigured. + if (fresh.status === 'unavailable') { + return fresh + } + + const previousHasData = Boolean( + previous?.session || + previous?.weekly || + previous?.fableWeekly || + previous?.monthly || + (previous?.buckets && previous.buckets.length > 0) + ) + + // No previous data to fall back on + if (!previous || !previousHasData) { + return fresh + } + + // Previous data is too old — don't show stale data + const staleThresholdMs = + fresh.usageMetadata?.failureKind === 'rate-limited' + ? RATE_LIMITED_STALE_THRESHOLD_MS + : STALE_THRESHOLD_MS + if (Date.now() - previous.updatedAt > staleThresholdMs) { + return fresh + } + + // Why: keep showing a recent snapshot through repeated transient failures until it ages out, so the bar doesn't flap to empty. + return { + ...previous, + error: fresh.error, + status: 'error', + usageMetadata: { + ...previous.usageMetadata, + ...fresh.usageMetadata, + lastSuccessfulSource: + previous.usageMetadata?.lastSuccessfulSource ?? previous.usageMetadata?.source + } + } + } + + protected trackActiveFailureStreak( + provider: ActiveRateLimitProvider, + fresh: ProviderRateLimits + ): void { + if (fresh.status === 'error') { + this.activeFailureStreakByProvider[provider] = Math.min( + this.activeFailureStreakByProvider[provider] + 1, + MAX_ACTIVE_FAILURE_STREAK + ) + return + } + if (fresh.status === 'ok' || fresh.status === 'unavailable') { + this.activeFailureStreakByProvider[provider] = 0 + } + } + + protected withFetchingStatus( + current: ProviderRateLimits | null, + provider: + | 'claude' + | 'codex' + | 'gemini' + | 'opencode-go' + | 'kimi' + | 'minimax' + | 'grok' + | 'antigravity' + ): ProviderRateLimits { + if (!current) { + return { + provider, + session: null, + weekly: null, + updatedAt: 0, + error: null, + status: 'fetching' + } + } + // Why: keep a settled chip visible during background refetch so a persistently failing provider doesn't flash "…" → error each cycle. + if (current.status === 'ok' || current.status === 'error' || current.status === 'unavailable') { + return current + } + return { ...current, status: 'fetching' } + } +} diff --git a/src/main/rate-limits/service/service-state.ts b/src/main/rate-limits/service/service-state.ts new file mode 100644 index 00000000000..c7e9aa4751a --- /dev/null +++ b/src/main/rate-limits/service/service-state.ts @@ -0,0 +1,165 @@ +import type { BrowserWindow } from 'electron' +import type { + InactiveAccountUsage, + ProviderRateLimits, + RateLimitState +} from '../../../shared/rate-limit-types' +import { + type ActiveRateLimitProvider, + type InactiveCodexAccountInfo, + type InternalRateLimitState, + type CodexHomePathResolver, + type KimiHomeResolver, + type ClaudeAuthPreparationResolver, + type OpenCodeGoRateLimitConfig, + type MiniMaxRateLimitConfig, + type GeminiCliOAuthEnabledResolver, + type NormalizedCodexAccountSelectionTarget, + type NormalizedClaudeAccountSelectionTarget, + type InactiveClaudeAccountInfo, + type NetworkProxySettings, + DEFAULT_POLL_MS +} from './service-types' +import { readGrokAuthSession } from '../grok-auth' + +export abstract class RateLimitServiceState { + protected state: InternalRateLimitState = { + claude: null, + codex: null, + gemini: null, + opencodeGo: null, + kimi: null, + antigravity: null, + minimax: null, + grok: null + } + protected grokAuthConfigured = readGrokAuthSession().status === 'ok' + protected pollInterval: number = DEFAULT_POLL_MS + protected timer: ReturnType | null = null + protected deferredStartupRefreshTimer: ReturnType | null = null + // Why: throttle repeated focus/show/restore events so one outage doesn't create a tight provider retry loop. + protected lastActiveFailureRetryAtByProvider: Record = { + claude: 0, + codex: 0, + gemini: 0, + 'opencode-go': 0, + kimi: 0, + minimax: 0, + grok: 0, + antigravity: 0 + } + // Why: consecutive failures drive exponential backoff of the fast activation-retry lane; reset on any success/unavailable result. + protected activeFailureStreakByProvider: Record = { + claude: 0, + codex: 0, + gemini: 0, + 'opencode-go': 0, + kimi: 0, + minimax: 0, + grok: 0, + antigravity: 0 + } + protected mainWindow: BrowserWindow | null = null + protected detachWindowListeners: (() => void) | null = null + protected isFetching = false + protected fullFetchQueued = false + protected codexOnlyFetchQueued = false + protected claudeOnlyFetchQueued = false + protected grokOnlyFetchQueued = false + protected activeFetchAbortControllers = new Set() + protected fetchIdleResolvers: (() => void)[] = [] + protected codexFetchGeneration = 0 + protected claudeFetchGeneration = 0 + // Why: statusline ingest must attribute live windows to the selected account without re-running the side-effectful auth sync per post. + protected lastClaudeAuthSnapshot: { configDir: string | null; provenance: string } | null = null + protected opencodeFetchGeneration = 0 + protected minimaxFetchGeneration = 0 + protected lastOpencodeConfigHash = '' + protected lastMiniMaxConfigHash = '' + protected codexHomePathResolver: CodexHomePathResolver | null = null + protected codexFetchTarget: NormalizedCodexAccountSelectionTarget = { + runtime: 'host', + wslDistro: null + } + // Why: resolved per cycle — the local-account runtime policy can flip between fetches. + protected kimiHomeResolver: KimiHomeResolver | null = null + protected claudeAuthPreparationResolver: ClaudeAuthPreparationResolver | null = null + protected claudeFetchTarget: NormalizedClaudeAccountSelectionTarget = { + runtime: 'host', + wslDistro: null + } + protected openCodeGoConfigResolver: (() => OpenCodeGoRateLimitConfig) | null = null + protected miniMaxConfigResolver: (() => MiniMaxRateLimitConfig) | null = null + protected geminiCliOAuthEnabledResolver: GeminiCliOAuthEnabledResolver | null = null + protected inactiveClaudeAccountsResolver: (() => InactiveClaudeAccountInfo[]) | null = null + protected inactiveCodexAccountsResolver: (() => InactiveCodexAccountInfo[]) | null = null + protected networkProxySettingsResolver: (() => NetworkProxySettings) | null = null + protected inactiveClaudeCache = new Map() + protected inactiveCodexCache = new Map() + protected inactiveClaudeFetching = new Set() + protected inactiveCodexFetching = new Set() + protected inactiveCodexFetchInFlight = false + protected lastInactiveClaudeFetchAt = 0 + protected inactiveClaudeAccountsGeneration = 0 + protected lastInactiveCodexFetchAt = 0 + protected inactiveCodexAccountsGeneration = 0 + protected stateListeners = new Set<(state: RateLimitState) => void>() + + constructor() {} + + onStateChange(listener: (state: RateLimitState) => void): () => void { + this.stateListeners.add(listener) + return () => { + this.stateListeners.delete(listener) + } + } + + protected abstract getState(): RateLimitState + + protected buildInactiveArray( + cache: Map, + fetching: Set + ): InactiveAccountUsage[] { + const result: InactiveAccountUsage[] = [] + for (const [accountId, limits] of cache) { + result.push({ + accountId, + rateLimits: limits, + updatedAt: limits.updatedAt, + isFetching: fetching.has(accountId) + }) + } + // Why: include fetching-but-uncached accounts so the renderer shows a loading indicator for newly added accounts. + for (const accountId of fetching) { + if (!cache.has(accountId)) { + result.push({ + accountId, + rateLimits: null, + updatedAt: 0, + isFetching: true + }) + } + } + return result + } + + protected updateState(next: InternalRateLimitState): void { + this.state = next + this.pushToRenderer() + } + + protected pushToRenderer(): void { + const state = this.getState() + for (const listener of this.stateListeners) { + try { + listener(state) + } catch { + // ignore — one bad listener must not break the others + } + } + if (!this.mainWindow || this.mainWindow.isDestroyed()) { + return + } + this.mainWindow.webContents.send('rateLimits:update', state) + } +} diff --git a/src/main/rate-limits/service/service-types.ts b/src/main/rate-limits/service/service-types.ts new file mode 100644 index 00000000000..0b38bd39433 --- /dev/null +++ b/src/main/rate-limits/service/service-types.ts @@ -0,0 +1,163 @@ +import type { ProviderRateLimits } from '../../../shared/rate-limit-types' +import type { ClaudeRuntimeAuthPreparation } from '../../claude-accounts/runtime-auth-service' +import type { ClaudeAccountSelectionTarget } from '../../claude-accounts/runtime-selection' +import type { KimiHomeResolution } from '../../kimi/kimi-runtime-home' +import type { CodexAccountSelectionTarget } from '../../codex-accounts/runtime-selection' +import type { CodexRateLimitHomeResolution } from '../../codex-accounts/runtime-home-service' + +export type { + CodexRateLimitResetResult, + RateLimitState, + ProviderRateLimits, + InactiveAccountUsage, + RateLimitRuntimeTarget +} from '../../../shared/rate-limit-types' +export type { InactiveClaudeAccountInfo } from '../claude-fetcher' +export type { ClaudeStatusLineRateLimits } from '../../../shared/claude-statusline-rate-limits' +export type { NetworkProxySettings } from '../../../shared/network-proxy' +export type { ClaudeRuntimeAuthPreparation } from '../../claude-accounts/runtime-auth-service' +export type { + ClaudeAccountSelectionTarget, + NormalizedClaudeAccountSelectionTarget +} from '../../claude-accounts/runtime-selection' +export { normalizeClaudeAccountSelectionTarget } from '../../claude-accounts/runtime-selection' +export type { + CodexAccountSelectionTarget, + NormalizedCodexAccountSelectionTarget +} from '../../codex-accounts/runtime-selection' +export { normalizeCodexAccountSelectionTarget } from '../../codex-accounts/runtime-selection' +export type { CodexRateLimitHomeResolution } from '../../codex-accounts/runtime-home-service' + +export type InactiveCodexAccountInfo = { + id: string + resolveHome: () => { kind: 'ready'; managedHomePath: string } | { kind: 'skip' } +} + +export type CodexHomePathResolver = ( + target?: CodexAccountSelectionTarget +) => CodexRateLimitHomeResolution +export type KimiHomeResolver = () => Promise +export type ClaudeAuthPreparationResolver = ( + target?: ClaudeAccountSelectionTarget +) => Promise + +export type OpenCodeGoRateLimitConfig = { + sessionCookie: string + workspaceIdOverride: string +} + +export type MiniMaxRateLimitConfig = { + sessionCookie: string + groupId: string + models: string +} + +export type MiniMaxResolvedConfig = { + config: MiniMaxRateLimitConfig + error: string | null +} + +export type GeminiCliOAuthEnabledResolver = () => boolean +export type ActiveRateLimitProvider = ProviderRateLimits['provider'] +export type ActiveProviderState = { + provider: ActiveRateLimitProvider + limits: ProviderRateLimits | null +} +export type ActiveWindowRefreshPlan = + | { kind: 'none' } + | { kind: 'full' } + | { kind: 'providers'; providers: ActiveRateLimitProvider[] } + +// Why: Claude's usage endpoint has a tight budget and quota is only informational; prefer a recent snapshot over polling into 429s. +export const DEFAULT_POLL_MS = 15 * 60 * 1000 // 15 minutes +export const MIN_POLL_MS = 30 * 1000 // 30 seconds — renderer input should never create a tight loop. +export const MAX_POLL_MS = 2_147_483_647 // Max safe setInterval delay before Node clamps back to 1ms. +export const MIN_REFETCH_MS = 5 * 60 * 1000 // 5 minutes — debounce resume/manual refresh bursts +export const ACTIVE_FAILURE_REFETCH_MS = MIN_POLL_MS +// Why: retrying a persistent failure at the 30s floor hammers endpoints into 429s; back off per failure, capped at the poll cadence. +export const MAX_ACTIVE_FAILURE_REFETCH_MS = DEFAULT_POLL_MS +export const MAX_ACTIVE_FAILURE_STREAK = 8 +// Why: these providers have a dedicated fetch cycle, so an activation retry refreshes just the failing one; others force a full fetchAll. +export const INDIVIDUALLY_REFRESHABLE_PROVIDERS: ReadonlySet = new Set([ + 'claude', + 'codex', + 'grok' +]) +export const STALE_THRESHOLD_MS = 30 * 60 * 1000 // 30 minutes — after this, stale data is dropped +// Why: usage-endpoint 429 windows can outlast the generic threshold (Retry-After ~1h); quota is informational, so a stale snapshot beats a bare "Limited". +export const RATE_LIMITED_STALE_THRESHOLD_MS = 24 * 60 * 60 * 1000 +// Why: statusline posts arrive on every turn; skip renderer pushes for identical windows so streaming sessions don't spam state updates. +export const LIVE_CLAUDE_INGEST_DEDUPE_MS = 30 * 1000 +export const INACTIVE_FETCH_DEBOUNCE_MS = 60 * 1000 // 60 seconds — debounce fetch-on-open +// Why: each inactive Codex probe spawns a real codex process inside that +// account's live credential home; pace them out instead of bursting every +// account the moment the switcher opens. +export const INACTIVE_CODEX_PROBE_STAGGER_MS = 2_000 +export const DEFERRED_STARTUP_ACTIVE_REFRESH_MS = 1000 + +// Why: inactive account arrays are derived from provider caches on demand in getState()/pushToRenderer(). +export type InternalRateLimitState = { + claude: ProviderRateLimits | null + codex: ProviderRateLimits | null + gemini: ProviderRateLimits | null + opencodeGo: ProviderRateLimits | null + kimi: ProviderRateLimits | null + antigravity: ProviderRateLimits | null + minimax: ProviderRateLimits | null + grok: ProviderRateLimits | null +} + +export function normalizePollingInterval(ms: number): number { + if (!Number.isFinite(ms)) { + return DEFAULT_POLL_MS + } + return Math.min(MAX_POLL_MS, Math.max(MIN_POLL_MS, ms)) +} + +export function isSystemDefaultClaudeAuth( + authPreparation: ClaudeRuntimeAuthPreparation | undefined +): boolean { + // Why: fetch cycles treat missing Claude auth as system-default; align the PTY gate so refresh can't trigger auth flows. + if (!authPreparation) { + return true + } + const provenance = authPreparation?.provenance + return provenance === 'system' || Boolean(provenance?.endsWith(':system')) +} + +export function toErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} + +export function normalizeClaudeConfigDir(dir: string | null | undefined): string | null { + // Why: normalize mixed Windows separators for path attribution; preserve Linux case sensitivity. + const trimmed = dir?.trim().replace(/\\/g, '/').replace(/\/+$/, '') + return trimmed || null +} + +export function delayUnlessAborted(ms: number, signal: AbortSignal): Promise { + if (signal.aborted) { + return Promise.resolve() + } + return new Promise((resolve) => { + const onAbort = (): void => { + clearTimeout(timer) + resolve() + } + const timer = setTimeout(() => { + signal.removeEventListener('abort', onAbort) + resolve() + }, ms) + signal.addEventListener('abort', onAbort, { once: true }) + }) +} + +export function isSameUsageWindow( + a: ProviderRateLimits['session'], + b: ProviderRateLimits['session'] +): boolean { + if (!a || !b) { + return a === b + } + return a.usedPercent === b.usedPercent && a.resetsAt === b.resetsAt +} diff --git a/src/main/repo-maintenance-idle-gate.test.ts b/src/main/repo-maintenance-idle-gate.test.ts new file mode 100644 index 00000000000..78728f3a43a --- /dev/null +++ b/src/main/repo-maintenance-idle-gate.test.ts @@ -0,0 +1,134 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const isOnBatteryPowerMock = vi.hoisted(() => vi.fn(() => false)) +const hasPendingPreparationsMock = vi.hoisted(() => vi.fn(() => false)) +const hasRemovalsInFlightMock = vi.hoisted(() => vi.fn(() => false)) +const setProbeMock = vi.hoisted(() => vi.fn()) +const disposeMock = vi.hoisted(() => vi.fn(async () => {})) +const postponeMock = vi.hoisted(() => vi.fn()) +const powerListeners = vi.hoisted(() => new Map void>()) +const appListeners = vi.hoisted(() => new Map void>()) + +vi.mock('electron', () => ({ + app: { + on: (event: string, listener: () => void) => appListeners.set(event, listener), + off: (event: string) => appListeners.delete(event) + }, + powerMonitor: { + isOnBatteryPower: isOnBatteryPowerMock, + on: (event: string, listener: () => void) => powerListeners.set(event, listener), + off: (event: string) => powerListeners.delete(event) + } +})) + +vi.mock('./worktree-create-preparation', () => ({ + hasPendingWorktreeCreatePreparations: hasPendingPreparationsMock +})) + +vi.mock('./ipc/worktrees/worktree-ipc-context', () => ({ + hasWorktreeRemovalsInFlight: hasRemovalsInFlightMock +})) + +vi.mock('./git/local-repo-ref-maintenance', () => ({ + setRepoMaintenanceActivityProbe: setProbeMock, + disposeLocalRepoRefMaintenance: disposeMock, + postponeRepoRefMaintenance: postponeMock +})) + +import { installRepoMaintenanceIdleGate } from './repo-maintenance-idle-gate' + +function installProbe( + overrides: Partial<{ isQuitting: () => boolean; getWorkingAgentCount: () => number }> = {} +): { probe: () => boolean; uninstall: () => Promise } { + const uninstall = installRepoMaintenanceIdleGate({ + isQuitting: () => false, + getWorkingAgentCount: () => 0, + ...overrides + }) + return { probe: setProbeMock.mock.calls.at(-1)?.[0] as () => boolean, uninstall } +} + +beforeEach(() => { + isOnBatteryPowerMock.mockReturnValue(false) + hasPendingPreparationsMock.mockReturnValue(false) + hasRemovalsInFlightMock.mockReturnValue(false) + postponeMock.mockClear() + powerListeners.clear() + appListeners.clear() + setProbeMock.mockClear() + disposeMock.mockClear() +}) + +afterEach(() => { + vi.restoreAllMocks() +}) + +describe('repo maintenance idle gate', () => { + it('reports idle when nothing is happening', () => { + expect(installProbe().probe()).toBe(false) + }) + + it('vetoes while an agent is working', () => { + expect(installProbe({ getWorkingAgentCount: () => 1 }).probe()).toBe(true) + }) + + it('vetoes while a worktree create is prepared or in flight', () => { + hasPendingPreparationsMock.mockReturnValue(true) + + expect(installProbe().probe()).toBe(true) + }) + + it('vetoes while a worktree removal is deleting refs', () => { + // Removal deletes branches, and a ref deletion needs the same packed-refs lock. + hasRemovalsInFlightMock.mockReturnValue(true) + + expect(installProbe().probe()).toBe(true) + }) + + it('vetoes on battery power', () => { + isOnBatteryPowerMock.mockReturnValue(true) + + expect(installProbe().probe()).toBe(true) + }) + + it('vetoes during shutdown', () => { + expect(installProbe({ isQuitting: () => true }).probe()).toBe(true) + }) + + it('treats an unavailable power API as not-on-battery', () => { + isOnBatteryPowerMock.mockImplementation(() => { + throw new Error('unsupported') + }) + + expect(installProbe().probe()).toBe(false) + }) + + it('pushes the next attempt out when the machine drops onto battery', () => { + // Do-not-start, never stop-what-is-running: killing a pack to honour a + // battery change would strand a ref lock to save a little unlinking. + installProbe() + + powerListeners.get('on-battery')?.() + + expect(postponeMock).toHaveBeenCalledTimes(1) + }) + + it('pushes the next attempt out when the user comes back to the window', () => { + // A focus transition, not focus itself: a window left focused while the user + // walks away fires no event and blocks nothing. + installProbe() + + appListeners.get('browser-window-focus')?.() + + expect(postponeMock).toHaveBeenCalledTimes(1) + }) + + it('cancels armed timers, unsubscribes both sources, and clears the probe when uninstalled', async () => { + await installProbe().uninstall() + + expect(disposeMock).toHaveBeenCalledTimes(1) + expect(powerListeners.has('on-battery')).toBe(false) + expect(appListeners.has('browser-window-focus')).toBe(false) + expect(setProbeMock).toHaveBeenLastCalledWith(null) + }) +}) diff --git a/src/main/repo-maintenance-idle-gate.ts b/src/main/repo-maintenance-idle-gate.ts new file mode 100644 index 00000000000..78bb25fe68c --- /dev/null +++ b/src/main/repo-maintenance-idle-gate.ts @@ -0,0 +1,67 @@ +import { app, powerMonitor } from 'electron' +import { + disposeLocalRepoRefMaintenance, + postponeRepoRefMaintenance, + setRepoMaintenanceActivityProbe +} from './git/local-repo-ref-maintenance' +import { hasWorktreeRemovalsInFlight } from './ipc/worktrees/worktree-ipc-context' +import { hasPendingWorktreeCreatePreparations } from './worktree-create-preparation' + +/** + * The app-wide "not now" answer for idle repo maintenance. + * + * `pack-refs` holds a general git admission slot for its whole run, which on a + * large backlog is minutes, and takes the `packed-refs` lock while it writes. + * Any ref deletion needs that same lock and gives up after + * `core.packedRefsTimeout` (1s), so worktree removal in particular has to veto + * this -- as does a create in flight, an agent mid-run, and shutdown. Battery is + * a veto too: this is work the user did not ask for, and a plugged-in quiet + * window always comes along later. + */ +export type RepoMaintenanceIdleInputs = { + isQuitting: () => boolean + getWorkingAgentCount: () => number +} + +export function installRepoMaintenanceIdleGate( + inputs: RepoMaintenanceIdleInputs +): () => Promise { + setRepoMaintenanceActivityProbe( + () => + inputs.isQuitting() || + inputs.getWorkingAgentCount() > 0 || + hasPendingWorktreeCreatePreparations() || + hasWorktreeRemovalsInFlight() || + isOnBatteryPower() + ) + // Do-not-start, never stop-what-is-running. Killing a pack to honour a battery + // or focus change would strand a ref lock roughly one time in five to save at + // most a couple of minutes of background unlinking; pushing the next attempt + // out costs nothing and risks nothing. + const onBattery = (): void => { + postponeRepoRefMaintenance() + } + const onFocus = (): void => { + postponeRepoRefMaintenance() + } + powerMonitor.on('on-battery', onBattery) + app.on('browser-window-focus', onFocus) + return () => { + app.off('browser-window-focus', onFocus) + powerMonitor.off('on-battery', onBattery) + // Order matters: clearing the probe alone would leave armed timers running + // against a gate that can no longer see agents, creates, or shutdown. + const stopped = disposeLocalRepoRefMaintenance() + setRepoMaintenanceActivityProbe(null) + return stopped + } +} + +function isOnBatteryPower(): boolean { + try { + return powerMonitor.isOnBatteryPower() + } catch { + // Absence of the API is not evidence of battery; desktops answer false anyway. + return false + } +} diff --git a/src/main/repo-worktrees.test.ts b/src/main/repo-worktrees.test.ts index d0c0ea5c617..d1c3935c1e1 100644 --- a/src/main/repo-worktrees.test.ts +++ b/src/main/repo-worktrees.test.ts @@ -1,11 +1,13 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -const { listWorktreesMock, listWorktreesStrictMock } = vi.hoisted(() => ({ +const { listWorktreeGraphMock, listWorktreesMock, listWorktreesStrictMock } = vi.hoisted(() => ({ + listWorktreeGraphMock: vi.fn(), listWorktreesMock: vi.fn(), listWorktreesStrictMock: vi.fn() })) vi.mock('./git/worktree', () => ({ + listWorktreeGraph: listWorktreeGraphMock, listWorktrees: listWorktreesMock, listWorktreesStrict: listWorktreesStrictMock })) @@ -14,11 +16,13 @@ import { createFolderWorktree, isRepoRoot, listLocalRepoWorktreesStrict, + listRepoWorktreeGraph, listRepoWorktrees } from './repo-worktrees' describe('repo-worktrees', () => { beforeEach(() => { + listWorktreeGraphMock.mockReset() listWorktreesMock.mockReset() listWorktreesStrictMock.mockReset() }) @@ -109,6 +113,49 @@ describe('repo-worktrees', () => { expect(result).toHaveLength(1) }) + // Path-only callers must reach the probe-free listing, never the annotated one. + it('delegates to the graph listing without sparse annotation', async () => { + listWorktreeGraphMock.mockResolvedValue([ + { path: '/workspace/repo', head: 'abc', branch: '', isBare: false, isMainWorktree: true } + ]) + + const result = await listRepoWorktreeGraph({ + id: 'repo-1', + path: '/workspace/repo', + displayName: 'repo', + badgeColor: '#000', + addedAt: 0, + kind: 'git' + }) + + expect(listWorktreeGraphMock).toHaveBeenCalledWith('/workspace/repo') + expect(listWorktreesMock).not.toHaveBeenCalled() + expect(result).toHaveLength(1) + }) + + it('returns the synthetic folder worktree from the graph listing', async () => { + const result = await listRepoWorktreeGraph({ + id: 'repo-1', + path: '/workspace/folder', + displayName: 'folder', + badgeColor: '#000', + addedAt: 0, + kind: 'folder' + }) + + expect(listWorktreeGraphMock).not.toHaveBeenCalled() + expect(result).toEqual([ + createFolderWorktree({ + id: 'repo-1', + path: '/workspace/folder', + displayName: 'folder', + badgeColor: '#000', + addedAt: 0, + kind: 'folder' + }) + ]) + }) + it('delegates strict local listing with the signal and WSL options', async () => { listWorktreesStrictMock.mockResolvedValue([ { path: '/workspace/repo', head: 'abc', branch: '', isBare: false, isMainWorktree: true } diff --git a/src/main/repo-worktrees.ts b/src/main/repo-worktrees.ts index 14751c17861..c5e756b9310 100644 --- a/src/main/repo-worktrees.ts +++ b/src/main/repo-worktrees.ts @@ -1,6 +1,6 @@ import type { Repo } from '../shared/repo-types' import type { GitWorktreeInfo } from '../shared/worktree/types' -import { listWorktrees, listWorktreesStrict } from './git/worktree' +import { listWorktreeGraph, listWorktrees, listWorktreesStrict } from './git/worktree' import { isFolderRepo } from '../shared/repo-kind' import { getSshGitProvider } from './providers/ssh-git-dispatch' import { areWorktreePathsEqual } from './ipc/worktree-logic' @@ -52,6 +52,29 @@ export async function listRepoWorktrees( : await listWorktrees(repo.path) } +/** + * Worktree rows for callers that read only `worktree.path`. + * + * Skips the sparse-checkout probe behind the badge, which those callers discard. On a WSL repo the + * probe is a 9p stat plus a config read per worktree, re-paid cold after every worktree + * create/remove because that invalidates both the authorized-roots cache and the sparse cache. + */ +export async function listRepoWorktreeGraph( + repo: Repo, + options?: LocalRepoWorktreeListOptions +): Promise { + if (isFolderRepo(repo)) { + return [createFolderWorktree(repo)] + } + if (repo.connectionId) { + const provider = getSshGitProvider(repo.connectionId) + return provider ? await provider.listWorktrees(repo.path) : [] + } + return hasLocalRepoWorktreeListOptions(options) + ? await listWorktreeGraph(repo.path, options) + : await listWorktreeGraph(repo.path) +} + export async function listLocalRepoWorktreesStrict( repo: Repo, options?: LocalRepoWorktreeListOptions diff --git a/src/main/runtime/agent-session-claim-key-state.ts b/src/main/runtime/agent-session-claim-key-state.ts deleted file mode 100644 index f9fe77cbdc5..00000000000 --- a/src/main/runtime/agent-session-claim-key-state.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { classifyObservedAgentSessionSpawnToken } from '../../shared/agent-session-lease-adjudication' -import type { AgentSessionRecord } from '../../shared/agent-session-record' -import type { AgentSessionStoreState } from './agent-session-record-store-file' - -export function isVerifiable( - state: AgentSessionStoreState, - keyId: string, - now: number, - retentionMs: number -): boolean { - const retired = state.retiredClaimKeys.find((entry) => entry.keyId === keyId) - return !retired || now - retired.retiredAt <= retentionMs -} - -export function markConflicted(record: AgentSessionRecord, now: number): AgentSessionRecord { - return { - ...record, - updatedAt: now, - // A conflicted key must remain conflicted after its observing process exits. - lease: { ...record.lease, claimStatus: 'conflicted', handoffStage: 'manual-recovery' } - } -} - -export function retire( - state: AgentSessionStoreState, - keyId: string, - now: number, - retentionMs: number -): void { - if (!state.retiredClaimKeys.some((entry) => entry.keyId === keyId)) { - state.retiredClaimKeys.push({ keyId, retiredAt: now }) - } - state.retiredClaimKeys = state.retiredClaimKeys.filter( - (entry) => now - entry.retiredAt <= retentionMs - ) -} - -export function listOrphanSpawnTokens( - records: readonly AgentSessionRecord[], - observedTokens: readonly string[] -): string[] { - const leases = records.map((record) => record.lease) - return observedTokens.filter( - (spawnToken) => classifyObservedAgentSessionSpawnToken({ spawnToken, leases }) === 'orphan' - ) -} diff --git a/src/main/runtime/external-worktree-paired-client-discovery.integration.test.ts b/src/main/runtime/external-worktree-paired-client-discovery.integration.test.ts index c58be0c25fd..f9af8d126fa 100644 --- a/src/main/runtime/external-worktree-paired-client-discovery.integration.test.ts +++ b/src/main/runtime/external-worktree-paired-client-discovery.integration.test.ts @@ -5,6 +5,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { listWorktrees, listWorktreesStrict } from '../git/worktree' import { scheduleWorktreeBaseNotification } from '../ipc/worktree-base-directory-notifications' import { createWorktreeHeadIdentityRefreshState } from '../ipc/worktree-head-identity-refresh' +import { EMPTY_HEAD_IDENTITY_SCOPE } from '../ipc/worktree-head-identity-scope' import { setWorktreeCatalogRemoteClientNotifier } from '../ipc/watched-worktree-catalog-notification' import { OrcaRuntimeService } from './orca-runtime' import { @@ -166,6 +167,7 @@ describe('external worktree discovery for paired clients', () => { pendingStructureRepoIds: new Set(), pendingGitStatusRepoIds: new Set(), pendingHeadIdentityRepoIds: new Set(), + pendingHeadIdentityScope: EMPTY_HEAD_IDENTITY_SCOPE, headIdentityRefresh: createWorktreeHeadIdentityRefreshState(), disposed: false } @@ -352,6 +354,7 @@ describe('external worktree discovery for paired clients', () => { pendingStructureRepoIds: new Set(), pendingGitStatusRepoIds: new Set(), pendingHeadIdentityRepoIds: new Set(), + pendingHeadIdentityScope: EMPTY_HEAD_IDENTITY_SCOPE, headIdentityRefresh: createWorktreeHeadIdentityRefreshState(), disposed: false } @@ -437,6 +440,7 @@ describe('external worktree discovery for paired clients', () => { pendingStructureRepoIds: new Set(), pendingGitStatusRepoIds: new Set(), pendingHeadIdentityRepoIds: new Set(), + pendingHeadIdentityScope: EMPTY_HEAD_IDENTITY_SCOPE, headIdentityRefresh: createWorktreeHeadIdentityRefreshState(), disposed: false } @@ -493,6 +497,7 @@ describe('external worktree discovery for paired clients', () => { pendingStructureRepoIds: new Set(), pendingGitStatusRepoIds: new Set(), pendingHeadIdentityRepoIds: new Set(), + pendingHeadIdentityScope: EMPTY_HEAD_IDENTITY_SCOPE, headIdentityRefresh: createWorktreeHeadIdentityRefreshState(), disposed: false } diff --git a/src/main/runtime/fetch-remote-cache.test.ts b/src/main/runtime/fetch-remote-cache.test.ts index fc2d761c059..c97966c344e 100644 --- a/src/main/runtime/fetch-remote-cache.test.ts +++ b/src/main/runtime/fetch-remote-cache.test.ts @@ -133,9 +133,11 @@ describe('OrcaRuntimeService.fetchRemoteWithCache', () => { const first = runtime.fetchRemoteWithCache('/repo/c', 'origin') const second = runtime.fetchRemoteWithCache('/repo/c', 'origin') - // Allow both callers to register before we resolve. - await Promise.resolve() - await Promise.resolve() + // Allow both callers to register before we resolve. Each canonicalizes the + // repo key first, so the dispatch lands several microtasks in. + for (let tick = 0; tick < 8; tick += 1) { + await Promise.resolve() + } expect(fetchCallCount()).toBe(1) resolveFetch() diff --git a/src/main/runtime/orca-runtime-tests/worktree-removal-and-reconciliation.spec.ts b/src/main/runtime/orca-runtime-tests/worktree-removal-and-reconciliation.spec.ts index fc02a6f18c2..64caa486013 100644 --- a/src/main/runtime/orca-runtime-tests/worktree-removal-and-reconciliation.spec.ts +++ b/src/main/runtime/orca-runtime-tests/worktree-removal-and-reconciliation.spec.ts @@ -412,7 +412,7 @@ describe('OrcaRuntimeService', () => { [ 'fetch', 'pr-contributor-orca', - '+refs/heads/contributor/runtime-wsl:refs/remotes/pr-contributor-orca/contributor/runtime-wsl' + '+refs/heads/contributor/runtime-wsl*:refs/remotes/pr-contributor-orca/contributor/runtime-wsl*' ], { cwd: TEST_REPO_PATH, wslDistro: 'Ubuntu' } ) diff --git a/src/main/runtime/relay/mobile-relay-e2ee.integration.test.ts b/src/main/runtime/relay/mobile-relay-e2ee.integration.test.ts index 1f2b50e0648..bf9ec2ce431 100644 --- a/src/main/runtime/relay/mobile-relay-e2ee.integration.test.ts +++ b/src/main/runtime/relay/mobile-relay-e2ee.integration.test.ts @@ -61,7 +61,8 @@ describe('desktop relay E2EE integration', () => { }) it('splices a simulated phone through CloudRelayTransport with real NaCl E2EE v2', async () => { - const relay = new WebSocketServer({ port: 0, perMessageDeflate: false }) + // host must match the 127.0.0.1 clients dial: a wildcard bind lets a foreign loopback listener claim the port and answer here. + const relay = new WebSocketServer({ host: '127.0.0.1', port: 0, perMessageDeflate: false }) servers.push(relay) await new Promise((resolve) => relay.once('listening', resolve)) const address = relay.address() diff --git a/src/main/runtime/relay/relay-control-client.test.ts b/src/main/runtime/relay/relay-control-client.test.ts index a1ad5c03482..2975b67e631 100644 --- a/src/main/runtime/relay/relay-control-client.test.ts +++ b/src/main/runtime/relay/relay-control-client.test.ts @@ -99,7 +99,8 @@ describe('RelayControlClient', () => { }) it('rejects a control handshake that never receives a proof response', async () => { - const server = new WebSocketServer({ port: 0, perMessageDeflate: false }) + // host must match the 127.0.0.1 clients dial: a wildcard bind lets a foreign loopback listener claim the port and answer here. + const server = new WebSocketServer({ host: '127.0.0.1', port: 0, perMessageDeflate: false }) servers.push(server) await new Promise((resolve) => server.once('listening', resolve)) const address = server.address() @@ -129,7 +130,7 @@ describe('RelayControlClient', () => { }) it('settles an opening control immediately when ownership closes', async () => { - const server = new WebSocketServer({ port: 0, perMessageDeflate: false }) + const server = new WebSocketServer({ host: '127.0.0.1', port: 0, perMessageDeflate: false }) servers.push(server) await new Promise((resolve) => server.once('listening', resolve)) const address = server.address() @@ -165,7 +166,7 @@ describe('RelayControlClient', () => { }) it('proves the host key and drives control/data commands without URL credentials', async () => { - const server = new WebSocketServer({ port: 0, perMessageDeflate: false }) + const server = new WebSocketServer({ host: '127.0.0.1', port: 0, perMessageDeflate: false }) servers.push(server) await new Promise((resolve) => server.once('listening', resolve)) const address = server.address() diff --git a/src/main/runtime/rpc/methods/artifacts.test.ts b/src/main/runtime/rpc/methods/artifacts.test.ts index 478e5b030d5..f08db1e0c88 100644 --- a/src/main/runtime/rpc/methods/artifacts.test.ts +++ b/src/main/runtime/rpc/methods/artifacts.test.ts @@ -46,7 +46,7 @@ describe('artifact RPC schemas', () => { ).toBe(false) }) - it('accepts a 5 MiB UTF-8 artifact at the content boundary', () => { + it('accepts a 10 MiB UTF-8 artifact at the content boundary', () => { expect( writeSchema('artifacts.publish').safeParse({ ...validRequest, @@ -56,7 +56,9 @@ describe('artifact RPC schemas', () => { }) it('measures the content boundary in UTF-8 bytes', () => { - const exact = `${'€'.repeat(Math.floor(ARTIFACT_MAX_CONTENT_BYTES / 3))}aa` + const euroCount = Math.floor(ARTIFACT_MAX_CONTENT_BYTES / 3) + const asciiBytes = ARTIFACT_MAX_CONTENT_BYTES - euroCount * 3 + const exact = `${'€'.repeat(euroCount)}${'a'.repeat(asciiBytes)}` const oversized = `${exact}€` expect(new TextEncoder().encode(exact).byteLength).toBe(ARTIFACT_MAX_CONTENT_BYTES) expect(new TextEncoder().encode(oversized).byteLength).toBeGreaterThan( @@ -70,11 +72,11 @@ describe('artifact RPC schemas', () => { ).toBe(false) }) - it('allows JSON escaping within the bounded 5 MiB content request', () => { + it('allows JSON escaping within the bounded content request', () => { expect( writeSchema('artifacts.publish').safeParse({ ...validRequest, - content: '"'.repeat(ARTIFACT_MAX_CONTENT_BYTES) + content: '"'.repeat(Math.floor(ARTIFACT_MAX_CONTENT_BYTES / 2)) }).success ).toBe(true) }) diff --git a/src/main/runtime/rpc/methods/artifacts.ts b/src/main/runtime/rpc/methods/artifacts.ts index ace49a53ed3..4b5d7b5ab3a 100644 --- a/src/main/runtime/rpc/methods/artifacts.ts +++ b/src/main/runtime/rpc/methods/artifacts.ts @@ -30,7 +30,7 @@ const WriteRequest = z .min(1) .max(ARTIFACT_MAX_CONTENT_BYTES) .refine((content) => artifactContentByteLength(content) <= ARTIFACT_MAX_CONTENT_BYTES, { - message: 'Artifact content exceeds the 5 MiB limit.' + message: 'Artifact content exceeds the 10 MiB limit.' }), contentType: z.enum(['text/html', 'text/markdown']), fileName: z.string().min(1).max(512), diff --git a/src/main/runtime/rpc/methods/orchestration-ask-methods.ts b/src/main/runtime/rpc/methods/orchestration-ask-methods.ts new file mode 100644 index 00000000000..fb5194df87d --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration-ask-methods.ts @@ -0,0 +1,168 @@ +import { defineMethod, type RpcMethod } from '../core' +import { OrchestrationError } from '../../orchestration/orchestration-error' +import { clampOrchestrationAskTimeoutMs } from '../../../../shared/orchestration-ask-timeout' +import { isGroupAddress } from '../../orchestration/groups' +import { AskParams } from './orchestration-schemas' +import { rejectFederatedExplicitTarget } from './orchestration-routing' +import { askRemoteRunHome } from './orchestration-ask-remote' + +export const ORCHESTRATION_ASK_METHODS: RpcMethod[] = [ + defineMethod({ + name: 'orchestration.ask', + params: AskParams, + handler: async ( + params, + { runtime, signal, orchestrationCapability, recordMutationReceipt } + ) => { + // Why: group addresses have no unambiguous first-answer authority. + if (params.to && isGroupAddress(params.to)) { + throw new Error( + 'ask does not support group addresses; use send for non-blocking fan-out questions' + ) + } + + const db = runtime.getOrchestrationDb() + const from = params.from ?? 'unknown' + // Why: echoed on every return so a clamped caller reports the budget actually waited, not the one it asked for. + const timeoutMs = clampOrchestrationAskTimeoutMs(params.timeoutMs) + const paneKey = runtime.getTerminalPaneKey(from) ?? undefined + const remoteAttachment = paneKey ? db.findActiveRemoteAttachmentForPane(paneKey) : undefined + if (remoteAttachment) { + rejectFederatedExplicitTarget(params) + return askRemoteRunHome({ + params: { ...params, timeoutMs }, + runtime, + signal, + orchestrationCapability, + recordMutationReceipt, + from, + paneKey: paneKey as string, + dispatchId: remoteAttachment.dispatch_id, + taskId: remoteAttachment.task_id + }) + } + const activeDispatch = db.getActiveDispatchForIdentity(from, paneKey) + if (!activeDispatch) { + throw new OrchestrationError( + 'dispatch_inactive', + 'ask requires an active supervised Dispatch.' + ) + } + if (activeDispatch.capability_hash) { + const authority = db.verifyDispatchCapability({ + dispatchId: activeDispatch.id, + capability: orchestrationCapability, + paneKey, + processIncarnation: runtime.getTerminalProcessIncarnation(from) ?? undefined + }) + if (!authority.valid) { + throw new OrchestrationError('dispatch_capability_invalid', authority.reason) + } + } + const options = + params.options + ?.split(',') + .map((s) => s.trim()) + .filter(Boolean) ?? [] + let question = params.resume ? db.getQuestion(params.resume) : undefined + if (params.resume) { + if (!question || question.dispatch_id !== activeDispatch.id) { + throw new OrchestrationError( + 'question_not_found', + `Question ${params.resume} does not belong to this active Dispatch.` + ) + } + } else { + const run = db.getRun(activeDispatch.run_id) + if (!run || run.legacy === 1) { + throw new OrchestrationError( + 'run_not_found', + `Run ${activeDispatch.run_id} was not found.` + ) + } + if (params.run && params.run !== run.id) { + throw new OrchestrationError( + 'dispatch_run_mismatch', + `Dispatch ${activeDispatch.id} belongs to Run ${run.id}, not ${params.run}.` + ) + } + if (params.to && params.to !== `run:${run.id}` && params.to !== run.coordinator_handle) { + throw new OrchestrationError( + 'dispatch_run_mismatch', + `ask from Dispatch ${activeDispatch.id} must target its owning Run ${run.id}.` + ) + } + const created = db.createQuestion({ + runId: run.id, + dispatchId: activeDispatch.id, + askerHandle: from, + question: params.question as string, + options + }) + question = created.question + runtime.notifyMessageArrived(`run:${run.id}`, created.message.type) + } + + const questionId = question.message_id + recordMutationReceipt?.({ + accepted: true, + answer: null, + messageId: questionId, + threadId: questionId, + timedOut: false, + cancelled: false, + connectionLost: false, + timeoutMs + }) + const deadline = Date.now() + timeoutMs + while (true) { + const current = db.getQuestion(questionId) + if (!current || current.status === 'closed') { + throw new OrchestrationError( + 'dispatch_inactive', + `Question ${questionId} closed because its Dispatch is inactive.` + ) + } + if (current.status === 'answered') { + return { + answer: current.answer_body, + messageId: questionId, + answerMessageId: current.answer_message_id, + threadId: questionId, + timedOut: false, + cancelled: false, + connectionLost: false, + timeoutMs + } + } + if (signal?.aborted) { + return { + answer: null, + messageId: questionId, + threadId: questionId, + timedOut: false, + cancelled: true, + connectionLost: true, + timeoutMs + } + } + const remainingMs = deadline - Date.now() + if (remainingMs <= 0) { + return { + answer: null, + messageId: questionId, + threadId: questionId, + timedOut: true, + cancelled: false, + connectionLost: false, + timeoutMs + } + } + await runtime.waitForMessage(`dispatch:${activeDispatch.id}`, { + timeoutMs: remainingMs, + signal + }) + } + } + }) +] diff --git a/src/main/runtime/rpc/methods/orchestration-ask-remote.ts b/src/main/runtime/rpc/methods/orchestration-ask-remote.ts new file mode 100644 index 00000000000..4b76e626e87 --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration-ask-remote.ts @@ -0,0 +1,129 @@ +import type { z } from 'zod' +import type { OrcaRuntimeService } from '../../orca-runtime' +import { OrchestrationError } from '../../orchestration/orchestration-error' +import { clampOrchestrationAskTimeoutMs } from '../../../../shared/orchestration-ask-timeout' +import type { AskParams } from './orchestration-schemas' + +export async function askRemoteRunHome(args: { + params: z.infer + runtime: OrcaRuntimeService + signal?: AbortSignal + orchestrationCapability?: string + recordMutationReceipt?: (receipt: unknown) => void + from: string + paneKey: string + dispatchId: string + taskId: string +}): Promise { + const db = args.runtime.getOrchestrationDb() + const timeoutMs = clampOrchestrationAskTimeoutMs(args.params.timeoutMs) + if ( + !db.verifyRemoteAttachmentAuthority({ + dispatchId: args.dispatchId, + capability: args.orchestrationCapability, + paneKey: args.paneKey, + processIncarnation: args.runtime.getTerminalProcessIncarnation(args.from) + }) + ) { + throw new OrchestrationError( + 'dispatch_capability_invalid', + 'The remote Dispatch capability or exact worker process is invalid.' + ) + } + const options = + args.params.options + ?.split(',') + .map((option) => option.trim()) + .filter(Boolean) ?? [] + let questionId = args.params.resume + if (questionId) { + const existing = db.getRemoteQuestion(questionId) + if (!existing || existing.dispatch_id !== args.dispatchId) { + throw new OrchestrationError( + 'question_not_found', + `Question ${questionId} does not belong to this remote Dispatch.` + ) + } + } else { + const relay = db.enqueueFederationRelay({ + dispatchId: args.dispatchId, + direction: 'to_home', + kind: 'question', + payload: JSON.stringify({ + from: args.from, + subject: 'Question', + body: args.params.question as string, + type: 'question', + priority: 'normal', + threadId: null, + payload: JSON.stringify({ + taskId: args.taskId, + dispatchId: args.dispatchId, + question: args.params.question, + options + }) + }), + remoteQuestion: true + }) + questionId = relay.message_id + } + args.recordMutationReceipt?.({ + accepted: true, + answer: null, + messageId: questionId, + threadId: questionId, + timedOut: false, + cancelled: false, + connectionLost: false, + timeoutMs + }) + const deadline = Date.now() + timeoutMs + while (true) { + const question = db.getRemoteQuestion(questionId) + if (!question || question.status === 'closed') { + throw new OrchestrationError( + 'dispatch_inactive', + `Question ${questionId} closed because its remote Dispatch is inactive.` + ) + } + if (question.status === 'answered') { + return { + answer: question.answer_body, + messageId: questionId, + answerMessageId: question.answer_message_id, + threadId: questionId, + timedOut: false, + cancelled: false, + connectionLost: false, + timeoutMs + } + } + if (args.signal?.aborted) { + return { + answer: null, + messageId: questionId, + threadId: questionId, + timedOut: false, + cancelled: true, + connectionLost: true, + timeoutMs + } + } + const remainingMs = deadline - Date.now() + if (remainingMs <= 0) { + return { + answer: null, + messageId: questionId, + threadId: questionId, + timedOut: true, + cancelled: false, + connectionLost: false, + timeoutMs + } + } + await args.runtime.waitForMessage(`dispatch:${args.dispatchId}`, { + timeoutMs: remainingMs, + signal: args.signal + }) + } +} diff --git a/src/main/runtime/rpc/methods/orchestration-check-direct.ts b/src/main/runtime/rpc/methods/orchestration-check-direct.ts new file mode 100644 index 00000000000..1ac5ddeb3f3 --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration-check-direct.ts @@ -0,0 +1,79 @@ +import type { MessageType, OrchestrationDb } from '../../orchestration/db' +import type { OrcaRuntimeService } from '../../orca-runtime' +import { OrchestrationError } from '../../orchestration/orchestration-error' +import { formatMessageBanner } from '../../orchestration/formatter' +import { reconcileLifecycleMessage } from '../../orchestration/lifecycle-reconciliation' +import { ORCHESTRATION_LEGACY_RUN_ID } from '../../../../shared/orchestration-rpc-contract' +import type { CheckParams } from './orchestration-schemas' +import type { z } from 'zod' + +type CheckParamsInput = z.infer + +export async function checkDirectMailbox(args: { + params: CheckParamsInput + runtime: OrcaRuntimeService + db: OrchestrationDb + handle: string + typeFilter: MessageType[] | undefined + signal: AbortSignal | undefined +}): Promise { + const { params, runtime, db, handle, typeFilter, signal } = args + // Why: unread:false is honored for one release as a compat shim so in-flight callers don't break (design doc §5). + const showAll = params.all === true || (params.unread === false && params.peek !== true) + const consumeUnread = !showAll && params.peek !== true + const readAndReturn = () => { + const messages = showAll + ? db.getAllMessagesForHandle(handle, undefined, typeFilter) + : db.getUnreadMessages(handle, typeFilter) + if ( + consumeUnread && + messages.some((message) => message.run_id === ORCHESTRATION_LEGACY_RUN_ID) + ) { + throw new OrchestrationError( + 'legacy_read_only', + 'Legacy orchestration messages are inspect-only; use --peek or --all. No acknowledgment was applied.', + { effectsApplied: false } + ) + } + let visibleMessages = messages + if (consumeUnread && messages.length > 0) { + // Why: unread check is an authoritative read path for worker_done/heartbeat, so reconcile lifecycle messages here too. + visibleMessages = messages.map((message) => { + const reconciled = reconcileLifecycleMessage(db, message) + return reconciled.action === 'rejected' + ? (db.getMessageById(message.id) ?? message) + : message + }) + db.markAsRead(messages.map((message) => message.id)) + } + if (params.format || params.inject) { + const formatted = visibleMessages.map(formatMessageBanner).join('\n\n') + return { messages: visibleMessages, formatted, count: visibleMessages.length } + } + return { messages: visibleMessages, count: visibleMessages.length } + } + + if (signal?.aborted) { + return { messages: [], count: 0 } + } + const result = readAndReturn() + if (result.count > 0 || !params.wait) { + return result + } + // Why: signal aborts this waiter when the client socket closes, freeing the long-poll slot immediately rather than after timeoutMs (design doc §3.1). + const waitResult = await runtime.waitForMessage(handle, { + typeFilter: typeFilter as string[] | undefined, + timeoutMs: params.timeoutMs ?? undefined, + signal + }) + if (signal?.aborted) { + return { messages: [], count: 0 } + } + if (waitResult === 'cancelled') { + throw new OrchestrationError( + 'consumer_fenced', + 'This direct mailbox became owned by a Run while the check was waiting.' + ) + } + return readAndReturn() +} diff --git a/src/main/runtime/rpc/methods/orchestration-check-methods.ts b/src/main/runtime/rpc/methods/orchestration-check-methods.ts new file mode 100644 index 00000000000..d07be04d129 --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration-check-methods.ts @@ -0,0 +1,79 @@ +import { defineMethod, type RpcMethod } from '../core' +import { OrchestrationError } from '../../orchestration/orchestration-error' +import { CheckParams } from './orchestration-schemas' +import { parseMessageTypes } from './orchestration-routing' +import { checkRunMailbox } from './orchestration-check-run' +import { checkWorkerMailbox } from './orchestration-check-worker' +import { checkDirectMailbox } from './orchestration-check-direct' + +export const ORCHESTRATION_CHECK_METHODS: RpcMethod[] = [ + defineMethod({ + name: 'orchestration.check', + params: CheckParams, + handler: async ( + params, + { + orchestrationCompatibilityEvidence, + runtime, + signal, + legacyCoordinatorRunId, + revalidateLegacyCoordinator, + recordMutationReceipt + } + ) => { + const db = runtime.getOrchestrationDb() + const handle = params.terminal ?? 'unknown' + const typeFilter = parseMessageTypes(params.types) + + // Why: a live runtime handle is authoritative; pane metadata is only the restart fallback. + const paneKey = runtime.getTerminalPaneKey(handle) ?? params.terminalPaneKey + const boundRun = paneKey ? db.getCurrentRunForPane(paneKey) : undefined + if (params.run || boundRun) { + return checkRunMailbox({ + params, + runtime, + db, + handle, + paneKey, + typeFilter, + signal, + legacyCoordinatorRunId, + revalidateLegacyCoordinator, + orchestrationCompatibilityEvidence, + recordMutationReceipt + }) + } + + const activeDispatch = db.getActiveDispatchForIdentity(handle, paneKey) + const remoteAttachment = + !activeDispatch && paneKey ? db.findActiveRemoteAttachmentForPane(paneKey) : undefined + if ( + remoteAttachment && + !db.isRemoteAttachmentProcessCurrent({ + dispatchId: remoteAttachment.dispatch_id, + paneKey: paneKey ?? null, + processIncarnation: runtime.getTerminalProcessIncarnation(handle) + }) + ) { + throw new OrchestrationError( + 'dispatch_inactive', + `Dispatch ${remoteAttachment.dispatch_id} is no longer attached to this worker process.` + ) + } + if (activeDispatch || remoteAttachment) { + return checkWorkerMailbox({ + params, + runtime, + db, + handle, + paneKey, + typeFilter, + signal, + activeDispatch, + remoteAttachment + }) + } + return checkDirectMailbox({ params, runtime, db, handle, typeFilter, signal }) + } + }) +] diff --git a/src/main/runtime/rpc/methods/orchestration-check-run.ts b/src/main/runtime/rpc/methods/orchestration-check-run.ts new file mode 100644 index 00000000000..140b58d1a46 --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration-check-run.ts @@ -0,0 +1,251 @@ +import type { MessageRow, MessageType, OrchestrationDb } from '../../orchestration/db' +import type { OrcaRuntimeService } from '../../orca-runtime' +import type { RpcContext } from '../core' +import { OrchestrationError } from '../../orchestration/orchestration-error' +import { formatMessageBanner } from '../../orchestration/formatter' +import { interruptedAcknowledgedCheck } from './orchestration-routing' +import { routeAllMailboxPages } from './orchestration-schemas' +import { resolveRunScope } from './orchestration-run-scope' +import type { CheckParams } from './orchestration-schemas' +import type { z } from 'zod' + +type CheckParamsInput = z.infer + +export async function checkRunMailbox(args: { + params: CheckParamsInput + runtime: OrcaRuntimeService + db: OrchestrationDb + handle: string + paneKey: string | undefined + typeFilter: MessageType[] | undefined + signal: AbortSignal | undefined + legacyCoordinatorRunId: string | undefined + revalidateLegacyCoordinator: (() => string) | undefined + orchestrationCompatibilityEvidence: RpcContext['orchestrationCompatibilityEvidence'] + recordMutationReceipt: ((receipt: unknown) => void) | undefined +}): Promise { + const { + params, + runtime, + db, + handle, + paneKey, + typeFilter, + signal, + legacyCoordinatorRunId, + revalidateLegacyCoordinator, + orchestrationCompatibilityEvidence, + recordMutationReceipt + } = args + const routeDirectSnapshot = async ( + runId: string, + directHandle: string, + routePage: (throughSequence: number) => { routedCount: number; hasMore: boolean } + ): Promise => { + const throughSequence = db.getLatestUnreadDirectMessageSequenceForRun(runId, directHandle) + if (throughSequence !== undefined) { + await routeAllMailboxPages(() => routePage(throughSequence), signal) + } + } + const run = resolveRunScope(runtime, { + runId: params.run, + callerTerminalHandle: handle, + callerPaneKey: paneKey, + requireCurrentConsumer: true, + legacyCoordinatorRunId, + callerEvidence: orchestrationCompatibilityEvidence + }) + const generation = run.consumer_generation + const address = `run:${run.id}` + runtime.ensureOrchestrationFederationRelay(run.id) + await routeDirectSnapshot(run.id, handle, (throughSequence) => + db.routeUnreadDirectMessagesToRunMailbox(run.id, handle, throughSequence) + ) + const coordinatorHandle = run.coordinator_handle + if (coordinatorHandle && coordinatorHandle !== handle) { + await routeDirectSnapshot(run.id, coordinatorHandle, (throughSequence) => + db.routeUnreadDirectMessagesToRunMailbox(run.id, coordinatorHandle, throughSequence) + ) + } + revalidateLegacyCoordinator?.() + const currentRun = resolveRunScope(runtime, { + runId: run.id, + callerTerminalHandle: handle, + callerPaneKey: paneKey, + requireCurrentConsumer: true, + legacyCoordinatorRunId, + callerEvidence: orchestrationCompatibilityEvidence + }) + if (currentRun.consumer_generation !== generation) { + throw new OrchestrationError( + 'consumer_fenced', + 'This mailbox consumer was replaced while routing pending mail.' + ) + } + + const acknowledged = params.ack + ? db.acknowledgeRunDelivery({ + runId: run.id, + consumerGeneration: generation, + deliveryId: params.ack + }) + : undefined + if (acknowledged) { + recordMutationReceipt?.( + interruptedAcknowledgedCheck(run.id, acknowledged.delivery.id, 'outcome_unknown') + ) + } + if (params.all || (params.unread === false && !params.peek)) { + const messages = db.getRunMailboxHistory(run.id, 100, typeFilter) + const result = { + messages, + count: messages.length, + acknowledged: acknowledged?.delivery.id ?? null + } + if (params.format || params.inject) { + return { + ...result, + formatted: messages.map(formatMessageBanner).join('\n\n'), + runId: run.id + } + } + return { ...result, runId: run.id } + } + + const peekResult = (messages: MessageRow[]) => ({ + runId: run.id, + messages, + count: messages.length, + acknowledged: acknowledged?.delivery.id ?? null, + ...(params.format || params.inject + ? { formatted: messages.map(formatMessageBanner).join('\n\n') } + : {}) + }) + const readPeek = () => db.getUnreadRunMailbox(run.id, 100, typeFilter) + const readDelivery = (wakeTypes?: MessageType[]) => + db.getOrCreateRunDelivery({ runId: run.id, consumerGeneration: generation, wakeTypes }) + let peeked = params.peek ? readPeek() : [] + if (params.peek && peeked.length > 0) { + return peekResult(peeked) + } + let current = params.peek ? undefined : readDelivery(params.wait ? typeFilter : undefined) + if (current) { + return { + runId: run.id, + deliveryId: current.delivery.id, + messages: current.messages, + count: current.messages.length, + replayed: current.replayed, + acknowledged: acknowledged?.delivery.id ?? null, + timedOut: false, + cancelled: false, + connectionLost: false, + ...(params.format || params.inject + ? { formatted: current.messages.map(formatMessageBanner).join('\n\n') } + : {}) + } + } + if (!params.wait) { + if (params.peek) { + return peekResult([]) + } + return { + runId: run.id, + deliveryId: null, + messages: [], + count: 0, + acknowledged: acknowledged?.delivery.id ?? null, + timedOut: false, + cancelled: false, + connectionLost: false + } + } + + const waitResult = await runtime.waitForMessage(address, { + typeFilter: typeFilter as string[] | undefined, + timeoutMs: params.timeoutMs ?? undefined, + signal, + exclusive: true + }) + try { + revalidateLegacyCoordinator?.() + } catch (error) { + if (!acknowledged) { + throw error + } + return interruptedAcknowledgedCheck(run.id, acknowledged.delivery.id, 'consumer_fenced') + } + const latestRun = db.getRun(run.id) + if (!latestRun || latestRun.consumer_generation !== generation) { + if (acknowledged) { + return interruptedAcknowledgedCheck(run.id, acknowledged.delivery.id, 'consumer_fenced') + } + throw new OrchestrationError( + 'consumer_fenced', + 'This mailbox consumer was replaced while waiting.' + ) + } + if (waitResult === 'waiter_exists') { + if (acknowledged) { + return interruptedAcknowledgedCheck(run.id, acknowledged.delivery.id, 'waiter_exists') + } + throw new OrchestrationError( + 'waiter_exists', + `Run ${run.id} already has an active actionable waiter.` + ) + } + if (waitResult === 'timed_out') { + if (params.peek) { + return { ...peekResult([]), timedOut: true, cancelled: false, connectionLost: false } + } + return { + runId: run.id, + deliveryId: null, + messages: [], + count: 0, + acknowledged: acknowledged?.delivery.id ?? null, + timedOut: true, + cancelled: false, + connectionLost: false + } + } + if (waitResult === 'cancelled') { + if (params.peek) { + return { + ...peekResult([]), + timedOut: false, + cancelled: true, + connectionLost: signal?.aborted === true + } + } + return { + runId: run.id, + deliveryId: null, + messages: [], + count: 0, + acknowledged: acknowledged?.delivery.id ?? null, + timedOut: false, + cancelled: true, + connectionLost: signal?.aborted === true + } + } + if (params.peek) { + peeked = readPeek() + return { ...peekResult(peeked), timedOut: false, cancelled: false, connectionLost: false } + } + current = readDelivery(typeFilter) + return { + runId: run.id, + deliveryId: current?.delivery.id ?? null, + messages: current?.messages ?? [], + count: current?.messages.length ?? 0, + replayed: current?.replayed ?? false, + acknowledged: acknowledged?.delivery.id ?? null, + timedOut: false, + cancelled: false, + connectionLost: false, + ...(params.format && current + ? { formatted: current.messages.map(formatMessageBanner).join('\n\n') } + : {}) + } +} diff --git a/src/main/runtime/rpc/methods/orchestration-check-worker.ts b/src/main/runtime/rpc/methods/orchestration-check-worker.ts new file mode 100644 index 00000000000..3d5e68dec75 --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration-check-worker.ts @@ -0,0 +1,192 @@ +import type { MessageType, OrchestrationDb } from '../../orchestration/db' +import type { OrcaRuntimeService } from '../../orca-runtime' +import { OrchestrationError } from '../../orchestration/orchestration-error' +import { formatMessageBanner } from '../../orchestration/formatter' +import { routeAllMailboxPages } from './orchestration-schemas' +import type { CheckParams } from './orchestration-schemas' +import type { z } from 'zod' + +type CheckParamsInput = z.infer +type ActiveDispatch = NonNullable> +type RemoteAttachment = NonNullable< + ReturnType +> + +export async function checkWorkerMailbox(args: { + params: CheckParamsInput + runtime: OrcaRuntimeService + db: OrchestrationDb + handle: string + paneKey: string | undefined + typeFilter: MessageType[] | undefined + signal: AbortSignal | undefined + activeDispatch: ActiveDispatch | undefined + remoteAttachment: RemoteAttachment | undefined +}): Promise { + const { + params, + runtime, + db, + handle, + paneKey, + typeFilter, + signal, + activeDispatch, + remoteAttachment + } = args + const workerMailbox = activeDispatch + ? { dispatchId: activeDispatch.id, runId: activeDispatch.run_id } + : remoteAttachment + ? { dispatchId: remoteAttachment.dispatch_id, runId: undefined } + : undefined + if (!workerMailbox) { + return undefined + } + const address = `dispatch:${workerMailbox.dispatchId}` + const routeDirectSnapshot = async ( + runId: string, + directHandle: string, + routePage: (throughSequence: number) => { routedCount: number; hasMore: boolean } + ): Promise => { + const throughSequence = db.getLatestUnreadDirectMessageSequenceForRun(runId, directHandle) + if (throughSequence !== undefined) { + await routeAllMailboxPages(() => routePage(throughSequence), signal) + } + } + const revalidateWorkerMailbox = async (): Promise => { + if (activeDispatch) { + const current = db.getActiveDispatchForIdentity(handle, paneKey) + if (current?.id === activeDispatch.id) { + return + } + } else if (remoteAttachment && paneKey) { + const current = db.findActiveRemoteAttachmentForPane(paneKey) + if ( + current?.dispatch_id === remoteAttachment.dispatch_id && + db.isRemoteAttachmentProcessCurrent({ + dispatchId: current.dispatch_id, + paneKey, + processIncarnation: runtime.getTerminalProcessIncarnation(handle) + }) + ) { + return + } + } + const latestDispatch = db.getDispatchContextById(workerMailbox.dispatchId) + const owningRunId = latestDispatch?.run_id ?? activeDispatch?.run_id ?? workerMailbox.runId + if ( + owningRunId && + (!latestDispatch || + (latestDispatch.status !== 'pending' && latestDispatch.status !== 'dispatched')) + ) { + const throughSequence = db.getLatestUnreadMessageSequence(address) + if (throughSequence !== undefined) { + const routedTypes = new Set() + const routePage = (): { routedCount: number; hasMore: boolean } => { + const routed = db.routeUnreadDispatchMailboxToRunMailbox( + workerMailbox.dispatchId, + owningRunId, + throughSequence + ) + for (const routedType of routed.types) { + routedTypes.add(routedType) + } + return routed + } + const notifyRoutedTypes = (): void => { + for (const routedType of routedTypes) { + runtime.notifyMessageArrived(`run:${owningRunId}`, routedType) + } + routedTypes.clear() + } + try { + await routeAllMailboxPages(routePage, signal) + } catch (error) { + notifyRoutedTypes() + if (error instanceof OrchestrationError && error.code === 'request_aborted') { + setImmediate(() => { + void routeAllMailboxPages(routePage) + .catch(() => undefined) + .finally(notifyRoutedTypes) + }) + } + throw error + } + notifyRoutedTypes() + } + } + throw new OrchestrationError( + 'dispatch_inactive', + `Dispatch ${workerMailbox.dispatchId} is no longer assigned to this worker.` + ) + } + + if (activeDispatch) { + await routeDirectSnapshot(activeDispatch.run_id, handle, (throughSequence) => + db.routeUnreadDirectMessagesToDispatchMailbox( + activeDispatch.id, + activeDispatch.run_id, + handle, + throughSequence + ) + ) + const assigneeHandle = activeDispatch.assignee_handle + if (assigneeHandle && assigneeHandle !== handle) { + await routeDirectSnapshot(activeDispatch.run_id, assigneeHandle, (throughSequence) => + db.routeUnreadDirectMessagesToDispatchMailbox( + activeDispatch.id, + activeDispatch.run_id, + assigneeHandle, + throughSequence + ) + ) + } + } + await revalidateWorkerMailbox() + const showAll = params.all === true || (params.unread === false && params.peek !== true) + const messages = showAll + ? db.getAllMessagesForHandle(address, 100, typeFilter) + : db.getUnreadMessages(address, typeFilter) + if (!showAll && params.peek !== true && messages.length > 0) { + db.markAsRead(messages.map((message) => message.id)) + } + if (messages.length > 0 || !params.wait) { + return { + ...(workerMailbox.runId ? { runId: workerMailbox.runId } : {}), + dispatchId: workerMailbox.dispatchId, + messages, + count: messages.length, + ...(params.format || params.inject + ? { formatted: messages.map(formatMessageBanner).join('\n\n') } + : {}) + } + } + const waitResult = await runtime.waitForMessage(address, { + typeFilter: typeFilter as string[] | undefined, + timeoutMs: params.timeoutMs ?? undefined, + signal + }) + await revalidateWorkerMailbox() + if (waitResult === 'timed_out' || waitResult === 'cancelled') { + return { + ...(workerMailbox.runId ? { runId: workerMailbox.runId } : {}), + dispatchId: workerMailbox.dispatchId, + messages: [], + count: 0, + timedOut: waitResult === 'timed_out', + cancelled: waitResult === 'cancelled', + connectionLost: waitResult === 'cancelled' && signal?.aborted === true + } + } + const arrived = db.getUnreadMessages(address, typeFilter) + db.markAsRead(arrived.map((message) => message.id)) + return { + ...(workerMailbox.runId ? { runId: workerMailbox.runId } : {}), + dispatchId: workerMailbox.dispatchId, + messages: arrived, + count: arrived.length, + ...(params.format || params.inject + ? { formatted: arrived.map(formatMessageBanner).join('\n\n') } + : {}) + } +} diff --git a/src/main/runtime/rpc/methods/orchestration-dispatch-methods.ts b/src/main/runtime/rpc/methods/orchestration-dispatch-methods.ts new file mode 100644 index 00000000000..ae21a4e5a46 --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration-dispatch-methods.ts @@ -0,0 +1,178 @@ +import { defineMethod, type RpcMethod } from '../core' +import { OrchestrationError } from '../../orchestration/orchestration-error' +import { buildDispatchPreamble } from '../../orchestration/preamble' +import { resolveDispatchCreator } from './orchestration-dispatch-creator' +import { buildInjectRejectionMessage } from './orchestration-inject-rejection-message' +import { resolveRunScope } from './orchestration-run-scope' +import { DispatchParams, DispatchShowParams } from './orchestration-schemas' + +export const ORCHESTRATION_DISPATCH_METHODS: RpcMethod[] = [ + defineMethod({ + name: 'orchestration.dispatch', + params: DispatchParams, + handler: async ( + params, + { + orchestrationCompatibilityEvidence, + runtime, + legacyCoordinatorRunId, + revalidateLegacyCoordinator + } + ) => { + const db = runtime.getOrchestrationDb() + const task = db.getTask(params.task) + if (!task) { + throw new Error(`Task not found: ${params.task}`) + } + const run = resolveRunScope(runtime, { + runId: params.run, + callerTerminalHandle: params.from, + requireCurrentConsumer: true, + legacyCoordinatorRunId, + callerEvidence: orchestrationCompatibilityEvidence + }) + if (task.run_id !== run.id) { + throw new OrchestrationError( + 'task_not_found', + `Task ${task.id} was not found in Run ${run.id}.` + ) + } + + // Why: dry-run previews the preamble without mutating state, so it skips the ready-status check and uses a placeholder dispatchId. + if (params.dryRun) { + const maxDepth = runtime.getNestedWorkerMaxDepth() + const previewDepth = db.resolveChildDispatchDepth( + resolveDispatchCreator(runtime, params.from), + maxDepth + ) + const preamble = buildDispatchPreamble({ + taskId: task.id, + dispatchId: 'ctx_dryrun', + canDispatchSubWorkers: previewDepth < maxDepth, + taskSpec: task.spec, + coordinatorHandle: params.from ?? 'coordinator', + workerHandle: params.to ?? 'worker', + devMode: params.devMode, + ...(params.to + ? { cliCommand: runtime.getTerminalOrchestrationCliCommand(params.to) } + : {}) + }) + return { dispatch: null, injected: false, dryRun: true, preamble } + } + + if (!params.to) { + throw new Error('Missing --to') + } + const to = params.to + + if (task.status !== 'ready') { + throw new Error(`Task ${params.task} is ${task.status}; only ready tasks can be dispatched`) + } + + // Why: injecting the preamble into a bare shell dumps it as shell commands (gibberish), so require a detected agent first. + if (params.inject) { + const hasAgent = await runtime.isTerminalRunningAgent(to) + if (!hasAgent) { + throw new Error(buildInjectRejectionMessage(to)) + } + } + + const dispatchAuthority = runtime.getOrchestrationDispatchAuthority(to) + const assigneePaneKey = + dispatchAuthority?.paneKey ?? runtime.getTerminalPaneKey(to) ?? undefined + const processIncarnation = + dispatchAuthority?.paneKey && dispatchAuthority.processIncarnation + ? dispatchAuthority.processIncarnation + : undefined + if (params.inject && (!assigneePaneKey || !processIncarnation)) { + throw new OrchestrationError( + 'stable_pane_required', + `Terminal ${to} has no stable pane/process incarnation for lifecycle authority.` + ) + } + + revalidateLegacyCoordinator?.() + const ctx = db.createDispatchContext({ + taskId: params.task, + assigneeHandle: to, + assigneePaneKey, + launchTokenHash: dispatchAuthority?.launchTokenHash ?? undefined, + processIncarnation, + creator: resolveDispatchCreator(runtime, params.from), + maxDepth: runtime.getNestedWorkerMaxDepth() + }) + const dispatchCapability = params.inject + ? db.mintDispatchCapability({ + dispatchId: ctx.id, + paneKey: assigneePaneKey as string, + processIncarnation: processIncarnation as string + }) + : undefined + + // Why: built after ctx so dispatchId is the real ctx.id, letting heartbeats attribute liveness to a specific dispatch context, not just a task. + const preamble = buildDispatchPreamble({ + taskId: task.id, + dispatchId: ctx.id, + canDispatchSubWorkers: ctx.depth < runtime.getNestedWorkerMaxDepth(), + taskSpec: task.spec, + coordinatorHandle: params.from ?? 'coordinator', + workerHandle: to, + dispatchCapability, + devMode: params.devMode, + cliCommand: runtime.getTerminalOrchestrationCliCommand(to) + }) + + let injected = false + if (params.inject) { + try { + await runtime.sendTerminalAgentPrompt(to, preamble) + injected = true + } catch (err) { + db.failDispatch(ctx.id, err instanceof Error ? err.message : String(err)) + throw err + } + } + + // Why: returnPreamble is opt-in because the preamble is several hundred bytes most callers don't need in the response. + if (params.returnPreamble) { + return { dispatch: ctx, injected, preamble } + } + return { dispatch: ctx, injected } + } + }), + + defineMethod({ + name: 'orchestration.dispatchShow', + params: DispatchShowParams, + handler: (params, { runtime }) => { + const db = runtime.getOrchestrationDb() + if (!params.task) { + throw new Error('Missing --task') + } + const ctx = db.getDispatchContext(params.task) + + // Why: the preamble is derived from the current task spec, so it can be regenerated deterministically even after dispatch completes. + if (params.preamble) { + const task = db.getTask(params.task) + if (!task) { + throw new Error(`Task not found: ${params.task}`) + } + const workerHandle = ctx?.assignee_handle ?? 'worker' + const preamble = buildDispatchPreamble({ + taskId: task.id, + // Why: use the real ctx.id when present so the preview matches what was injected; placeholder when no dispatch has occurred yet. + dispatchId: ctx?.id ?? 'ctx_preview', + canDispatchSubWorkers: (ctx?.depth ?? 1) < runtime.getNestedWorkerMaxDepth(), + taskSpec: task.spec, + coordinatorHandle: params.from ?? 'coordinator', + workerHandle, + devMode: params.devMode, + ...(ctx ? { cliCommand: runtime.getTerminalOrchestrationCliCommand(workerHandle) } : {}) + }) + return { dispatch: ctx ?? null, preamble } + } + + return { dispatch: ctx ?? null } + } + }) +] diff --git a/src/main/runtime/rpc/methods/orchestration-message-methods.ts b/src/main/runtime/rpc/methods/orchestration-message-methods.ts new file mode 100644 index 00000000000..faf89669247 --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration-message-methods.ts @@ -0,0 +1,219 @@ +import { defineMethod, type RpcMethod } from '../core' +import type { TaskStatus } from '../../orchestration/db' +import { OrchestrationError } from '../../orchestration/orchestration-error' +import { ORCHESTRATION_LEGACY_RUN_ID } from '../../../../shared/orchestration-rpc-contract' +import { abbreviateOrchestrationTasks } from '../../../../shared/orchestration-task-summary' +import { parseOrchestrationTaskDepsFlag } from '../../orchestration/task-deps-flag' +import { resolveRunScope } from './orchestration-run-scope' +import { + ReplyParams, + InboxParams, + TaskCreateParams, + TaskListParams, + TaskUpdateParams +} from './orchestration-schemas' + +export const ORCHESTRATION_MESSAGE_METHODS: RpcMethod[] = [ + defineMethod({ + name: 'orchestration.reply', + params: ReplyParams, + handler: async ( + params, + { orchestrationCompatibilityEvidence, runtime, legacyCoordinatorRunId } + ) => { + const db = runtime.getOrchestrationDb() + const original = db.getMessageById(params.id) + if (!original) { + throw new Error(`Message not found: ${params.id}`) + } + if ( + legacyCoordinatorRunId && + (original.run_id !== legacyCoordinatorRunId || + (params.run !== undefined && params.run !== legacyCoordinatorRunId)) + ) { + throw new OrchestrationError( + 'request_mismatch', + `Message ${params.id} does not belong to this adopted Run.`, + { effectsApplied: false } + ) + } + if ( + original.run_id === ORCHESTRATION_LEGACY_RUN_ID || + original.delivery_contract === 'legacy_direct' || + original.delivery_contract === 'audit_only' + ) { + throw new OrchestrationError( + 'legacy_read_only', + 'Legacy orchestration messages are inspect-only; no reply was applied.', + { effectsApplied: false } + ) + } + + const question = db.getQuestion(params.id) + if (question) { + const run = resolveRunScope(runtime, { + runId: params.run ?? question.run_id, + callerTerminalHandle: params.from, + requireCurrentConsumer: true, + legacyCoordinatorRunId, + callerEvidence: orchestrationCompatibilityEvidence + }) + const answered = db.answerQuestion({ + messageId: question.message_id, + runId: run.id, + consumerGeneration: run.consumer_generation, + body: params.body + }) + const federated = db.getFederatedDispatch(question.dispatch_id) + if (federated) { + db.enqueueFederationRelay({ + dispatchId: question.dispatch_id, + direction: 'to_worker', + kind: 'reply', + payload: JSON.stringify({ + questionId: question.message_id, + answerMessageId: answered.message.id, + body: params.body + }) + }) + runtime.ensureOrchestrationFederationRelay(run.id) + } else { + runtime.notifyMessageArrived(`dispatch:${question.dispatch_id}`, 'status') + } + return { + message: answered.message, + question: answered.question, + duplicate: answered.duplicate + } + } + + db.markAsRead([original.id]) + + const reply = db.insertMessage({ + from: params.from ?? original.to_handle, + to: original.from_handle, + subject: `Re: ${original.subject}`, + body: params.body, + threadId: original.thread_id ?? original.id, + runId: original.run_id + }) + + runtime.notifyMessageArrived(reply.to_handle, reply.type) + return { message: reply } + } + }), + + defineMethod({ + name: 'orchestration.inbox', + params: InboxParams, + handler: (params, { runtime }) => { + const db = runtime.getOrchestrationDb() + // Why: stale/unknown handles return empty rather than error — historical rows survive handle deletion (design doc §3.3). + const messages = params.terminal + ? db.getAllMessagesForHandle(params.terminal, params.limit) + : db.getInbox(params.limit) + return { messages, count: messages.length } + } + }), + + defineMethod({ + name: 'orchestration.taskCreate', + params: TaskCreateParams, + handler: (params, { orchestrationCompatibilityEvidence, runtime, legacyCoordinatorRunId }) => { + const db = runtime.getOrchestrationDb() + const deps = params.deps ? parseOrchestrationTaskDepsFlag(params.deps) : undefined + const run = resolveRunScope(runtime, { + runId: params.run, + callerTerminalHandle: params.callerTerminalHandle, + requireCurrentConsumer: true, + legacyCoordinatorRunId, + callerEvidence: orchestrationCompatibilityEvidence + }) + const creatorAuthority = params.callerTerminalHandle + ? runtime.getOrchestrationDispatchAuthority(params.callerTerminalHandle) + : null + const task = db.createTask({ + spec: params.spec, + taskTitle: params.taskTitle, + displayName: params.displayName, + deps, + parentId: params.parent, + createdByTerminalHandle: params.callerTerminalHandle, + ...(creatorAuthority?.paneKey && creatorAuthority.processIncarnation + ? { + createdByPaneKey: creatorAuthority.paneKey, + createdByProcessIncarnation: creatorAuthority.processIncarnation, + createdByRunGeneration: run.consumer_generation + } + : {}), + runId: run.id + }) + return { task } + } + }), + + defineMethod({ + name: 'orchestration.taskList', + params: TaskListParams, + handler: (params, { orchestrationCompatibilityEvidence, runtime, legacyCoordinatorRunId }) => { + const db = runtime.getOrchestrationDb() + const explicitRun = params.run ? db.getRun(params.run) : undefined + const run = + explicitRun?.legacy === 1 + ? explicitRun + : resolveRunScope(runtime, { + runId: params.run, + callerTerminalHandle: params.callerTerminalHandle, + requireCurrentConsumer: params.run === undefined, + legacyCoordinatorRunId, + callerEvidence: orchestrationCompatibilityEvidence + }) + // Why: listTasksWithDispatch adds assignee_handle + dispatch_id (NULL for non-dispatched), so legacy-shape consumers are unaffected. + const joined = db.listTasksWithDispatch({ + status: params.status as TaskStatus, + ready: params.ready, + runId: run.id + }) + const tasks = joined.map((row) => { + const { assignee_handle, dispatch_id, ...base } = row + if (base.status === 'dispatched') { + return { ...base, assignee_handle, dispatch_id } + } + return base + }) + return { + runId: run.id, + legacyReadOnly: run.legacy === 1, + tasks: params.brief ? abbreviateOrchestrationTasks(tasks) : tasks, + count: tasks.length + } + } + }), + + defineMethod({ + name: 'orchestration.taskUpdate', + params: TaskUpdateParams, + handler: (params, { orchestrationCompatibilityEvidence, runtime, legacyCoordinatorRunId }) => { + const db = runtime.getOrchestrationDb() + const run = resolveRunScope(runtime, { + runId: params.run, + callerTerminalHandle: params.callerTerminalHandle, + requireCurrentConsumer: true, + legacyCoordinatorRunId, + callerEvidence: orchestrationCompatibilityEvidence + }) + const existing = db.getTask(params.id) + if (!existing || existing.run_id !== run.id) { + throw new OrchestrationError( + 'task_not_found', + `Task ${params.id} was not found in Run ${run.id}.` + ) + } + const task = db.updateTaskStatus(params.id, params.status, params.result) + if (!task) { + throw new Error(`Task not found: ${params.id}`) + } + return { task } + } + }) +] diff --git a/src/main/runtime/rpc/methods/orchestration-reset-methods.ts b/src/main/runtime/rpc/methods/orchestration-reset-methods.ts new file mode 100644 index 00000000000..d98fc4719b9 --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration-reset-methods.ts @@ -0,0 +1,27 @@ +import { defineMethod, type RpcMethod } from '../core' +import { ResetParams } from './orchestration-schemas' + +export const ORCHESTRATION_RESET_METHODS: RpcMethod[] = [ + defineMethod({ + name: 'orchestration.reset', + params: ResetParams, + handler: (params, { runtime }) => { + const db = runtime.getOrchestrationDb() + if (params.all) { + runtime.stopOrchestrationFederationRelay() + db.resetAll() + return { reset: 'all' } + } + if (params.tasks) { + runtime.stopOrchestrationFederationRelay() + db.resetTasks() + return { reset: 'tasks' } + } + if (params.messages) { + db.resetMessages() + return { reset: 'messages' } + } + throw new Error('Invalid reset scope') + } + }) +] diff --git a/src/main/runtime/rpc/methods/orchestration-routing.ts b/src/main/runtime/rpc/methods/orchestration-routing.ts new file mode 100644 index 00000000000..0722f19b44e --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration-routing.ts @@ -0,0 +1,135 @@ +import type { MessageType } from '../../orchestration/db' +import type { RunRow } from '../../orchestration/types' +import type { OrcaRuntimeService } from '../../orca-runtime' +import { MESSAGE_TYPES } from '../../orchestration/types' +import { OrchestrationError } from '../../orchestration/orchestration-error' +import { LEGACY_CONTRACT_VERSION } from '../../orchestration/db' + +export function parseMessageTypes(rawTypes: string | undefined): MessageType[] | undefined { + const types = rawTypes + ?.split(',') + .map((type) => type.trim()) + .filter(Boolean) as MessageType[] | undefined + const invalidTypes = types?.filter((type) => !MESSAGE_TYPES.includes(type)) + if (invalidTypes && invalidTypes.length > 0) { + throw new OrchestrationError('invalid_argument', `Invalid --types: ${invalidTypes.join(',')}`) + } + return types && types.length > 0 ? types : undefined +} + +export function resolveMessageRun( + runtime: OrcaRuntimeService, + params: { + from?: string + senderPaneKey?: string + to?: string + runId?: string + payload?: string + } +): { run: RunRow | undefined; dispatchId: string | undefined } { + const db = runtime.getOrchestrationDb() + let dispatchId: string | undefined + if (params.payload) { + try { + const payload: unknown = JSON.parse(params.payload) + if ( + payload && + typeof payload === 'object' && + !Array.isArray(payload) && + typeof (payload as { dispatchId?: unknown }).dispatchId === 'string' + ) { + dispatchId = (payload as { dispatchId: string }).dispatchId + } + } catch { + // Lifecycle validation owns malformed payload errors; routing simply cannot derive a Dispatch. + } + } + if (!dispatchId && params.to?.startsWith('dispatch:')) { + dispatchId = params.to.slice('dispatch:'.length) + } + + const dispatch = dispatchId + ? db.getDispatchContextById(dispatchId) + : params.from + ? db.getActiveDispatchForIdentity(params.from, params.senderPaneKey) + : undefined + if (params.to?.startsWith('dispatch:') && !dispatch) { + throw new OrchestrationError( + 'dispatch_not_found', + `Dispatch ${dispatchId ?? ''} was not found.` + ) + } + const targetRunId = params.to?.startsWith('run:') ? params.to.slice('run:'.length) : undefined + const resolvedRunId = params.runId ?? targetRunId ?? dispatch?.run_id + let run = resolvedRunId ? db.getRun(resolvedRunId) : undefined + + if (!run && params.from) { + const paneKey = params.senderPaneKey ?? runtime.getTerminalPaneKey(params.from) + run = paneKey ? db.getCurrentRunForPane(paneKey) : undefined + } + if (resolvedRunId && (!run || run.legacy === 1)) { + throw new OrchestrationError('run_not_found', `Run ${resolvedRunId} was not found.`) + } + if (run && targetRunId && targetRunId !== run.id) { + throw new OrchestrationError('run_not_found', `Run ${targetRunId} was not found.`) + } + if (run && dispatch && dispatch.run_id !== run.id) { + throw new OrchestrationError( + 'dispatch_run_mismatch', + `Dispatch ${dispatch.id} belongs to Run ${dispatch.run_id}, not ${run.id}.` + ) + } + return { run, dispatchId: dispatch?.id ?? dispatchId } +} + +export function legacyWorkerDeliveryContract( + runtime: OrcaRuntimeService, + runId: string | undefined, + recipient: string +): 'legacy_direct' | undefined { + if (!runId) { + return undefined + } + if (!recipient.startsWith('dispatch:')) { + return runtime + .getOrchestrationDb() + .resolveLegacyWorkerCandidate({ runId, terminalHandle: recipient }) + ? 'legacy_direct' + : undefined + } + const dispatch = runtime + .getOrchestrationDb() + .getDispatchContextById(recipient.slice('dispatch:'.length)) + return dispatch?.run_id === runId && + dispatch.contract_version === LEGACY_CONTRACT_VERSION && + (dispatch.status === 'pending' || dispatch.status === 'dispatched') + ? 'legacy_direct' + : undefined +} + +export function interruptedAcknowledgedCheck( + runId: string, + acknowledged: string, + reason: 'consumer_fenced' | 'outcome_unknown' | 'waiter_exists' +): Record { + return { + runId, + deliveryId: null, + messages: [], + count: 0, + acknowledged, + timedOut: false, + cancelled: false, + connectionLost: false, + waitInterrupted: reason + } +} + +export function rejectFederatedExplicitTarget(params: { to?: string; run?: string }): void { + if (params.to || params.run) { + throw new OrchestrationError( + 'invalid_argument', + 'Federated Dispatch messages route to their Run home; omit --to and --run.' + ) + } +} diff --git a/src/main/runtime/rpc/methods/orchestration-schemas.ts b/src/main/runtime/rpc/methods/orchestration-schemas.ts new file mode 100644 index 00000000000..d1023827fee --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration-schemas.ts @@ -0,0 +1,272 @@ +import { z } from 'zod' +import { setImmediate as yieldToEventLoop } from 'node:timers/promises' +import { OptionalFiniteNumber, OptionalString, OptionalBoolean, requiredString } from '../schemas' +import type { TaskStatus } from '../../orchestration/db' +import { isGroupAddress } from '../../orchestration/groups' +import { MESSAGE_TYPES } from '../../orchestration/types' +import { OrchestrationError } from '../../orchestration/orchestration-error' + +export const TASK_STATUSES: TaskStatus[] = [ + 'pending', + 'ready', + 'dispatched', + 'completed', + 'failed', + 'blocked' +] + +export async function routeAllMailboxPages( + routePage: () => { routedCount: number; hasMore: boolean }, + signal?: AbortSignal +): Promise { + while (true) { + if (signal?.aborted) { + throw new OrchestrationError('request_aborted', 'Mailbox routing was cancelled.') + } + const page = routePage() + if (!page.hasMore) { + return + } + await yieldToEventLoop() + if (signal?.aborted) { + throw new OrchestrationError('request_aborted', 'Mailbox routing was cancelled.') + } + } +} + +const SEND_MESSAGE_TYPE_ERROR = [ + `Invalid --type. Expected one of: ${MESSAGE_TYPES.join(', ')}.`, + 'To answer a worker question, use the same Orca CLI executable with orchestration reply --id --body .' +].join(' ') + +export type DispatchMutationMessageType = + | 'worker_done' + | 'heartbeat' + | 'escalation' + | 'decision_gate' + +export function isDispatchMutationMessageType( + type: string | undefined +): type is DispatchMutationMessageType { + return ( + type === 'worker_done' || + type === 'heartbeat' || + type === 'escalation' || + type === 'decision_gate' + ) +} + +export function getLifecycleGroupRecipientError(type: DispatchMutationMessageType): string { + return `${type} messages belong to one exact Dispatch and cannot target a group address.` +} + +export function parseRemoteWorkerPayload(payload: string | undefined): Record { + if (!payload) { + return {} + } + try { + const parsed: unknown = JSON.parse(payload) + return parsed && typeof parsed === 'object' && !Array.isArray(parsed) + ? (parsed as Record) + : {} + } catch { + throw new OrchestrationError('invalid_argument', 'Message payload must be valid JSON.') + } +} + +export function parseMessageTaskId(payload: string | undefined): string | undefined { + if (!payload) { + return undefined + } + try { + const parsed: unknown = JSON.parse(payload) + return parsed && typeof parsed === 'object' && !Array.isArray(parsed) + ? typeof (parsed as { taskId?: unknown }).taskId === 'string' + ? (parsed as { taskId: string }).taskId + : undefined + : undefined + } catch { + return undefined + } +} + +export function isWorkerReportOutcome(value: unknown): value is 'succeeded' | 'failed' { + return value === 'succeeded' || value === 'failed' +} + +export const SendParams = z + .object({ + to: OptionalString, + subject: requiredString('Missing --subject'), + from: OptionalString, + body: OptionalString, + type: z + .enum(MESSAGE_TYPES, { + error: SEND_MESSAGE_TYPE_ERROR + }) + .optional(), + priority: z.enum(['normal', 'high', 'urgent']).optional(), + threadId: OptionalString, + payload: OptionalString, + // Why: pane key is the remint-stable identity used to verify worker_done/heartbeat ownership; the from handle stays routing metadata. + senderPaneKey: OptionalString, + run: OptionalString, + waitForLifecycleSettlement: OptionalBoolean, + devMode: OptionalBoolean + }) + .superRefine((params, ctx) => { + if (!isDispatchMutationMessageType(params.type) || !params.to || !isGroupAddress(params.to)) { + return + } + // Why: dispatch lifecycle messages are authority/liveness signals for one coordinator; fanout would create lifecycle mail in unrelated terminals. + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: getLifecycleGroupRecipientError(params.type), + path: ['to'] + }) + }) + +export const CheckParams = z + .object({ + terminal: OptionalString, + terminalPaneKey: OptionalString, + unread: OptionalBoolean, + peek: OptionalBoolean, + // Why: `all` surfaces every message and skips mark-read; legacy encoding was the `{unread: false}` trick (design doc §3.2/§3.3). + all: OptionalBoolean, + types: OptionalString, + format: OptionalBoolean, + // Why: one-release RPC compatibility only; the public CLI uses --format because no terminal input is injected. + inject: OptionalBoolean, + ack: OptionalString, + compatibilityAck: OptionalString, + compatibilityQuestionAck: OptionalString, + compatibilityCliCommand: z.enum(['orca', 'orca-ide', 'orca-dev']).optional(), + run: OptionalString, + wait: OptionalBoolean, + timeoutMs: OptionalFiniteNumber + }) + .superRefine((params, ctx) => { + // Why: CLI encodes --peek as {peek:true, unread:false} for pre-peek runtimes, so that pair is one mode, not a conflict. + const modes = [ + params.unread === true, + params.peek === true, + params.all === true || (params.unread === false && params.peek !== true) + ].filter(Boolean) + if (modes.length > 1) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Choose at most one message read mode: --unread, --peek, or --all.' + }) + } + }) + +export const ReplyParams = z.object({ + id: requiredString('Missing --id'), + body: requiredString('Missing --body'), + from: OptionalString, + run: OptionalString +}) + +export const InboxParams = z.object({ + limit: OptionalFiniteNumber, + // Why: filters the inbox to a handle so inbox and check --all give agreeing results (design doc §3.3). + terminal: OptionalString +}) + +export const TaskCreateParams = z.object({ + spec: requiredString('Missing --spec'), + taskTitle: OptionalString, + displayName: OptionalString, + deps: OptionalString, + parent: OptionalString, + callerTerminalHandle: OptionalString, + run: OptionalString +}) + +export const TaskListParams = z.object({ + status: z.enum(['pending', 'ready', 'dispatched', 'completed', 'failed', 'blocked']).optional(), + ready: OptionalBoolean, + // Why: server-side truncation keeps --brief cheap over SSH/relay instead of shipping full specs the CLI throws away. + brief: OptionalBoolean, + run: OptionalString, + callerTerminalHandle: OptionalString +}) + +export const TaskUpdateParams = z.object({ + id: requiredString('Missing --id'), + status: z + .unknown() + .transform((v) => { + if (typeof v === 'string' && TASK_STATUSES.includes(v as TaskStatus)) { + return v as TaskStatus + } + return '' + }) + .pipe( + z.enum(['pending', 'ready', 'dispatched', 'completed', 'failed', 'blocked'], { + message: 'Missing --status' + }) + ), + result: OptionalString, + run: OptionalString, + callerTerminalHandle: OptionalString +}) + +export const DispatchParams = z.object({ + task: requiredString('Missing --task'), + // Why: --to is optional so --dry-run can preview without a target; the handler enforces presence before any side-effecting work. + to: OptionalString, + from: OptionalString, + inject: OptionalBoolean, + dryRun: OptionalBoolean, + returnPreamble: OptionalBoolean, + devMode: OptionalBoolean, + run: OptionalString +}) + +export const DispatchShowParams = z.object({ + task: OptionalString, + preamble: OptionalBoolean, + from: OptionalString, + devMode: OptionalBoolean +}) + +export const AskParams = z + .object({ + to: OptionalString, + question: OptionalString, + resume: OptionalString, + options: OptionalString, + timeoutMs: OptionalFiniteNumber, + from: OptionalString, + run: OptionalString, + compatibilityCliCommand: z.enum(['orca', 'orca-ide', 'orca-dev']).optional(), + compatibilityWindowsCommand: z.enum(['orca', 'orca-ide']).optional() + }) + .superRefine((params, ctx) => { + if ((params.question ? 1 : 0) + (params.resume ? 1 : 0) !== 1) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Choose exactly one of --question or --resume.' + }) + } + }) + +export const ResetParams = z + .object({ + all: OptionalBoolean, + tasks: OptionalBoolean, + messages: OptionalBoolean + }) + .superRefine((params, ctx) => { + const selectedScopeCount = [params.all, params.tasks, params.messages].filter( + (scope) => scope === true + ).length + if (selectedScopeCount !== 1) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Choose exactly one reset scope: --all, --tasks, or --messages.' + }) + } + }) diff --git a/src/main/runtime/rpc/methods/orchestration-send-control-mail.ts b/src/main/runtime/rpc/methods/orchestration-send-control-mail.ts new file mode 100644 index 00000000000..40f8abf08ee --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration-send-control-mail.ts @@ -0,0 +1,83 @@ +import type { MessagePriority, MessageType, OrchestrationDb } from '../../orchestration/db' +import type { OrcaRuntimeService } from '../../orca-runtime' +import { OrchestrationError } from '../../orchestration/orchestration-error' +import { encodeFederatedControlMessage } from '../../orchestration/federation-control-message' +import { ORCHESTRATION_FEDERATION_CONTROL_MAIL_PROTOCOL_VERSION } from '../../../../shared/protocol-version' +import type { SendParams } from './orchestration-schemas' +import type { SendRecipientWarning } from './orchestration-recipient-routing' +import type { z } from 'zod' + +type SendParamsInput = z.infer +type SendReceipt = (receipt: T) => T & { warnings?: SendRecipientWarning[] } + +/** Delivers coordinator control mail to a federated worker when `to` names its exact Dispatch. */ +export function sendFederatedControlMail(args: { + params: SendParamsInput + runtime: OrcaRuntimeService + db: OrchestrationDb + from: string + to: string + messageRunId: string | undefined + revalidateLegacyCoordinator: (() => string) | undefined + withSendWarnings: SendReceipt +}): unknown { + const { + params, + runtime, + db, + from, + to, + messageRunId, + revalidateLegacyCoordinator, + withSendWarnings + } = args + const dispatchId = to.startsWith('dispatch:') ? to.slice('dispatch:'.length) : undefined + const federatedTarget = + dispatchId && to === `dispatch:${dispatchId}` ? db.getFederatedDispatch(dispatchId) : undefined + if (!federatedTarget || !dispatchId) { + return undefined + } + if (federatedTarget.protocol_version < ORCHESTRATION_FEDERATION_CONTROL_MAIL_PROTOCOL_VERSION) { + throw new OrchestrationError( + 'capability_unsupported', + `Federated Dispatch ${dispatchId} does not support coordinator control mail; start a fresh worker after updating its Orca server.` + ) + } + if (db.getWorkerDispatch(dispatchId)?.state !== 'ready') { + throw new OrchestrationError( + 'dispatch_inactive', + `Federated Dispatch ${dispatchId} is not active.` + ) + } + if (params.type === 'worker_done' || params.type === 'heartbeat') { + throw new OrchestrationError( + 'invalid_argument', + 'Coordinator-to-worker control mail cannot report worker lifecycle.' + ) + } + revalidateLegacyCoordinator?.() + const relay = db.enqueueFederationRelay({ + dispatchId, + direction: 'to_worker', + kind: 'control_message', + payload: encodeFederatedControlMessage({ + from, + subject: params.subject, + body: params.body ?? '', + type: (params.type ?? 'status') as MessageType, + priority: (params.priority ?? 'normal') as MessagePriority, + threadId: params.threadId ?? null, + payload: params.payload ?? null + }) + }) + runtime.ensureOrchestrationFederationRelay(messageRunId) + return withSendWarnings({ + relay: { + messageId: relay.message_id, + sequence: relay.sequence, + dispatchId: relay.dispatch_id, + destination: 'worker', + accepted: true + } + }) +} diff --git a/src/main/runtime/rpc/methods/orchestration-send-group.ts b/src/main/runtime/rpc/methods/orchestration-send-group.ts new file mode 100644 index 00000000000..aa3c8d47788 --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration-send-group.ts @@ -0,0 +1,131 @@ +import type { MessagePriority, MessageType, OrchestrationDb } from '../../orchestration/db' +import type { OrcaRuntimeService } from '../../orca-runtime' +import { OrchestrationError } from '../../orchestration/orchestration-error' +import { resolveGroupAddress } from '../../orchestration/groups' +import { resolveBareOrchestrationRecipient } from './orchestration-recipient-routing' +import { legacyWorkerDeliveryContract } from './orchestration-routing' +import type { SendRecipientWarning } from './orchestration-recipient-routing' +import type { SendParams } from './orchestration-schemas' +import type { z } from 'zod' + +type SendParamsInput = z.infer +type SendReceipt = (receipt: T) => T & { warnings?: SendRecipientWarning[] } + +export async function sendGroupMessage(args: { + params: SendParamsInput + runtime: OrcaRuntimeService + db: OrchestrationDb + from: string + groupAddress: string + senderPaneKey: string | undefined + senderRunId: string | undefined + explicitRunId: string | undefined + legacyCoordinatorRunId: string | undefined + revalidateLegacyCoordinator: (() => string) | undefined + recordMutationReceipt: ((receipt: unknown) => void) | undefined + withSendWarnings: SendReceipt +}): Promise { + const { + params, + runtime, + db, + from, + groupAddress, + senderPaneKey, + senderRunId, + explicitRunId, + legacyCoordinatorRunId, + revalidateLegacyCoordinator, + recordMutationReceipt + } = args + // Why: fan out one message per recipient (independent read-tracking) but share a thread_id for correlation (Section 4.5). + const { terminals } = await runtime.listTerminals(undefined, undefined, { + includeVisualLayouts: false + }) + const handles = resolveGroupAddress(groupAddress, from, terminals, (handle: string) => + runtime.getAgentStatusForHandle(handle) + ) + if (handles.length === 0) { + throw new Error(`No recipients resolved for group address: ${groupAddress}`) + } + + const legacyAdoptedMailboxOwner = db.getLegacyAdoptedRunMailboxOwner() + const resolvedRecipients = handles.map((handle) => ({ + handle, + resolution: resolveBareOrchestrationRecipient({ + runtime, + db, + handle, + senderRunId, + explicitRunId, + legacyAdoptedMailboxOwner + }) + })) + const deliverableRecipients = resolvedRecipients.filter( + ( + recipient + ): recipient is typeof recipient & { + resolution: { ok: true; to: string; runId?: string; warning?: SendRecipientWarning } + } => recipient.resolution.ok + ) + const senderRecipient = resolveBareOrchestrationRecipient({ + runtime, + db, + handle: from, + senderRunId, + legacyAdoptedMailboxOwner + }) + const senderMailboxKey = senderRecipient.ok + ? `${senderRecipient.runId ?? ''}\u0000${senderRecipient.to}` + : undefined + const seenMailboxes = new Set() + const uniqueRecipients = deliverableRecipients.filter(({ resolution }) => { + const mailboxKey = `${resolution.runId ?? ''}\u0000${resolution.to}` + if (mailboxKey === senderMailboxKey || seenMailboxes.has(mailboxKey)) { + return false + } + seenMailboxes.add(mailboxKey) + return true + }) + if (uniqueRecipients.length === 0) { + throw new OrchestrationError( + 'terminal_not_found', + `No recipient of ${groupAddress} resolved to a live terminal or durable Run/Dispatch mailbox.` + ) + } + + revalidateLegacyCoordinator?.() + const threadId = params.threadId ?? `thread_${Date.now()}` + const messages = db.insertMessages( + uniqueRecipients.map(({ resolution }) => ({ + from, + to: resolution.to, + subject: params.subject, + body: params.body, + type: params.type as MessageType, + priority: params.priority as MessagePriority, + threadId, + payload: params.payload, + senderPaneKey, + runId: resolution.runId, + deliveryContract: legacyWorkerDeliveryContract( + runtime, + resolution.runId ?? legacyCoordinatorRunId, + resolution.to + ) + })) + ) + const groupWarnings = resolvedRecipients.flatMap(({ resolution }) => + resolution.ok ? (resolution.warning ? [resolution.warning] : []) : [resolution.warning] + ) + const receipt = { + messages, + recipients: messages.length, + ...(groupWarnings.length > 0 ? { warnings: groupWarnings } : {}) + } + recordMutationReceipt?.(receipt) + for (const message of messages) { + runtime.notifyMessageArrived(message.to_handle, message.type) + } + return receipt +} diff --git a/src/main/runtime/rpc/methods/orchestration-send-methods.ts b/src/main/runtime/rpc/methods/orchestration-send-methods.ts new file mode 100644 index 00000000000..c94e0f22165 --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration-send-methods.ts @@ -0,0 +1,188 @@ +import { defineMethod, type RpcMethod } from '../core' +import { OrchestrationError } from '../../orchestration/orchestration-error' +import { isGroupAddress } from '../../orchestration/groups' +import { orchestrationSkillRecoveryData } from '../../../../shared/orchestration-rpc-contract' +import { + SendParams, + isWorkerReportOutcome, + parseRemoteWorkerPayload +} from './orchestration-schemas' +import { resolveMessageRun } from './orchestration-routing' +import { + assertDispatchMailboxDeliverable, + resolveBareOrchestrationRecipient, + type SendRecipientWarning +} from './orchestration-recipient-routing' +import { sendRemoteMessage } from './orchestration-send-remote' +import { sendPointToPointMessage } from './orchestration-send-point-to-point' +import { sendGroupMessage } from './orchestration-send-group' +import { sendFederatedControlMail } from './orchestration-send-control-mail' + +export const ORCHESTRATION_SEND_METHODS: RpcMethod[] = [ + defineMethod({ + name: 'orchestration.send', + params: SendParams, + handler: async ( + params, + { + runtime, + orchestrationCapability, + legacyCoordinatorRunId, + revalidateLegacyCoordinator, + orchestrationCompatibilityCallerAuthority, + recordMutationReceipt, + signal + } + ) => { + const db = runtime.getOrchestrationDb() + const from = params.from ?? 'unknown' + const attestedCaller = + orchestrationCompatibilityCallerAuthority?.terminalHandle === from + ? orchestrationCompatibilityCallerAuthority + : undefined + // Why: attested hook identity survives graph remount; caller params never supply lifecycle authority. + const senderPaneKey = attestedCaller?.paneKey ?? runtime.getTerminalPaneKey(from) ?? undefined + const remoteAttachment = senderPaneKey + ? db.findActiveRemoteAttachmentForPane(senderPaneKey) + : undefined + if (remoteAttachment && senderPaneKey) { + return sendRemoteMessage({ + params, + runtime, + db, + from, + senderPaneKey, + remoteAttachment, + processIncarnation: + attestedCaller?.processIncarnation ?? + runtime.getTerminalProcessIncarnation(from) ?? + undefined, + orchestrationCapability, + signal + }) + } + + const routing = resolveMessageRun(runtime, { + from, + senderPaneKey, + to: params.to, + runId: params.run, + payload: params.payload + }) + if ( + params.type === 'worker_done' && + !isWorkerReportOutcome(parseRemoteWorkerPayload(params.payload).outcome) + ) { + throw new OrchestrationError( + 'invalid_argument', + 'worker_done requires outcome=succeeded|failed for a current Dispatch.' + ) + } + if (params.to?.startsWith('task:')) { + throw new OrchestrationError( + 'invalid_argument', + 'Task recipients are intentionally unsupported; use run: or dispatch:.' + ) + } + + let to = params.to + if ( + routing.run && + (!to || + ((params.type === 'worker_done' || params.type === 'heartbeat') && routing.dispatchId)) + ) { + to = `run:${routing.run.id}` + } + if (!to) { + throw new OrchestrationError( + 'run_required', + 'No recipient or active Dispatch Run could be resolved. No effects were applied.', + orchestrationSkillRecoveryData() + ) + } + + const sendWarnings: SendRecipientWarning[] = [] + let messageRunId = routing.run?.id + if (!isGroupAddress(to) && !to.startsWith('run:') && !to.startsWith('dispatch:')) { + const recipient = resolveBareOrchestrationRecipient({ + runtime, + db, + handle: to, + senderRunId: routing.run?.id, + explicitRunId: params.run + }) + if (!recipient.ok) { + throw new OrchestrationError(recipient.code, recipient.message) + } + to = recipient.to + messageRunId = recipient.runId + if (recipient.warning) { + sendWarnings.push(recipient.warning) + } + } + const withSendWarnings = ( + receipt: T + ): T & { warnings?: SendRecipientWarning[] } => + sendWarnings.length > 0 ? { ...receipt, warnings: sendWarnings } : receipt + + if (!isGroupAddress(to)) { + const addressedDispatchId = to.startsWith('dispatch:') + ? to.slice('dispatch:'.length) + : undefined + const federatedTarget = + addressedDispatchId && to === `dispatch:${addressedDispatchId}` + ? db.getFederatedDispatch(addressedDispatchId) + : undefined + // Federated targets perform their own liveness check before relaying. + if (addressedDispatchId && !federatedTarget) { + assertDispatchMailboxDeliverable(db, addressedDispatchId) + } + const federatedControl = sendFederatedControlMail({ + params, + runtime, + db, + from, + to, + messageRunId, + revalidateLegacyCoordinator, + withSendWarnings + }) + if (federatedControl !== undefined) { + return federatedControl + } + return sendPointToPointMessage({ + params, + runtime, + db, + from, + to, + dispatchId: routing.dispatchId, + messageRunId, + senderPaneKey, + legacyCoordinatorRunId, + orchestrationCapability, + resolveProcessIncarnation: () => + attestedCaller?.processIncarnation ?? + runtime.getTerminalProcessIncarnation(from) ?? + undefined, + revalidateLegacyCoordinator, + withSendWarnings + }) + } + return sendGroupMessage({ + params, + runtime, + db, + from, + groupAddress: to, + senderPaneKey, + senderRunId: routing.run?.id, + explicitRunId: params.run, + legacyCoordinatorRunId, + revalidateLegacyCoordinator, + recordMutationReceipt, + withSendWarnings + }) + } + }) +] diff --git a/src/main/runtime/rpc/methods/orchestration-send-point-to-point.ts b/src/main/runtime/rpc/methods/orchestration-send-point-to-point.ts new file mode 100644 index 00000000000..827b90b8976 --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration-send-point-to-point.ts @@ -0,0 +1,188 @@ +import type { MessagePriority, MessageType, OrchestrationDb } from '../../orchestration/db' +import type { OrcaRuntimeService } from '../../orca-runtime' +import { reconcileLifecycleMessage } from '../../orchestration/lifecycle-reconciliation' +import { bindCoordinatorMutationPayload } from '../../orchestration/dispatch-message-binding' +import { isDispatchMutationMessageType, parseMessageTaskId } from './orchestration-schemas' +import type { SendParams } from './orchestration-schemas' +import { legacyWorkerDeliveryContract } from './orchestration-routing' +import type { SendRecipientWarning } from './orchestration-recipient-routing' +import type { z } from 'zod' + +type SendParamsInput = z.infer +type SendReceipt = (receipt: T) => T & { warnings?: SendRecipientWarning[] } + +export function sendPointToPointMessage(args: { + params: SendParamsInput + runtime: OrcaRuntimeService + db: OrchestrationDb + from: string + to: string + dispatchId: string | undefined + messageRunId: string | undefined + senderPaneKey: string | undefined + legacyCoordinatorRunId: string | undefined + orchestrationCapability: string | undefined + resolveProcessIncarnation: () => string | undefined + revalidateLegacyCoordinator: (() => string) | undefined + withSendWarnings: SendReceipt +}): unknown { + const { + params, + runtime, + db, + from, + to, + dispatchId, + messageRunId, + senderPaneKey, + legacyCoordinatorRunId, + orchestrationCapability, + resolveProcessIncarnation, + revalidateLegacyCoordinator, + withSendWarnings + } = args + // Point-to-point — existing single-recipient behavior + revalidateLegacyCoordinator?.() + const dispatch = dispatchId ? db.getDispatchContextById(dispatchId) : undefined + const messageType = (params.type ?? 'status') as MessageType + const msg = db.insertMessage({ + from, + to, + subject: params.subject, + body: params.body, + type: messageType, + priority: params.priority as MessagePriority, + threadId: params.threadId, + payload: dispatch + ? bindCoordinatorMutationPayload(messageType, params.payload, dispatch.id) + : params.payload, + senderPaneKey, + runId: messageRunId, + deliveryContract: legacyWorkerDeliveryContract( + runtime, + messageRunId ?? legacyCoordinatorRunId, + to + ) + }) + if (isDispatchMutationMessageType(msg.type)) { + const processIncarnation = resolveProcessIncarnation() + const taskId = parseMessageTaskId(params.payload) + const capabilityBacked = Boolean(dispatch?.capability_hash) + const coordinatorMutation = msg.type === 'escalation' || msg.type === 'decision_gate' + const authority = resolveLifecycleAuthority({ + db, + dispatch, + from, + paneKey: senderPaneKey, + processIncarnation, + capability: orchestrationCapability, + taskId, + capabilityBacked, + coordinatorMutation + }) + if (!authority.valid) { + const rejection = + db.convertLifecycleMessageToRejection(msg.id, authority.code, authority.reason) ?? msg + runtime.notifyMessageArrived(rejection.to_handle, rejection.type) + return withSendWarnings({ + message: rejection, + lifecycle: { action: 'rejected', code: authority.code, reason: authority.reason } + }) + } + } + + // Why: reconcile releases the dispatch lock before waking recipients, else a woken coordinator re-dispatches while the lock is still held. + if (msg.type === 'worker_done' || msg.type === 'heartbeat') { + const reconciled = reconcileLifecycleMessage(db, msg) + // Why: a suppressed message is already read, so skip the notify that would wake a check --wait waiter to an empty result. + if (reconciled.action === 'suppressed') { + return withSendWarnings({ message: msg }) + } + if (reconciled.action === 'rejected') { + const rejection = db.getMessageById(msg.id) ?? msg + runtime.notifyMessageArrived(rejection.to_handle, rejection.type) + return withSendWarnings({ message: rejection, lifecycle: reconciled }) + } + runtime.notifyMessageArrived(msg.to_handle, msg.type) + return withSendWarnings( + msg.type === 'worker_done' ? { message: msg, lifecycle: reconciled } : { message: msg } + ) + } + runtime.notifyMessageArrived(msg.to_handle, msg.type) + return withSendWarnings({ message: msg }) +} + +type LifecycleAuthority = { + valid: boolean + code: 'sender_not_assignee' | 'task_dispatch_mismatch' | 'dispatch_capability_invalid' + reason: string +} + +function resolveLifecycleAuthority(args: { + db: OrchestrationDb + dispatch: ReturnType + from: string + paneKey: string | undefined + processIncarnation: string | undefined + capability: string | undefined + taskId: string | undefined + capabilityBacked: boolean + coordinatorMutation: boolean +}): LifecycleAuthority { + const { + db, + dispatch, + from, + paneKey, + processIncarnation, + capability, + taskId, + capabilityBacked, + coordinatorMutation + } = args + if (!dispatch) { + return { + valid: !coordinatorMutation, + code: 'sender_not_assignee', + reason: 'No active Dispatch belongs to this message sender.' + } + } + if (coordinatorMutation && taskId && taskId !== dispatch.task_id) { + return { + valid: false, + code: 'task_dispatch_mismatch', + reason: `Task ${taskId} does not belong to Dispatch ${dispatch.id}.` + } + } + if (capabilityBacked) { + const authority = db.verifyDispatchCapability({ + dispatchId: dispatch.id, + capability, + paneKey, + processIncarnation + }) + return { + valid: authority.valid, + code: 'dispatch_capability_invalid', + reason: authority.valid ? '' : authority.reason + } + } + if (dispatch.process_incarnation) { + return { + valid: db.isDispatchProcessCurrent({ + dispatchId: dispatch.id, + paneKey: paneKey ?? null, + processIncarnation: processIncarnation ?? null + }), + code: 'sender_not_assignee', + reason: `Dispatch ${dispatch.id} process incarnation is no longer current for its pane.` + } + } + return { + valid: + !coordinatorMutation || + db.isDispatchMessageSender({ dispatchId: dispatch.id, handle: from, paneKey }), + code: 'sender_not_assignee', + reason: `Terminal ${from} does not own Dispatch ${dispatch.id}.` + } +} diff --git a/src/main/runtime/rpc/methods/orchestration-send-remote.ts b/src/main/runtime/rpc/methods/orchestration-send-remote.ts new file mode 100644 index 00000000000..9243b977d2e --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration-send-remote.ts @@ -0,0 +1,114 @@ +import type { MessageType, OrchestrationDb } from '../../orchestration/db' +import type { OrcaRuntimeService } from '../../orca-runtime' +import { OrchestrationError } from '../../orchestration/orchestration-error' +import { waitForFederatedLifecycleSettlement } from '../../orchestration/federation-lifecycle-settlement' +import { bindCoordinatorMutationPayload } from '../../orchestration/dispatch-message-binding' +import { ORCHESTRATION_FEDERATION_LIFECYCLE_SETTLEMENT_PROTOCOL_VERSION } from '../../../../shared/protocol-version' +import type { z } from 'zod' +import { parseRemoteWorkerPayload } from './orchestration-schemas' +import type { SendParams } from './orchestration-schemas' +import { rejectFederatedExplicitTarget } from './orchestration-routing' + +type SendParamsInput = z.infer + +type RemoteAttachment = { + dispatch_id: string + protocol_version: number +} + +export async function sendRemoteMessage(args: { + params: SendParamsInput + runtime: OrcaRuntimeService + db: OrchestrationDb + from: string + senderPaneKey: string + remoteAttachment: RemoteAttachment + processIncarnation?: string | null + orchestrationCapability?: string + signal?: AbortSignal +}): Promise { + const { params, runtime, db, from, senderPaneKey, remoteAttachment } = args + rejectFederatedExplicitTarget(params) + if ( + !db.verifyRemoteAttachmentAuthority({ + dispatchId: remoteAttachment.dispatch_id, + capability: args.orchestrationCapability, + paneKey: senderPaneKey, + processIncarnation: args.processIncarnation ?? null + }) + ) { + throw new OrchestrationError( + 'dispatch_capability_invalid', + 'The remote Dispatch capability or exact worker process is invalid.' + ) + } + + const type = (params.type ?? 'status') as MessageType + const payload = parseRemoteWorkerPayload(params.payload) + if ( + typeof payload.dispatchId === 'string' && + payload.dispatchId !== remoteAttachment.dispatch_id + ) { + throw new OrchestrationError( + 'dispatch_inactive', + `Dispatch ${payload.dispatchId} is not the active remote Dispatch for this pane.` + ) + } + const outcome = + type === 'worker_done' && (payload.outcome === 'succeeded' || payload.outcome === 'failed') + ? payload.outcome + : undefined + if (type === 'worker_done' && !outcome) { + throw new OrchestrationError( + 'invalid_argument', + 'Remote worker_done requires outcome=succeeded|failed.' + ) + } + + const supportsLifecycleSettlement = + remoteAttachment.protocol_version >= + ORCHESTRATION_FEDERATION_LIFECYCLE_SETTLEMENT_PROTOCOL_VERSION + const relay = db.enqueueFederationRelay({ + dispatchId: remoteAttachment.dispatch_id, + direction: 'to_home', + kind: type, + payload: JSON.stringify({ + from, + subject: params.subject, + body: params.body ?? '', + type, + priority: params.priority ?? 'normal', + threadId: params.threadId ?? null, + payload: bindCoordinatorMutationPayload(type, params.payload, remoteAttachment.dispatch_id) + }), + ...(!supportsLifecycleSettlement && outcome ? { settleRemoteOutcome: outcome } : {}) + }) + const lifecycle = + outcome && supportsLifecycleSettlement + ? await waitForFederatedLifecycleSettlement(runtime, relay.dispatch_id, relay.sequence, { + timeoutMs: 30_000, + signal: args.signal + }) + : outcome + ? { + action: outcome === 'succeeded' ? ('completed' as const) : ('failed' as const), + authority: 'worker_server_legacy' as const + } + : undefined + if (outcome && supportsLifecycleSettlement && !lifecycle) { + throw new OrchestrationError( + 'operation_unknown', + 'worker_done was queued, but the Run-home runtime did not confirm settlement. Verify the Task and Dispatch before retrying.' + ) + } + return { + relay: { + messageId: relay.message_id, + sequence: relay.sequence, + dispatchId: relay.dispatch_id, + destination: 'run_home', + accepted: true + }, + ...(lifecycle ? { lifecycle } : {}) + } +} diff --git a/src/main/runtime/rpc/methods/orchestration-send.test.ts b/src/main/runtime/rpc/methods/orchestration-send.test.ts index e4312c34a3a..7b84cad90c6 100644 --- a/src/main/runtime/rpc/methods/orchestration-send.test.ts +++ b/src/main/runtime/rpc/methods/orchestration-send.test.ts @@ -46,6 +46,21 @@ describe('orchestration RPC methods', () => { } describe('orchestration.send', () => { + it('does not resolve process incarnation for ordinary messages', async () => { + setup() + const resolveProcessIncarnation = vi.mocked(runtime.getTerminalProcessIncarnation) + resolveProcessIncarnation.mockClear() + + const result = (await call('orchestration.send', { + from: 'term_coord', + to: `run:${activeRunId}`, + subject: 'hello' + })) as { message: { type: string } } + + expect(result.message.type).toBe('status') + expect(resolveProcessIncarnation).not.toHaveBeenCalled() + }) + it('sends a message', async () => { setup() // Why: send notifies arrival so already-idle recipients get push-on-idle diff --git a/src/main/runtime/rpc/methods/orchestration.ts b/src/main/runtime/rpc/methods/orchestration.ts index 2d5e7b6a796..fab5feba812 100644 --- a/src/main/runtime/rpc/methods/orchestration.ts +++ b/src/main/runtime/rpc/methods/orchestration.ts @@ -1,2057 +1,26 @@ -/* eslint-disable max-lines -- Why: RPC method definitions co-locate param schemas with handlers; splitting by method would scatter the shared enums and Zod transforms without reducing complexity. */ -import { z } from 'zod' -import { setImmediate as yieldToEventLoop } from 'node:timers/promises' -import { defineMethod, type RpcMethod } from '../core' -import { resolveDispatchCreator } from './orchestration-dispatch-creator' -import { OptionalFiniteNumber, OptionalString, OptionalBoolean, requiredString } from '../schemas' -import { - LEGACY_CONTRACT_VERSION, - type MessageRow, - type MessageType, - type MessagePriority, - type TaskStatus -} from '../../orchestration/db' -import { MESSAGE_TYPES } from '../../orchestration/types' -import { buildDispatchPreamble } from '../../orchestration/preamble' -import { formatMessageBanner } from '../../orchestration/formatter' -import { isGroupAddress, resolveGroupAddress } from '../../orchestration/groups' -import { reconcileLifecycleMessage } from '../../orchestration/lifecycle-reconciliation' -import { waitForFederatedLifecycleSettlement } from '../../orchestration/federation-lifecycle-settlement' -import { abbreviateOrchestrationTasks } from '../../../../shared/orchestration-task-summary' -import { - ORCHESTRATION_LEGACY_RUN_ID, - orchestrationSkillRecoveryData -} from '../../../../shared/orchestration-rpc-contract' -import { clampOrchestrationAskTimeoutMs } from '../../../../shared/orchestration-ask-timeout' -import { ORCHESTRATION_GATE_METHODS } from './orchestration-gates' -import { - assertDispatchMailboxDeliverable, - resolveBareOrchestrationRecipient, - type SendRecipientWarning -} from './orchestration-recipient-routing' -import { buildInjectRejectionMessage } from './orchestration-inject-rejection-message' -import { parseOrchestrationTaskDepsFlag } from '../../orchestration/task-deps-flag' -import { resolveRunScope } from './orchestration-run-scope' +import type { RpcMethod } from '../core' import { ORCHESTRATION_RUN_METHODS } from './orchestration-runs' import { ORCHESTRATION_WORKER_METHODS } from './orchestration-worker-methods' import { ORCHESTRATION_FEDERATION_METHODS } from './orchestration-federation-methods' import { ORCHESTRATION_MUTATION_REQUEST_METHODS } from './orchestration-mutation-request-show' -import { OrchestrationError } from '../../orchestration/orchestration-error' -import type { OrcaRuntimeService } from '../../orca-runtime' -import type { RunRow } from '../../orchestration/types' -import { encodeFederatedControlMessage } from '../../orchestration/federation-control-message' -import { bindCoordinatorMutationPayload } from '../../orchestration/dispatch-message-binding' -import { - ORCHESTRATION_FEDERATION_CONTROL_MAIL_PROTOCOL_VERSION, - ORCHESTRATION_FEDERATION_LIFECYCLE_SETTLEMENT_PROTOCOL_VERSION -} from '../../../../shared/protocol-version' - -const TASK_STATUSES: TaskStatus[] = [ - 'pending', - 'ready', - 'dispatched', - 'completed', - 'failed', - 'blocked' -] - -async function routeAllMailboxPages( - routePage: () => { routedCount: number; hasMore: boolean }, - signal?: AbortSignal -): Promise { - while (true) { - if (signal?.aborted) { - throw new OrchestrationError('request_aborted', 'Mailbox routing was cancelled.') - } - const page = routePage() - if (!page.hasMore) { - return - } - await yieldToEventLoop() - if (signal?.aborted) { - throw new OrchestrationError('request_aborted', 'Mailbox routing was cancelled.') - } - } -} - -type DispatchMutationMessageType = 'worker_done' | 'heartbeat' | 'escalation' | 'decision_gate' - -const SEND_MESSAGE_TYPE_ERROR = [ - `Invalid --type. Expected one of: ${MESSAGE_TYPES.join(', ')}.`, - 'To answer a worker question, use the same Orca CLI executable with orchestration reply --id --body .' -].join(' ') - -function isDispatchMutationMessageType( - type: string | undefined -): type is DispatchMutationMessageType { - return ( - type === 'worker_done' || - type === 'heartbeat' || - type === 'escalation' || - type === 'decision_gate' - ) -} - -function getLifecycleGroupRecipientError(type: DispatchMutationMessageType): string { - return `${type} messages belong to one exact Dispatch and cannot target a group address.` -} - -function parseRemoteWorkerPayload(payload: string | undefined): Record { - if (!payload) { - return {} - } - try { - const parsed: unknown = JSON.parse(payload) - return parsed && typeof parsed === 'object' && !Array.isArray(parsed) - ? (parsed as Record) - : {} - } catch { - throw new OrchestrationError('invalid_argument', 'Message payload must be valid JSON.') - } -} - -function parseMessageTaskId(payload: string | undefined): string | undefined { - if (!payload) { - return undefined - } - try { - const parsed: unknown = JSON.parse(payload) - return parsed && typeof parsed === 'object' && !Array.isArray(parsed) - ? typeof (parsed as { taskId?: unknown }).taskId === 'string' - ? (parsed as { taskId: string }).taskId - : undefined - : undefined - } catch { - return undefined - } -} - -function isWorkerReportOutcome(value: unknown): value is 'succeeded' | 'failed' { - return value === 'succeeded' || value === 'failed' -} - -const SendParams = z - .object({ - to: OptionalString, - subject: requiredString('Missing --subject'), - from: OptionalString, - body: OptionalString, - type: z - .enum(MESSAGE_TYPES, { - error: SEND_MESSAGE_TYPE_ERROR - }) - .optional(), - priority: z.enum(['normal', 'high', 'urgent']).optional(), - threadId: OptionalString, - payload: OptionalString, - // Why: pane key is the remint-stable identity used to verify worker_done/heartbeat ownership; the from handle stays routing metadata. - senderPaneKey: OptionalString, - run: OptionalString, - waitForLifecycleSettlement: OptionalBoolean, - devMode: OptionalBoolean - }) - .superRefine((params, ctx) => { - if (!isDispatchMutationMessageType(params.type) || !params.to || !isGroupAddress(params.to)) { - return - } - // Why: dispatch lifecycle messages are authority/liveness signals for one coordinator; fanout would create lifecycle mail in unrelated terminals. - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: getLifecycleGroupRecipientError(params.type), - path: ['to'] - }) - }) - -const CheckParams = z - .object({ - terminal: OptionalString, - terminalPaneKey: OptionalString, - unread: OptionalBoolean, - peek: OptionalBoolean, - // Why: `all` surfaces every message and skips mark-read; legacy encoding was the `{unread: false}` trick (design doc §3.2/§3.3). - all: OptionalBoolean, - types: OptionalString, - format: OptionalBoolean, - // Why: one-release RPC compatibility only; the public CLI uses --format because no terminal input is injected. - inject: OptionalBoolean, - ack: OptionalString, - compatibilityAck: OptionalString, - compatibilityQuestionAck: OptionalString, - compatibilityCliCommand: z.enum(['orca', 'orca-ide', 'orca-dev']).optional(), - run: OptionalString, - wait: OptionalBoolean, - timeoutMs: OptionalFiniteNumber - }) - .superRefine((params, ctx) => { - // Why: CLI encodes --peek as {peek:true, unread:false} for pre-peek runtimes, so that pair is one mode, not a conflict. - const modes = [ - params.unread === true, - params.peek === true, - params.all === true || (params.unread === false && params.peek !== true) - ].filter(Boolean) - if (modes.length > 1) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: 'Choose at most one message read mode: --unread, --peek, or --all.' - }) - } - }) - -const ReplyParams = z.object({ - id: requiredString('Missing --id'), - body: requiredString('Missing --body'), - from: OptionalString, - run: OptionalString -}) - -const InboxParams = z.object({ - limit: OptionalFiniteNumber, - // Why: filters the inbox to a handle so inbox and check --all give agreeing results (design doc §3.3). - terminal: OptionalString -}) - -const TaskCreateParams = z.object({ - spec: requiredString('Missing --spec'), - taskTitle: OptionalString, - displayName: OptionalString, - deps: OptionalString, - parent: OptionalString, - callerTerminalHandle: OptionalString, - run: OptionalString -}) - -const TaskListParams = z.object({ - status: z.enum(['pending', 'ready', 'dispatched', 'completed', 'failed', 'blocked']).optional(), - ready: OptionalBoolean, - // Why: server-side truncation keeps --brief cheap over SSH/relay instead of shipping full specs the CLI throws away. - brief: OptionalBoolean, - run: OptionalString, - callerTerminalHandle: OptionalString -}) - -const TaskUpdateParams = z.object({ - id: requiredString('Missing --id'), - status: z - .unknown() - .transform((v) => { - if (typeof v === 'string' && TASK_STATUSES.includes(v as TaskStatus)) { - return v as TaskStatus - } - return '' - }) - .pipe( - z.enum(['pending', 'ready', 'dispatched', 'completed', 'failed', 'blocked'], { - message: 'Missing --status' - }) - ), - result: OptionalString, - run: OptionalString, - callerTerminalHandle: OptionalString -}) - -const DispatchParams = z.object({ - task: requiredString('Missing --task'), - // Why: --to is optional so --dry-run can preview without a target; the handler enforces presence before any side-effecting work. - to: OptionalString, - from: OptionalString, - inject: OptionalBoolean, - dryRun: OptionalBoolean, - returnPreamble: OptionalBoolean, - devMode: OptionalBoolean, - run: OptionalString -}) - -const DispatchShowParams = z.object({ - task: OptionalString, - preamble: OptionalBoolean, - from: OptionalString, - devMode: OptionalBoolean -}) - -const AskParams = z - .object({ - to: OptionalString, - question: OptionalString, - resume: OptionalString, - options: OptionalString, - timeoutMs: OptionalFiniteNumber, - from: OptionalString, - run: OptionalString, - compatibilityCliCommand: z.enum(['orca', 'orca-ide', 'orca-dev']).optional(), - compatibilityWindowsCommand: z.enum(['orca', 'orca-ide']).optional() - }) - .superRefine((params, ctx) => { - if ((params.question ? 1 : 0) + (params.resume ? 1 : 0) !== 1) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: 'Choose exactly one of --question or --resume.' - }) - } - }) - -const ResetParams = z - .object({ - all: OptionalBoolean, - tasks: OptionalBoolean, - messages: OptionalBoolean - }) - .superRefine((params, ctx) => { - const selectedScopeCount = [params.all, params.tasks, params.messages].filter( - (scope) => scope === true - ).length - if (selectedScopeCount !== 1) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: 'Choose exactly one reset scope: --all, --tasks, or --messages.' - }) - } - }) - -function parseMessageTypes(rawTypes: string | undefined): MessageType[] | undefined { - const types = rawTypes - ?.split(',') - .map((type) => type.trim()) - .filter(Boolean) as MessageType[] | undefined - const invalidTypes = types?.filter((type) => !MESSAGE_TYPES.includes(type)) - if (invalidTypes && invalidTypes.length > 0) { - throw new OrchestrationError('invalid_argument', `Invalid --types: ${invalidTypes.join(',')}`) - } - return types && types.length > 0 ? types : undefined -} - -function resolveMessageRun( - runtime: OrcaRuntimeService, - params: { - from?: string - senderPaneKey?: string - to?: string - runId?: string - payload?: string - } -): { run: RunRow | undefined; dispatchId: string | undefined } { - const db = runtime.getOrchestrationDb() - let dispatchId: string | undefined - if (params.payload) { - try { - const payload: unknown = JSON.parse(params.payload) - if ( - payload && - typeof payload === 'object' && - !Array.isArray(payload) && - typeof (payload as { dispatchId?: unknown }).dispatchId === 'string' - ) { - dispatchId = (payload as { dispatchId: string }).dispatchId - } - } catch { - // Lifecycle validation owns malformed payload errors; routing simply cannot derive a Dispatch. - } - } - if (!dispatchId && params.to?.startsWith('dispatch:')) { - dispatchId = params.to.slice('dispatch:'.length) - } - - const dispatch = dispatchId - ? db.getDispatchContextById(dispatchId) - : params.from - ? db.getActiveDispatchForIdentity(params.from, params.senderPaneKey) - : undefined - if (params.to?.startsWith('dispatch:') && !dispatch) { - throw new OrchestrationError( - 'dispatch_not_found', - `Dispatch ${dispatchId ?? ''} was not found.` - ) - } - const targetRunId = params.to?.startsWith('run:') ? params.to.slice('run:'.length) : undefined - const resolvedRunId = params.runId ?? targetRunId ?? dispatch?.run_id - let run = resolvedRunId ? db.getRun(resolvedRunId) : undefined - - if (!run && params.from) { - const paneKey = params.senderPaneKey ?? runtime.getTerminalPaneKey(params.from) - run = paneKey ? db.getCurrentRunForPane(paneKey) : undefined - } - if (resolvedRunId && (!run || run.legacy === 1)) { - throw new OrchestrationError('run_not_found', `Run ${resolvedRunId} was not found.`) - } - if (run && targetRunId && targetRunId !== run.id) { - throw new OrchestrationError('run_not_found', `Run ${targetRunId} was not found.`) - } - if (run && dispatch && dispatch.run_id !== run.id) { - throw new OrchestrationError( - 'dispatch_run_mismatch', - `Dispatch ${dispatch.id} belongs to Run ${dispatch.run_id}, not ${run.id}.` - ) - } - return { run, dispatchId: dispatch?.id ?? dispatchId } -} - -function legacyWorkerDeliveryContract( - runtime: OrcaRuntimeService, - runId: string | undefined, - recipient: string -): 'legacy_direct' | undefined { - if (!runId) { - return undefined - } - if (!recipient.startsWith('dispatch:')) { - return runtime - .getOrchestrationDb() - .resolveLegacyWorkerCandidate({ runId, terminalHandle: recipient }) - ? 'legacy_direct' - : undefined - } - const dispatch = runtime - .getOrchestrationDb() - .getDispatchContextById(recipient.slice('dispatch:'.length)) - return dispatch?.run_id === runId && - dispatch.contract_version === LEGACY_CONTRACT_VERSION && - (dispatch.status === 'pending' || dispatch.status === 'dispatched') - ? 'legacy_direct' - : undefined -} - -function interruptedAcknowledgedCheck( - runId: string, - acknowledged: string, - reason: 'consumer_fenced' | 'outcome_unknown' | 'waiter_exists' -): Record { - return { - runId, - deliveryId: null, - messages: [], - count: 0, - acknowledged, - timedOut: false, - cancelled: false, - connectionLost: false, - waitInterrupted: reason - } -} - -function rejectFederatedExplicitTarget(params: { to?: string; run?: string }): void { - if (params.to || params.run) { - throw new OrchestrationError( - 'invalid_argument', - 'Federated Dispatch messages route to their Run home; omit --to and --run.' - ) - } -} +import { ORCHESTRATION_SEND_METHODS } from './orchestration-send-methods' +import { ORCHESTRATION_CHECK_METHODS } from './orchestration-check-methods' +import { ORCHESTRATION_MESSAGE_METHODS } from './orchestration-message-methods' +import { ORCHESTRATION_DISPATCH_METHODS } from './orchestration-dispatch-methods' +import { ORCHESTRATION_ASK_METHODS } from './orchestration-ask-methods' +import { ORCHESTRATION_GATE_METHODS } from './orchestration-gates' +import { ORCHESTRATION_RESET_METHODS } from './orchestration-reset-methods' export const ORCHESTRATION_METHODS: RpcMethod[] = [ ...ORCHESTRATION_RUN_METHODS, ...ORCHESTRATION_WORKER_METHODS, ...ORCHESTRATION_FEDERATION_METHODS, ...ORCHESTRATION_MUTATION_REQUEST_METHODS, - defineMethod({ - name: 'orchestration.send', - params: SendParams, - handler: async ( - params, - { - runtime, - orchestrationCapability, - legacyCoordinatorRunId, - revalidateLegacyCoordinator, - orchestrationCompatibilityCallerAuthority, - recordMutationReceipt, - signal - } - ) => { - const db = runtime.getOrchestrationDb() - const from = params.from ?? 'unknown' - const attestedCaller = - orchestrationCompatibilityCallerAuthority?.terminalHandle === from - ? orchestrationCompatibilityCallerAuthority - : undefined - // Why: attested hook identity survives graph remount; caller params never supply lifecycle authority. - const senderPaneKey = attestedCaller?.paneKey ?? runtime.getTerminalPaneKey(from) ?? undefined - const remoteAttachment = senderPaneKey - ? db.findActiveRemoteAttachmentForPane(senderPaneKey) - : undefined - if (remoteAttachment) { - rejectFederatedExplicitTarget(params) - const processIncarnation = - attestedCaller?.processIncarnation ?? runtime.getTerminalProcessIncarnation(from) - if ( - !db.verifyRemoteAttachmentAuthority({ - dispatchId: remoteAttachment.dispatch_id, - capability: orchestrationCapability, - paneKey: senderPaneKey ?? null, - processIncarnation - }) - ) { - throw new OrchestrationError( - 'dispatch_capability_invalid', - 'The remote Dispatch capability or exact worker process is invalid.' - ) - } - const type = (params.type ?? 'status') as MessageType - const payload = parseRemoteWorkerPayload(params.payload) - if ( - typeof payload.dispatchId === 'string' && - payload.dispatchId !== remoteAttachment.dispatch_id - ) { - throw new OrchestrationError( - 'dispatch_inactive', - `Dispatch ${payload.dispatchId} is not the active remote Dispatch for this pane.` - ) - } - const outcome = - type === 'worker_done' && - (payload.outcome === 'succeeded' || payload.outcome === 'failed') - ? payload.outcome - : undefined - if (type === 'worker_done' && !outcome) { - throw new OrchestrationError( - 'invalid_argument', - 'Remote worker_done requires outcome=succeeded|failed.' - ) - } - const supportsLifecycleSettlement = - remoteAttachment.protocol_version >= - ORCHESTRATION_FEDERATION_LIFECYCLE_SETTLEMENT_PROTOCOL_VERSION - const relay = db.enqueueFederationRelay({ - dispatchId: remoteAttachment.dispatch_id, - direction: 'to_home', - kind: type, - payload: JSON.stringify({ - from, - subject: params.subject, - body: params.body ?? '', - type, - priority: params.priority ?? 'normal', - threadId: params.threadId ?? null, - payload: bindCoordinatorMutationPayload( - type, - params.payload, - remoteAttachment.dispatch_id - ) - }), - ...(!supportsLifecycleSettlement && outcome ? { settleRemoteOutcome: outcome } : {}) - }) - const lifecycle = - outcome && supportsLifecycleSettlement - ? await waitForFederatedLifecycleSettlement( - runtime, - relay.dispatch_id, - relay.sequence, - { - timeoutMs: 30_000, - signal - } - ) - : outcome - ? { - action: outcome === 'succeeded' ? ('completed' as const) : ('failed' as const), - authority: 'worker_server_legacy' as const - } - : undefined - if (outcome && supportsLifecycleSettlement && !lifecycle) { - throw new OrchestrationError( - 'operation_unknown', - 'worker_done was queued, but the Run-home runtime did not confirm settlement. Verify the Task and Dispatch before retrying.' - ) - } - return { - relay: { - messageId: relay.message_id, - sequence: relay.sequence, - dispatchId: relay.dispatch_id, - destination: 'run_home', - accepted: true - }, - ...(lifecycle ? { lifecycle } : {}) - } - } - const routing = resolveMessageRun(runtime, { - from, - senderPaneKey, - to: params.to, - runId: params.run, - payload: params.payload - }) - if ( - params.type === 'worker_done' && - !isWorkerReportOutcome(parseRemoteWorkerPayload(params.payload).outcome) - ) { - throw new OrchestrationError( - 'invalid_argument', - 'worker_done requires outcome=succeeded|failed for a current Dispatch.' - ) - } - if (params.to?.startsWith('task:')) { - throw new OrchestrationError( - 'invalid_argument', - 'Task recipients are intentionally unsupported; use run: or dispatch:.' - ) - } - let to = params.to - if ( - routing.run && - (!to || - ((params.type === 'worker_done' || params.type === 'heartbeat') && routing.dispatchId)) - ) { - to = `run:${routing.run.id}` - } - if (!to) { - throw new OrchestrationError( - 'run_required', - 'No recipient or active Dispatch Run could be resolved. No effects were applied.', - orchestrationSkillRecoveryData() - ) - } - - const sendWarnings: SendRecipientWarning[] = [] - let messageRunId = routing.run?.id - if (!isGroupAddress(to) && !to.startsWith('run:') && !to.startsWith('dispatch:')) { - const recipient = resolveBareOrchestrationRecipient({ - runtime, - db, - handle: to, - senderRunId: routing.run?.id, - explicitRunId: params.run - }) - if (!recipient.ok) { - throw new OrchestrationError(recipient.code, recipient.message) - } - to = recipient.to - messageRunId = recipient.runId - if (recipient.warning) { - sendWarnings.push(recipient.warning) - } - } - const withSendWarnings = ( - receipt: T - ): T & { - warnings?: SendRecipientWarning[] - } => (sendWarnings.length > 0 ? { ...receipt, warnings: sendWarnings } : receipt) - - if (!isGroupAddress(to)) { - const addressedDispatchId = to.startsWith('dispatch:') - ? to.slice('dispatch:'.length) - : undefined - const federatedTarget = - addressedDispatchId && to === `dispatch:${addressedDispatchId}` - ? db.getFederatedDispatch(addressedDispatchId) - : undefined - if (addressedDispatchId && !federatedTarget) { - assertDispatchMailboxDeliverable(db, addressedDispatchId) - } - if (federatedTarget && addressedDispatchId) { - const dispatchId = addressedDispatchId - if ( - federatedTarget.protocol_version < - ORCHESTRATION_FEDERATION_CONTROL_MAIL_PROTOCOL_VERSION - ) { - throw new OrchestrationError( - 'capability_unsupported', - `Federated Dispatch ${dispatchId} does not support coordinator control mail; start a fresh worker after updating its Orca server.` - ) - } - if (db.getWorkerDispatch(dispatchId)?.state !== 'ready') { - throw new OrchestrationError( - 'dispatch_inactive', - `Federated Dispatch ${dispatchId} is not active.` - ) - } - if (params.type === 'worker_done' || params.type === 'heartbeat') { - throw new OrchestrationError( - 'invalid_argument', - 'Coordinator-to-worker control mail cannot report worker lifecycle.' - ) - } - revalidateLegacyCoordinator?.() - const relay = db.enqueueFederationRelay({ - dispatchId, - direction: 'to_worker', - kind: 'control_message', - payload: encodeFederatedControlMessage({ - from, - subject: params.subject, - body: params.body ?? '', - type: (params.type ?? 'status') as MessageType, - priority: (params.priority ?? 'normal') as MessagePriority, - threadId: params.threadId ?? null, - payload: params.payload ?? null - }) - }) - runtime.ensureOrchestrationFederationRelay(messageRunId) - return withSendWarnings({ - relay: { - messageId: relay.message_id, - sequence: relay.sequence, - dispatchId: relay.dispatch_id, - destination: 'worker', - accepted: true - } - }) - } - // Point-to-point — existing single-recipient behavior - revalidateLegacyCoordinator?.() - const dispatch = routing.dispatchId - ? db.getDispatchContextById(routing.dispatchId) - : undefined - const messageType = (params.type ?? 'status') as MessageType - const msg = db.insertMessage({ - from, - to, - subject: params.subject, - body: params.body, - type: messageType, - priority: params.priority as MessagePriority, - threadId: params.threadId, - payload: dispatch - ? bindCoordinatorMutationPayload(messageType, params.payload, dispatch.id) - : params.payload, - senderPaneKey, - runId: messageRunId, - deliveryContract: legacyWorkerDeliveryContract( - runtime, - messageRunId ?? legacyCoordinatorRunId, - to - ) - }) - const dispatchMutationMessage = isDispatchMutationMessageType(msg.type) - if (dispatchMutationMessage) { - const processIncarnation = - attestedCaller?.processIncarnation ?? - runtime.getTerminalProcessIncarnation(from) ?? - undefined - const taskId = parseMessageTaskId(params.payload) - const capabilityBacked = Boolean(dispatch?.capability_hash) - const coordinatorMutation = msg.type === 'escalation' || msg.type === 'decision_gate' - let authority: { - valid: boolean - code: 'sender_not_assignee' | 'task_dispatch_mismatch' | 'dispatch_capability_invalid' - reason: string - } - if (!dispatch) { - authority = { - valid: !coordinatorMutation, - code: 'sender_not_assignee', - reason: 'No active Dispatch belongs to this message sender.' - } - } else if (coordinatorMutation && taskId && taskId !== dispatch.task_id) { - authority = { - valid: false, - code: 'task_dispatch_mismatch', - reason: `Task ${taskId} does not belong to Dispatch ${dispatch.id}.` - } - } else if (capabilityBacked) { - const capabilityAuthority = db.verifyDispatchCapability({ - dispatchId: dispatch.id, - capability: orchestrationCapability, - paneKey: senderPaneKey, - processIncarnation - }) - authority = { - valid: capabilityAuthority.valid, - code: 'dispatch_capability_invalid', - reason: capabilityAuthority.valid ? '' : capabilityAuthority.reason - } - } else if (dispatch.process_incarnation) { - authority = { - valid: db.isDispatchProcessCurrent({ - dispatchId: dispatch.id, - paneKey: senderPaneKey ?? null, - processIncarnation: processIncarnation ?? null - }), - code: 'sender_not_assignee', - reason: `Dispatch ${dispatch.id} process incarnation is no longer current for its pane.` - } - } else { - authority = { - valid: - !coordinatorMutation || - db.isDispatchMessageSender({ - dispatchId: dispatch.id, - handle: from, - paneKey: senderPaneKey - }), - code: 'sender_not_assignee', - reason: `Terminal ${from} does not own Dispatch ${dispatch.id}.` - } - } - if (!authority.valid) { - const code = authority.code - const rejection = - db.convertLifecycleMessageToRejection(msg.id, code, authority.reason) ?? msg - runtime.notifyMessageArrived(rejection.to_handle, rejection.type) - return withSendWarnings({ - message: rejection, - lifecycle: { - action: 'rejected', - code, - reason: authority.reason - } - }) - } - } - // Why: reconcile releases the dispatch lock before waking recipients, else a woken coordinator re-dispatches while the lock is still held. - if (msg.type === 'worker_done' || msg.type === 'heartbeat') { - const reconciled = reconcileLifecycleMessage(db, msg) - // Why: a suppressed message is already read, so skip the notify that would wake a check --wait waiter to an empty result. - if (reconciled.action === 'suppressed') { - return withSendWarnings({ message: msg }) - } - if (reconciled.action === 'rejected') { - const rejection = db.getMessageById(msg.id) ?? msg - runtime.notifyMessageArrived(rejection.to_handle, rejection.type) - return withSendWarnings({ message: rejection, lifecycle: reconciled }) - } - runtime.notifyMessageArrived(msg.to_handle, msg.type) - return withSendWarnings( - msg.type === 'worker_done' ? { message: msg, lifecycle: reconciled } : { message: msg } - ) - } - runtime.notifyMessageArrived(msg.to_handle, msg.type) - return withSendWarnings({ message: msg }) - } - - // Why: fan out one message per recipient (independent read-tracking) but share a thread_id for correlation (Section 4.5). - const { terminals } = await runtime.listTerminals(undefined, undefined, { - includeVisualLayouts: false - }) - const handles = resolveGroupAddress(to, from, terminals, (handle: string) => - runtime.getAgentStatusForHandle(handle) - ) - - if (handles.length === 0) { - throw new Error(`No recipients resolved for group address: ${to}`) - } - - const legacyAdoptedMailboxOwner = db.getLegacyAdoptedRunMailboxOwner() - const resolvedRecipients = handles.map((handle) => ({ - handle, - resolution: resolveBareOrchestrationRecipient({ - runtime, - db, - handle, - senderRunId: routing.run?.id, - explicitRunId: params.run, - legacyAdoptedMailboxOwner - }) - })) - const deliverableRecipients = resolvedRecipients.filter( - ( - recipient - ): recipient is typeof recipient & { - resolution: { ok: true; to: string; runId?: string; warning?: SendRecipientWarning } - } => recipient.resolution.ok - ) - const senderRecipient = resolveBareOrchestrationRecipient({ - runtime, - db, - handle: from, - senderRunId: routing.run?.id, - legacyAdoptedMailboxOwner - }) - const senderMailboxKey = senderRecipient.ok - ? `${senderRecipient.runId ?? ''}\u0000${senderRecipient.to}` - : undefined - const seenMailboxes = new Set() - const uniqueRecipients = deliverableRecipients.filter(({ resolution }) => { - const mailboxKey = `${resolution.runId ?? ''}\u0000${resolution.to}` - if (mailboxKey === senderMailboxKey || seenMailboxes.has(mailboxKey)) { - return false - } - seenMailboxes.add(mailboxKey) - return true - }) - if (uniqueRecipients.length === 0) { - throw new OrchestrationError( - 'terminal_not_found', - `No recipient of ${to} resolved to a live terminal or durable Run/Dispatch mailbox.` - ) - } - - revalidateLegacyCoordinator?.() - const threadId = params.threadId ?? `thread_${Date.now()}` - const messages = db.insertMessages( - uniqueRecipients.map(({ resolution }) => ({ - from, - to: resolution.to, - subject: params.subject, - body: params.body, - type: params.type as MessageType, - priority: params.priority as MessagePriority, - threadId, - payload: params.payload, - senderPaneKey, - runId: resolution.runId, - deliveryContract: legacyWorkerDeliveryContract( - runtime, - resolution.runId ?? legacyCoordinatorRunId, - resolution.to - ) - })) - ) - const groupWarnings = resolvedRecipients.flatMap(({ resolution }) => - resolution.ok ? (resolution.warning ? [resolution.warning] : []) : [resolution.warning] - ) - const receipt = { - messages, - recipients: messages.length, - ...(groupWarnings.length > 0 ? { warnings: groupWarnings } : {}) - } - recordMutationReceipt?.(receipt) - for (const message of messages) { - runtime.notifyMessageArrived(message.to_handle, message.type) - } - return receipt - } - }), - - defineMethod({ - name: 'orchestration.check', - params: CheckParams, - handler: async ( - params, - { - orchestrationCompatibilityEvidence, - runtime, - signal, - legacyCoordinatorRunId, - revalidateLegacyCoordinator, - recordMutationReceipt - } - ) => { - const db = runtime.getOrchestrationDb() - const handle = params.terminal ?? 'unknown' - const typeFilter = parseMessageTypes(params.types) - const routeDirectSnapshot = async ( - runId: string, - directHandle: string, - routePage: (throughSequence: number) => { routedCount: number; hasMore: boolean } - ): Promise => { - const throughSequence = db.getLatestUnreadDirectMessageSequenceForRun(runId, directHandle) - if (throughSequence !== undefined) { - await routeAllMailboxPages(() => routePage(throughSequence), signal) - } - } - - // Why: a live runtime handle is authoritative; pane metadata is only the restart fallback. - const paneKey = runtime.getTerminalPaneKey(handle) ?? params.terminalPaneKey - const boundRun = paneKey ? db.getCurrentRunForPane(paneKey) : undefined - if (params.run || boundRun) { - const run = resolveRunScope(runtime, { - runId: params.run, - callerTerminalHandle: handle, - callerPaneKey: paneKey ?? undefined, - requireCurrentConsumer: true, - legacyCoordinatorRunId, - callerEvidence: orchestrationCompatibilityEvidence - }) - const generation = run.consumer_generation - const address = `run:${run.id}` - runtime.ensureOrchestrationFederationRelay(run.id) - await routeDirectSnapshot(run.id, handle, (throughSequence) => - db.routeUnreadDirectMessagesToRunMailbox(run.id, handle, throughSequence) - ) - const coordinatorHandle = run.coordinator_handle - if (coordinatorHandle && coordinatorHandle !== handle) { - await routeDirectSnapshot(run.id, coordinatorHandle, (throughSequence) => - db.routeUnreadDirectMessagesToRunMailbox(run.id, coordinatorHandle, throughSequence) - ) - } - revalidateLegacyCoordinator?.() - const currentRun = resolveRunScope(runtime, { - runId: run.id, - callerTerminalHandle: handle, - callerPaneKey: paneKey ?? undefined, - requireCurrentConsumer: true, - legacyCoordinatorRunId, - callerEvidence: orchestrationCompatibilityEvidence - }) - if (currentRun.consumer_generation !== generation) { - throw new OrchestrationError( - 'consumer_fenced', - 'This mailbox consumer was replaced while routing pending mail.' - ) - } - - const acknowledged = params.ack - ? db.acknowledgeRunDelivery({ - runId: run.id, - consumerGeneration: generation, - deliveryId: params.ack - }) - : undefined - if (acknowledged) { - recordMutationReceipt?.( - interruptedAcknowledgedCheck(run.id, acknowledged.delivery.id, 'outcome_unknown') - ) - } - if (params.all || (params.unread === false && !params.peek)) { - const history = db.getRunMailboxHistory(run.id, 100, typeFilter) - const messages = history - const result = { - messages, - count: messages.length, - acknowledged: acknowledged?.delivery.id ?? null - } - if (params.format || params.inject) { - return { - ...result, - formatted: messages.map(formatMessageBanner).join('\n\n'), - runId: run.id - } - } - return { ...result, runId: run.id } - } - - const peekResult = (messages: MessageRow[]) => ({ - runId: run.id, - messages, - count: messages.length, - acknowledged: acknowledged?.delivery.id ?? null, - ...(params.format || params.inject - ? { formatted: messages.map(formatMessageBanner).join('\n\n') } - : {}) - }) - const readPeek = () => db.getUnreadRunMailbox(run.id, 100, typeFilter) - const readDelivery = (wakeTypes?: MessageType[]) => - db.getOrCreateRunDelivery({ - runId: run.id, - consumerGeneration: generation, - wakeTypes - }) - let peeked = params.peek ? readPeek() : [] - if (params.peek && peeked.length > 0) { - return peekResult(peeked) - } - let current = params.peek ? undefined : readDelivery(params.wait ? typeFilter : undefined) - if (current) { - return { - runId: run.id, - deliveryId: current.delivery.id, - messages: current.messages, - count: current.messages.length, - replayed: current.replayed, - acknowledged: acknowledged?.delivery.id ?? null, - timedOut: false, - cancelled: false, - connectionLost: false, - ...(params.format || params.inject - ? { formatted: current.messages.map(formatMessageBanner).join('\n\n') } - : {}) - } - } - if (!params.wait) { - if (params.peek) { - return peekResult([]) - } - return { - runId: run.id, - deliveryId: null, - messages: [], - count: 0, - acknowledged: acknowledged?.delivery.id ?? null, - timedOut: false, - cancelled: false, - connectionLost: false - } - } - - const waitResult = await runtime.waitForMessage(address, { - typeFilter: typeFilter as string[] | undefined, - timeoutMs: params.timeoutMs ?? undefined, - signal, - exclusive: true - }) - try { - revalidateLegacyCoordinator?.() - } catch (error) { - if (!acknowledged) { - throw error - } - return interruptedAcknowledgedCheck(run.id, acknowledged.delivery.id, 'consumer_fenced') - } - const latestRun = db.getRun(run.id) - if (!latestRun || latestRun.consumer_generation !== generation) { - if (acknowledged) { - return interruptedAcknowledgedCheck(run.id, acknowledged.delivery.id, 'consumer_fenced') - } - throw new OrchestrationError( - 'consumer_fenced', - 'This mailbox consumer was replaced while waiting.' - ) - } - if (waitResult === 'waiter_exists') { - if (acknowledged) { - return interruptedAcknowledgedCheck(run.id, acknowledged.delivery.id, 'waiter_exists') - } - throw new OrchestrationError( - 'waiter_exists', - `Run ${run.id} already has an active actionable waiter.` - ) - } - if (waitResult === 'timed_out') { - if (params.peek) { - return { ...peekResult([]), timedOut: true, cancelled: false, connectionLost: false } - } - return { - runId: run.id, - deliveryId: null, - messages: [], - count: 0, - acknowledged: acknowledged?.delivery.id ?? null, - timedOut: true, - cancelled: false, - connectionLost: false - } - } - if (waitResult === 'cancelled') { - if (params.peek) { - return { - ...peekResult([]), - timedOut: false, - cancelled: true, - connectionLost: signal?.aborted === true - } - } - return { - runId: run.id, - deliveryId: null, - messages: [], - count: 0, - acknowledged: acknowledged?.delivery.id ?? null, - timedOut: false, - cancelled: true, - connectionLost: signal?.aborted === true - } - } - - if (params.peek) { - peeked = readPeek() - return { - ...peekResult(peeked), - timedOut: false, - cancelled: false, - connectionLost: false - } - } - current = readDelivery(typeFilter) - return { - runId: run.id, - deliveryId: current?.delivery.id ?? null, - messages: current?.messages ?? [], - count: current?.messages.length ?? 0, - replayed: current?.replayed ?? false, - acknowledged: acknowledged?.delivery.id ?? null, - timedOut: false, - cancelled: false, - connectionLost: false, - ...(params.format && current - ? { formatted: current.messages.map(formatMessageBanner).join('\n\n') } - : {}) - } - } - - const activeDispatch = db.getActiveDispatchForIdentity(handle, paneKey ?? undefined) - const remoteAttachment = - !activeDispatch && paneKey ? db.findActiveRemoteAttachmentForPane(paneKey) : undefined - if ( - remoteAttachment && - !db.isRemoteAttachmentProcessCurrent({ - dispatchId: remoteAttachment.dispatch_id, - paneKey: paneKey ?? null, - processIncarnation: runtime.getTerminalProcessIncarnation(handle) - }) - ) { - throw new OrchestrationError( - 'dispatch_inactive', - `Dispatch ${remoteAttachment.dispatch_id} is no longer attached to this worker process.` - ) - } - const workerMailbox = activeDispatch - ? { dispatchId: activeDispatch.id, runId: activeDispatch.run_id } - : remoteAttachment - ? { dispatchId: remoteAttachment.dispatch_id, runId: undefined } - : undefined - if (workerMailbox) { - const address = `dispatch:${workerMailbox.dispatchId}` - const revalidateWorkerMailbox = async (): Promise => { - if (activeDispatch) { - const current = db.getActiveDispatchForIdentity(handle, paneKey ?? undefined) - if (current?.id === activeDispatch.id) { - return - } - } else if (remoteAttachment && paneKey) { - const current = db.findActiveRemoteAttachmentForPane(paneKey) - if ( - current?.dispatch_id === remoteAttachment.dispatch_id && - db.isRemoteAttachmentProcessCurrent({ - dispatchId: current.dispatch_id, - paneKey, - processIncarnation: runtime.getTerminalProcessIncarnation(handle) - }) - ) { - return - } - } - const latestDispatch = db.getDispatchContextById(workerMailbox.dispatchId) - const owningRunId = - latestDispatch?.run_id ?? activeDispatch?.run_id ?? workerMailbox.runId - if ( - owningRunId && - (!latestDispatch || - (latestDispatch.status !== 'pending' && latestDispatch.status !== 'dispatched')) - ) { - const throughSequence = db.getLatestUnreadMessageSequence(address) - if (throughSequence !== undefined) { - const routedTypes = new Set() - const routePage = (): { routedCount: number; hasMore: boolean } => { - const routed = db.routeUnreadDispatchMailboxToRunMailbox( - workerMailbox.dispatchId, - owningRunId, - throughSequence - ) - for (const routedType of routed.types) { - routedTypes.add(routedType) - } - return routed - } - const notifyRoutedTypes = (): void => { - for (const routedType of routedTypes) { - runtime.notifyMessageArrived(`run:${owningRunId}`, routedType) - } - routedTypes.clear() - } - try { - await routeAllMailboxPages(routePage, signal) - } catch (error) { - notifyRoutedTypes() - if (error instanceof OrchestrationError && error.code === 'request_aborted') { - setImmediate(() => { - void routeAllMailboxPages(routePage) - .catch(() => undefined) - .finally(notifyRoutedTypes) - }) - } - throw error - } - notifyRoutedTypes() - } - } - throw new OrchestrationError( - 'dispatch_inactive', - `Dispatch ${workerMailbox.dispatchId} is no longer assigned to this worker.` - ) - } - if (activeDispatch) { - await routeDirectSnapshot(activeDispatch.run_id, handle, (throughSequence) => - db.routeUnreadDirectMessagesToDispatchMailbox( - activeDispatch.id, - activeDispatch.run_id, - handle, - throughSequence - ) - ) - const assigneeHandle = activeDispatch.assignee_handle - if (assigneeHandle && assigneeHandle !== handle) { - await routeDirectSnapshot(activeDispatch.run_id, assigneeHandle, (throughSequence) => - db.routeUnreadDirectMessagesToDispatchMailbox( - activeDispatch.id, - activeDispatch.run_id, - assigneeHandle, - throughSequence - ) - ) - } - } - await revalidateWorkerMailbox() - const showAll = params.all === true || (params.unread === false && params.peek !== true) - const messages = showAll - ? db.getAllMessagesForHandle(address, 100, typeFilter) - : db.getUnreadMessages(address, typeFilter) - if (!showAll && params.peek !== true && messages.length > 0) { - db.markAsRead(messages.map((message) => message.id)) - } - if (messages.length > 0 || !params.wait) { - return { - ...(workerMailbox.runId ? { runId: workerMailbox.runId } : {}), - dispatchId: workerMailbox.dispatchId, - messages, - count: messages.length, - ...(params.format || params.inject - ? { formatted: messages.map(formatMessageBanner).join('\n\n') } - : {}) - } - } - const waitResult = await runtime.waitForMessage(address, { - typeFilter: typeFilter as string[] | undefined, - timeoutMs: params.timeoutMs ?? undefined, - signal - }) - await revalidateWorkerMailbox() - if (waitResult === 'timed_out' || waitResult === 'cancelled') { - return { - ...(workerMailbox.runId ? { runId: workerMailbox.runId } : {}), - dispatchId: workerMailbox.dispatchId, - messages: [], - count: 0, - timedOut: waitResult === 'timed_out', - cancelled: waitResult === 'cancelled', - connectionLost: waitResult === 'cancelled' && signal?.aborted === true - } - } - const arrived = db.getUnreadMessages(address, typeFilter) - db.markAsRead(arrived.map((message) => message.id)) - return { - ...(workerMailbox.runId ? { runId: workerMailbox.runId } : {}), - dispatchId: workerMailbox.dispatchId, - messages: arrived, - count: arrived.length, - ...(params.format || params.inject - ? { formatted: arrived.map(formatMessageBanner).join('\n\n') } - : {}) - } - } - - // Why: unread:false is honored for one release as a compat shim so in-flight callers don't break (design doc §5). - const showAll = params.all === true || (params.unread === false && params.peek !== true) - const consumeUnread = !showAll && params.peek !== true - - const readAndReturn = () => { - const messages = showAll - ? db.getAllMessagesForHandle(handle, undefined, typeFilter) - : db.getUnreadMessages(handle, typeFilter) - - if ( - consumeUnread && - messages.some((message) => message.run_id === ORCHESTRATION_LEGACY_RUN_ID) - ) { - throw new OrchestrationError( - 'legacy_read_only', - 'Legacy orchestration messages are inspect-only; use --peek or --all. No acknowledgment was applied.', - { effectsApplied: false } - ) - } - - let visibleMessages = messages - if (consumeUnread && messages.length > 0) { - // Why: unread check is an authoritative read path for worker_done/heartbeat, so reconcile lifecycle messages here too. - visibleMessages = messages.map((message) => { - const reconciled = reconcileLifecycleMessage(db, message) - return reconciled.action === 'rejected' - ? (db.getMessageById(message.id) ?? message) - : message - }) - db.markAsRead(messages.map((m) => m.id)) - } - - if (params.format || params.inject) { - const formatted = visibleMessages.map(formatMessageBanner).join('\n\n') - return { messages: visibleMessages, formatted, count: visibleMessages.length } - } - - return { messages: visibleMessages, count: visibleMessages.length } - } - - if (signal?.aborted) { - return { messages: [], count: 0 } - } - const result = readAndReturn() - if (result.count > 0 || !params.wait) { - return result - } - - // Why: signal aborts this waiter when the client socket closes, freeing the long-poll slot immediately rather than after timeoutMs (design doc §3.1). - const waitResult = await runtime.waitForMessage(handle, { - typeFilter: typeFilter as string[] | undefined, - timeoutMs: params.timeoutMs ?? undefined, - signal - }) - if (signal?.aborted) { - return { messages: [], count: 0 } - } - if (waitResult === 'cancelled') { - throw new OrchestrationError( - 'consumer_fenced', - 'This direct mailbox became owned by a Run while the check was waiting.' - ) - } - return readAndReturn() - } - }), - - defineMethod({ - name: 'orchestration.reply', - params: ReplyParams, - handler: async ( - params, - { orchestrationCompatibilityEvidence, runtime, legacyCoordinatorRunId } - ) => { - const db = runtime.getOrchestrationDb() - const original = db.getMessageById(params.id) - if (!original) { - throw new Error(`Message not found: ${params.id}`) - } - if ( - legacyCoordinatorRunId && - (original.run_id !== legacyCoordinatorRunId || - (params.run !== undefined && params.run !== legacyCoordinatorRunId)) - ) { - throw new OrchestrationError( - 'request_mismatch', - `Message ${params.id} does not belong to this adopted Run.`, - { effectsApplied: false } - ) - } - if ( - original.run_id === ORCHESTRATION_LEGACY_RUN_ID || - original.delivery_contract === 'legacy_direct' || - original.delivery_contract === 'audit_only' - ) { - throw new OrchestrationError( - 'legacy_read_only', - 'Legacy orchestration messages are inspect-only; no reply was applied.', - { effectsApplied: false } - ) - } - - const question = db.getQuestion(params.id) - if (question) { - const run = resolveRunScope(runtime, { - runId: params.run ?? question.run_id, - callerTerminalHandle: params.from, - requireCurrentConsumer: true, - legacyCoordinatorRunId, - callerEvidence: orchestrationCompatibilityEvidence - }) - const answered = db.answerQuestion({ - messageId: question.message_id, - runId: run.id, - consumerGeneration: run.consumer_generation, - body: params.body - }) - const federated = db.getFederatedDispatch(question.dispatch_id) - if (federated) { - db.enqueueFederationRelay({ - dispatchId: question.dispatch_id, - direction: 'to_worker', - kind: 'reply', - payload: JSON.stringify({ - questionId: question.message_id, - answerMessageId: answered.message.id, - body: params.body - }) - }) - runtime.ensureOrchestrationFederationRelay(run.id) - } else { - runtime.notifyMessageArrived(`dispatch:${question.dispatch_id}`, 'status') - } - return { - message: answered.message, - question: answered.question, - duplicate: answered.duplicate - } - } - - db.markAsRead([original.id]) - - const reply = db.insertMessage({ - from: params.from ?? original.to_handle, - to: original.from_handle, - subject: `Re: ${original.subject}`, - body: params.body, - threadId: original.thread_id ?? original.id, - runId: original.run_id - }) - - runtime.notifyMessageArrived(reply.to_handle, reply.type) - return { message: reply } - } - }), - - defineMethod({ - name: 'orchestration.inbox', - params: InboxParams, - handler: (params, { runtime }) => { - const db = runtime.getOrchestrationDb() - // Why: stale/unknown handles return empty rather than error — historical rows survive handle deletion (design doc §3.3). - const messages = params.terminal - ? db.getAllMessagesForHandle(params.terminal, params.limit) - : db.getInbox(params.limit) - return { messages, count: messages.length } - } - }), - - defineMethod({ - name: 'orchestration.taskCreate', - params: TaskCreateParams, - handler: (params, { orchestrationCompatibilityEvidence, runtime, legacyCoordinatorRunId }) => { - const db = runtime.getOrchestrationDb() - const deps = params.deps ? parseOrchestrationTaskDepsFlag(params.deps) : undefined - const run = resolveRunScope(runtime, { - runId: params.run, - callerTerminalHandle: params.callerTerminalHandle, - requireCurrentConsumer: true, - legacyCoordinatorRunId, - callerEvidence: orchestrationCompatibilityEvidence - }) - const creatorAuthority = params.callerTerminalHandle - ? runtime.getOrchestrationDispatchAuthority(params.callerTerminalHandle) - : null - const task = db.createTask({ - spec: params.spec, - taskTitle: params.taskTitle, - displayName: params.displayName, - deps, - parentId: params.parent, - createdByTerminalHandle: params.callerTerminalHandle, - ...(creatorAuthority?.paneKey && creatorAuthority.processIncarnation - ? { - createdByPaneKey: creatorAuthority.paneKey, - createdByProcessIncarnation: creatorAuthority.processIncarnation, - createdByRunGeneration: run.consumer_generation - } - : {}), - runId: run.id - }) - return { task } - } - }), - - defineMethod({ - name: 'orchestration.taskList', - params: TaskListParams, - handler: (params, { orchestrationCompatibilityEvidence, runtime, legacyCoordinatorRunId }) => { - const db = runtime.getOrchestrationDb() - const explicitRun = params.run ? db.getRun(params.run) : undefined - const run = - explicitRun?.legacy === 1 - ? explicitRun - : resolveRunScope(runtime, { - runId: params.run, - callerTerminalHandle: params.callerTerminalHandle, - requireCurrentConsumer: params.run === undefined, - legacyCoordinatorRunId, - callerEvidence: orchestrationCompatibilityEvidence - }) - // Why: listTasksWithDispatch adds assignee_handle + dispatch_id (NULL for non-dispatched), so legacy-shape consumers are unaffected. - const joined = db.listTasksWithDispatch({ - status: params.status as TaskStatus, - ready: params.ready, - runId: run.id - }) - const tasks = joined.map((row) => { - const { assignee_handle, dispatch_id, ...base } = row - if (base.status === 'dispatched') { - return { ...base, assignee_handle, dispatch_id } - } - return base - }) - return { - runId: run.id, - legacyReadOnly: run.legacy === 1, - tasks: params.brief ? abbreviateOrchestrationTasks(tasks) : tasks, - count: tasks.length - } - } - }), - - defineMethod({ - name: 'orchestration.taskUpdate', - params: TaskUpdateParams, - handler: (params, { orchestrationCompatibilityEvidence, runtime, legacyCoordinatorRunId }) => { - const db = runtime.getOrchestrationDb() - const run = resolveRunScope(runtime, { - runId: params.run, - callerTerminalHandle: params.callerTerminalHandle, - requireCurrentConsumer: true, - legacyCoordinatorRunId, - callerEvidence: orchestrationCompatibilityEvidence - }) - const existing = db.getTask(params.id) - if (!existing || existing.run_id !== run.id) { - throw new OrchestrationError( - 'task_not_found', - `Task ${params.id} was not found in Run ${run.id}.` - ) - } - const task = db.updateTaskStatus(params.id, params.status, params.result) - if (!task) { - throw new Error(`Task not found: ${params.id}`) - } - return { task } - } - }), - - defineMethod({ - name: 'orchestration.dispatch', - params: DispatchParams, - handler: async ( - params, - { - orchestrationCompatibilityEvidence, - runtime, - legacyCoordinatorRunId, - revalidateLegacyCoordinator - } - ) => { - const db = runtime.getOrchestrationDb() - const task = db.getTask(params.task) - if (!task) { - throw new Error(`Task not found: ${params.task}`) - } - const run = resolveRunScope(runtime, { - runId: params.run, - callerTerminalHandle: params.from, - requireCurrentConsumer: true, - legacyCoordinatorRunId, - callerEvidence: orchestrationCompatibilityEvidence - }) - if (task.run_id !== run.id) { - throw new OrchestrationError( - 'task_not_found', - `Task ${task.id} was not found in Run ${run.id}.` - ) - } - - // Why: dry-run previews the preamble without mutating state, so it skips the ready-status check and uses a placeholder dispatchId. - if (params.dryRun) { - const maxDepth = runtime.getNestedWorkerMaxDepth() - const previewDepth = db.resolveChildDispatchDepth( - resolveDispatchCreator(runtime, params.from), - maxDepth - ) - const preamble = buildDispatchPreamble({ - taskId: task.id, - dispatchId: 'ctx_dryrun', - canDispatchSubWorkers: previewDepth < maxDepth, - taskSpec: task.spec, - coordinatorHandle: params.from ?? 'coordinator', - workerHandle: params.to ?? 'worker', - devMode: params.devMode, - ...(params.to - ? { cliCommand: runtime.getTerminalOrchestrationCliCommand(params.to) } - : {}) - }) - return { dispatch: null, injected: false, dryRun: true, preamble } - } - - if (!params.to) { - throw new Error('Missing --to') - } - const to = params.to - - if (task.status !== 'ready') { - throw new Error(`Task ${params.task} is ${task.status}; only ready tasks can be dispatched`) - } - - // Why: injecting the preamble into a bare shell dumps it as shell commands (gibberish), so require a detected agent first. - if (params.inject) { - const hasAgent = await runtime.isTerminalRunningAgent(to) - if (!hasAgent) { - throw new Error(buildInjectRejectionMessage(to)) - } - } - - const dispatchAuthority = runtime.getOrchestrationDispatchAuthority(to) - const assigneePaneKey = - dispatchAuthority?.paneKey ?? runtime.getTerminalPaneKey(to) ?? undefined - const processIncarnation = - dispatchAuthority?.paneKey && dispatchAuthority.processIncarnation - ? dispatchAuthority.processIncarnation - : undefined - if (params.inject && (!assigneePaneKey || !processIncarnation)) { - throw new OrchestrationError( - 'stable_pane_required', - `Terminal ${to} has no stable pane/process incarnation for lifecycle authority.` - ) - } - - revalidateLegacyCoordinator?.() - const ctx = db.createDispatchContext({ - taskId: params.task, - assigneeHandle: to, - assigneePaneKey, - launchTokenHash: dispatchAuthority?.launchTokenHash ?? undefined, - processIncarnation, - creator: resolveDispatchCreator(runtime, params.from), - maxDepth: runtime.getNestedWorkerMaxDepth() - }) - const dispatchCapability = params.inject - ? db.mintDispatchCapability({ - dispatchId: ctx.id, - paneKey: assigneePaneKey as string, - processIncarnation: processIncarnation as string - }) - : undefined - - // Why: built after ctx so dispatchId is the real ctx.id, letting heartbeats attribute liveness to a specific dispatch context, not just a task. - const preamble = buildDispatchPreamble({ - taskId: task.id, - dispatchId: ctx.id, - canDispatchSubWorkers: ctx.depth < runtime.getNestedWorkerMaxDepth(), - taskSpec: task.spec, - coordinatorHandle: params.from ?? 'coordinator', - workerHandle: to, - dispatchCapability, - devMode: params.devMode, - cliCommand: runtime.getTerminalOrchestrationCliCommand(to) - }) - - let injected = false - if (params.inject) { - try { - await runtime.sendTerminalAgentPrompt(to, preamble) - injected = true - } catch (err) { - db.failDispatch(ctx.id, err instanceof Error ? err.message : String(err)) - throw err - } - } - - // Why: returnPreamble is opt-in because the preamble is several hundred bytes most callers don't need in the response. - if (params.returnPreamble) { - return { dispatch: ctx, injected, preamble } - } - return { dispatch: ctx, injected } - } - }), - - defineMethod({ - name: 'orchestration.dispatchShow', - params: DispatchShowParams, - handler: (params, { runtime }) => { - const db = runtime.getOrchestrationDb() - if (!params.task) { - throw new Error('Missing --task') - } - const ctx = db.getDispatchContext(params.task) - - // Why: the preamble is derived from the current task spec, so it can be regenerated deterministically even after dispatch completes. - if (params.preamble) { - const task = db.getTask(params.task) - if (!task) { - throw new Error(`Task not found: ${params.task}`) - } - const workerHandle = ctx?.assignee_handle ?? 'worker' - const preamble = buildDispatchPreamble({ - taskId: task.id, - // Why: use the real ctx.id when present so the preview matches what was injected; placeholder when no dispatch has occurred yet. - dispatchId: ctx?.id ?? 'ctx_preview', - canDispatchSubWorkers: (ctx?.depth ?? 1) < runtime.getNestedWorkerMaxDepth(), - taskSpec: task.spec, - coordinatorHandle: params.from ?? 'coordinator', - workerHandle, - devMode: params.devMode, - ...(ctx ? { cliCommand: runtime.getTerminalOrchestrationCliCommand(workerHandle) } : {}) - }) - return { dispatch: ctx ?? null, preamble } - } - - return { dispatch: ctx ?? null } - } - }), - - defineMethod({ - name: 'orchestration.ask', - params: AskParams, - handler: async ( - params, - { runtime, signal, orchestrationCapability, recordMutationReceipt } - ) => { - // Why: group addresses have no unambiguous first-answer authority. - if (params.to && isGroupAddress(params.to)) { - throw new Error( - 'ask does not support group addresses; use send for non-blocking fan-out questions' - ) - } - - const db = runtime.getOrchestrationDb() - const from = params.from ?? 'unknown' - // Why: echoed on every return so a clamped caller reports the budget actually waited, not the one it asked for. - const timeoutMs = clampOrchestrationAskTimeoutMs(params.timeoutMs) - const paneKey = runtime.getTerminalPaneKey(from) ?? undefined - const remoteAttachment = paneKey ? db.findActiveRemoteAttachmentForPane(paneKey) : undefined - if (remoteAttachment) { - rejectFederatedExplicitTarget(params) - return askRemoteRunHome({ - params: { ...params, timeoutMs }, - runtime, - signal, - orchestrationCapability, - recordMutationReceipt, - from, - paneKey: paneKey as string, - dispatchId: remoteAttachment.dispatch_id, - taskId: remoteAttachment.task_id - }) - } - const activeDispatch = db.getActiveDispatchForIdentity(from, paneKey) - if (!activeDispatch) { - throw new OrchestrationError( - 'dispatch_inactive', - 'ask requires an active supervised Dispatch.' - ) - } - if (activeDispatch.capability_hash) { - const authority = db.verifyDispatchCapability({ - dispatchId: activeDispatch.id, - capability: orchestrationCapability, - paneKey, - processIncarnation: runtime.getTerminalProcessIncarnation(from) ?? undefined - }) - if (!authority.valid) { - throw new OrchestrationError('dispatch_capability_invalid', authority.reason) - } - } - const options = - params.options - ?.split(',') - .map((s) => s.trim()) - .filter(Boolean) ?? [] - let question = params.resume ? db.getQuestion(params.resume) : undefined - if (params.resume) { - if (!question || question.dispatch_id !== activeDispatch.id) { - throw new OrchestrationError( - 'question_not_found', - `Question ${params.resume} does not belong to this active Dispatch.` - ) - } - } else { - const run = db.getRun(activeDispatch.run_id) - if (!run || run.legacy === 1) { - throw new OrchestrationError( - 'run_not_found', - `Run ${activeDispatch.run_id} was not found.` - ) - } - if (params.run && params.run !== run.id) { - throw new OrchestrationError( - 'dispatch_run_mismatch', - `Dispatch ${activeDispatch.id} belongs to Run ${run.id}, not ${params.run}.` - ) - } - if (params.to && params.to !== `run:${run.id}` && params.to !== run.coordinator_handle) { - throw new OrchestrationError( - 'dispatch_run_mismatch', - `ask from Dispatch ${activeDispatch.id} must target its owning Run ${run.id}.` - ) - } - const created = db.createQuestion({ - runId: run.id, - dispatchId: activeDispatch.id, - askerHandle: from, - question: params.question as string, - options - }) - question = created.question - runtime.notifyMessageArrived(`run:${run.id}`, created.message.type) - } - - const questionId = question.message_id - recordMutationReceipt?.({ - accepted: true, - answer: null, - messageId: questionId, - threadId: questionId, - timedOut: false, - cancelled: false, - connectionLost: false, - timeoutMs - }) - const deadline = Date.now() + timeoutMs - while (true) { - const current = db.getQuestion(questionId) - if (!current || current.status === 'closed') { - throw new OrchestrationError( - 'dispatch_inactive', - `Question ${questionId} closed because its Dispatch is inactive.` - ) - } - if (current.status === 'answered') { - return { - answer: current.answer_body, - messageId: questionId, - answerMessageId: current.answer_message_id, - threadId: questionId, - timedOut: false, - cancelled: false, - connectionLost: false, - timeoutMs - } - } - if (signal?.aborted) { - return { - answer: null, - messageId: questionId, - threadId: questionId, - timedOut: false, - cancelled: true, - connectionLost: true, - timeoutMs - } - } - const remainingMs = deadline - Date.now() - if (remainingMs <= 0) { - return { - answer: null, - messageId: questionId, - threadId: questionId, - timedOut: true, - cancelled: false, - connectionLost: false, - timeoutMs - } - } - await runtime.waitForMessage(`dispatch:${activeDispatch.id}`, { - timeoutMs: remainingMs, - signal - }) - } - } - }), - + ...ORCHESTRATION_SEND_METHODS, + ...ORCHESTRATION_CHECK_METHODS, + ...ORCHESTRATION_MESSAGE_METHODS, + ...ORCHESTRATION_DISPATCH_METHODS, + ...ORCHESTRATION_ASK_METHODS, ...ORCHESTRATION_GATE_METHODS, - - defineMethod({ - name: 'orchestration.reset', - params: ResetParams, - handler: (params, { runtime }) => { - const db = runtime.getOrchestrationDb() - if (params.all) { - runtime.stopOrchestrationFederationRelay() - db.resetAll() - return { reset: 'all' } - } - if (params.tasks) { - runtime.stopOrchestrationFederationRelay() - db.resetTasks() - return { reset: 'tasks' } - } - if (params.messages) { - db.resetMessages() - return { reset: 'messages' } - } - throw new Error('Invalid reset scope') - } - }) + ...ORCHESTRATION_RESET_METHODS ] - -async function askRemoteRunHome(args: { - params: z.infer - runtime: OrcaRuntimeService - signal?: AbortSignal - orchestrationCapability?: string - recordMutationReceipt?: (receipt: unknown) => void - from: string - paneKey: string - dispatchId: string - taskId: string -}): Promise { - const db = args.runtime.getOrchestrationDb() - const timeoutMs = clampOrchestrationAskTimeoutMs(args.params.timeoutMs) - if ( - !db.verifyRemoteAttachmentAuthority({ - dispatchId: args.dispatchId, - capability: args.orchestrationCapability, - paneKey: args.paneKey, - processIncarnation: args.runtime.getTerminalProcessIncarnation(args.from) - }) - ) { - throw new OrchestrationError( - 'dispatch_capability_invalid', - 'The remote Dispatch capability or exact worker process is invalid.' - ) - } - const options = - args.params.options - ?.split(',') - .map((option) => option.trim()) - .filter(Boolean) ?? [] - let questionId = args.params.resume - if (questionId) { - const existing = db.getRemoteQuestion(questionId) - if (!existing || existing.dispatch_id !== args.dispatchId) { - throw new OrchestrationError( - 'question_not_found', - `Question ${questionId} does not belong to this remote Dispatch.` - ) - } - } else { - const relay = db.enqueueFederationRelay({ - dispatchId: args.dispatchId, - direction: 'to_home', - kind: 'question', - payload: JSON.stringify({ - from: args.from, - subject: 'Question', - body: args.params.question as string, - type: 'question', - priority: 'normal', - threadId: null, - payload: JSON.stringify({ - taskId: args.taskId, - dispatchId: args.dispatchId, - question: args.params.question, - options - }) - }), - remoteQuestion: true - }) - questionId = relay.message_id - } - args.recordMutationReceipt?.({ - accepted: true, - answer: null, - messageId: questionId, - threadId: questionId, - timedOut: false, - cancelled: false, - connectionLost: false, - timeoutMs - }) - const deadline = Date.now() + timeoutMs - while (true) { - const question = db.getRemoteQuestion(questionId) - if (!question || question.status === 'closed') { - throw new OrchestrationError( - 'dispatch_inactive', - `Question ${questionId} closed because its remote Dispatch is inactive.` - ) - } - if (question.status === 'answered') { - return { - answer: question.answer_body, - messageId: questionId, - answerMessageId: question.answer_message_id, - threadId: questionId, - timedOut: false, - cancelled: false, - connectionLost: false, - timeoutMs - } - } - if (args.signal?.aborted) { - return { - answer: null, - messageId: questionId, - threadId: questionId, - timedOut: false, - cancelled: true, - connectionLost: true, - timeoutMs - } - } - const remainingMs = deadline - Date.now() - if (remainingMs <= 0) { - return { - answer: null, - messageId: questionId, - threadId: questionId, - timedOut: true, - cancelled: false, - connectionLost: false, - timeoutMs - } - } - await args.runtime.waitForMessage(`dispatch:${args.dispatchId}`, { - timeoutMs: remainingMs, - signal: args.signal - }) - } -} diff --git a/src/main/runtime/rpc/orchestration-legacy-coordinator-race.test.ts b/src/main/runtime/rpc/orchestration-legacy-coordinator-race.test.ts index 9236d643e40..3040c37a9ef 100644 --- a/src/main/runtime/rpc/orchestration-legacy-coordinator-race.test.ts +++ b/src/main/runtime/rpc/orchestration-legacy-coordinator-race.test.ts @@ -656,9 +656,21 @@ describe('legacy coordinator takeover races', () => { const detectionStarted = new Promise((resolve) => { signalDetectionStarted = resolve }) + let detectionCalls = 0 vi.spyOn(harness.runtime, 'isTerminalRunningAgent').mockImplementation( () => - new Promise((resolve) => { + new Promise((resolve, reject) => { + detectionCalls += 1 + // Why reject instead of re-arming: a second call would overwrite resolveDetection and + // strand the first promise, hanging to a timeout instead of naming what changed. + if (detectionCalls > 1) { + reject( + new Error( + `isTerminalRunningAgent was called ${detectionCalls} times; this test drives exactly one detection.` + ) + ) + return + } resolveDetection = resolve signalDetectionStarted?.() }) diff --git a/src/main/runtime/rpc/relay-transport.test.ts b/src/main/runtime/rpc/relay-transport.test.ts index 8b7303ed805..21b520c8c9e 100644 --- a/src/main/runtime/rpc/relay-transport.test.ts +++ b/src/main/runtime/rpc/relay-transport.test.ts @@ -28,7 +28,8 @@ describe('CloudRelayTransport', () => { }) it('authenticates one query-free host-data socket and forwards messages verbatim', async () => { - const server = new WebSocketServer({ port: 0, perMessageDeflate: false }) + // host must match the 127.0.0.1 clients dial: a wildcard bind lets a foreign loopback listener claim the port and answer here. + const server = new WebSocketServer({ host: '127.0.0.1', port: 0, perMessageDeflate: false }) servers.push(server) await new Promise((resolve) => server.once('listening', resolve)) const address = server.address() diff --git a/src/main/runtime/rpc/remote-runtime-server-heartbeat-missed-probe-tolerance.test.ts b/src/main/runtime/rpc/remote-runtime-server-heartbeat-missed-probe-tolerance.test.ts index 665d94b0f41..9b52981d1f5 100644 --- a/src/main/runtime/rpc/remote-runtime-server-heartbeat-missed-probe-tolerance.test.ts +++ b/src/main/runtime/rpc/remote-runtime-server-heartbeat-missed-probe-tolerance.test.ts @@ -19,7 +19,12 @@ describe('RemoteRuntimeServerHeartbeat missed-probe tolerance', () => { const socket = makeSocket() const heartbeat = new RemoteRuntimeServerHeartbeat(INTERVAL_MS, () => now) heartbeat.noteAlive(socket) - heartbeat.start(() => [socket]) // probe #1 + heartbeat.start(() => [socket]) + + // Probe #1 goes out on the first interval tick. + now += INTERVAL_MS + await vi.advanceTimersByTimeAsync(INTERVAL_MS) + expect(socket.ping).toHaveBeenCalledTimes(1) heartbeat.noteAlive(socket) // pongs probe #1 // A transient blackhole: probe #2 goes out and its pong is stuck in the network. @@ -47,6 +52,9 @@ describe('RemoteRuntimeServerHeartbeat missed-probe tolerance', () => { const heartbeat = new RemoteRuntimeServerHeartbeat(INTERVAL_MS, () => now) heartbeat.noteAlive(socket) heartbeat.start(() => [socket]) + now += INTERVAL_MS + await vi.advanceTimersByTimeAsync(INTERVAL_MS) + expect(socket.ping).toHaveBeenCalledTimes(1) heartbeat.noteAlive(socket) const missesBeforeReap: number[] = [] @@ -72,6 +80,9 @@ describe('RemoteRuntimeServerHeartbeat missed-probe tolerance', () => { const heartbeat = new RemoteRuntimeServerHeartbeat(INTERVAL_MS, () => now) heartbeat.noteAlive(socket) heartbeat.start(() => [socket]) + now += INTERVAL_MS + await vi.advanceTimersByTimeAsync(INTERVAL_MS) + expect(socket.ping).toHaveBeenCalledTimes(1) heartbeat.noteAlive(socket) // Two silent probes, then a single inbound frame, repeated well past any fixed budget. diff --git a/src/main/runtime/rpc/remote-runtime-server-heartbeat.test.ts b/src/main/runtime/rpc/remote-runtime-server-heartbeat.test.ts index e1a58d23587..5965da30e6c 100644 --- a/src/main/runtime/rpc/remote-runtime-server-heartbeat.test.ts +++ b/src/main/runtime/rpc/remote-runtime-server-heartbeat.test.ts @@ -7,6 +7,20 @@ afterEach(() => { }) describe('RemoteRuntimeServerHeartbeat', () => { + it('defers the first probe until after the WebSocket handshake window', async () => { + vi.useFakeTimers() + const socket = { ping: vi.fn(), terminate: vi.fn() } as unknown as WebSocket + const heartbeat = new RemoteRuntimeServerHeartbeat(100) + heartbeat.noteAlive(socket) + heartbeat.start(() => [socket]) + + expect(socket.ping).not.toHaveBeenCalled() + + await vi.advanceTimersByTimeAsync(100) + expect(socket.ping).toHaveBeenCalledTimes(1) + heartbeat.stop() + }) + it('still reaps a persistently silent client while another remains alive', async () => { vi.useFakeTimers() let now = 1_000 @@ -16,19 +30,23 @@ describe('RemoteRuntimeServerHeartbeat', () => { const heartbeat = new RemoteRuntimeServerHeartbeat(100, () => now, 128, 2) heartbeat.noteAlive(responsiveSocket) heartbeat.noteAlive(deadSocket) - // start() probes immediately: both are pinged now (probe #1) and cleared to await a pong. heartbeat.start(() => [responsiveSocket, deadSocket]) - // Only the responsive socket pongs the immediate probe. + expect(responsiveSocket.ping).not.toHaveBeenCalled() + expect(deadSocket.ping).not.toHaveBeenCalled() + + // Tick #1: probe #1 goes out to both. + now += 100 + await vi.advanceTimersByTimeAsync(100) heartbeat.noteAlive(responsiveSocket) - // Miss #1: not yet evidence, so the silent socket is re-probed rather than reaped. + // Tick #2: miss #1 is not yet evidence, so the silent socket is re-probed. now += 100 await vi.advanceTimersByTimeAsync(100) heartbeat.noteAlive(responsiveSocket) expect(deadSocket.ping).toHaveBeenCalledTimes(2) expect(deadSocket.terminate).not.toHaveBeenCalled() - // Miss #2 reaches the limit: consecutive silence is evidence. + // Tick #3: miss #2 reaches the limit: consecutive silence is evidence. now += 100 await vi.advanceTimersByTimeAsync(100) @@ -46,8 +64,9 @@ describe('RemoteRuntimeServerHeartbeat', () => { // Limit of 1 isolates the resume grant: without it the very next sweep would reap. const heartbeat = new RemoteRuntimeServerHeartbeat(100, () => now, 128, 1) heartbeat.noteAlive(socket) - // start() probes immediately (ping #1); the socket pongs it. heartbeat.start(() => [socket]) + now += 100 + await vi.advanceTimersByTimeAsync(100) // ping #1, socket pongs heartbeat.noteAlive(socket) now += 100 diff --git a/src/main/runtime/rpc/remote-runtime-server-heartbeat.ts b/src/main/runtime/rpc/remote-runtime-server-heartbeat.ts index 6134259c51f..4bce033a67a 100644 --- a/src/main/runtime/rpc/remote-runtime-server-heartbeat.ts +++ b/src/main/runtime/rpc/remote-runtime-server-heartbeat.ts @@ -2,8 +2,8 @@ import type { WebSocket } from 'ws' // Why: one unanswered probe is UNKNOWN, not death — a cellular/Tailscale blackhole or a stalled TCP // retransmit routinely swallows a single pong from a peer that is still there (STA-3320). Only a run of -// consecutive unanswered probes is evidence. Three matches the web client's own 45s liveness budget -// (25s idle + 20s probe grace), so both ends of a paired session give up on roughly the same evidence. +// consecutive unanswered probes is evidence. Three matches the web client's liveness budget, so both +// ends of a paired session give up on roughly the same evidence after the initial grace interval. const MISSED_PROBE_LIMIT = 3 export class RemoteRuntimeServerHeartbeat { @@ -33,11 +33,6 @@ export class RemoteRuntimeServerHeartbeat { this.lastTickAt = this.now() this.timer = setInterval(() => this.sweep(getClients()), this.intervalMs) this.timer.unref?.() - // Why: the interval's first tick is a full intervalMs (~15s) out, so arming on the first accepted - // connection would leave that socket unprobed for the whole window. Sweep once now so the first - // liveness ping goes out immediately; seeded-alive sockets are pinged (not reaped) and have until - // the next tick to pong. WS pong is answered at the protocol level, so a live socket always survives. - this.sweep(getClients()) } stop(): void { diff --git a/src/main/runtime/rpc/ws-transport-accept-order.test.ts b/src/main/runtime/rpc/ws-transport-accept-order.test.ts index 20ae28f0f0e..3f8d944acad 100644 --- a/src/main/runtime/rpc/ws-transport-accept-order.test.ts +++ b/src/main/runtime/rpc/ws-transport-accept-order.test.ts @@ -63,13 +63,14 @@ describe('WebSocketTransport accepted socket ordering', () => { socket.once('open', () => events.push('open')) socket.emit('open') lifecycle.handleConnection(socket) + transport.setClientId(socket, 'client') await vi.advanceTimersByTimeAsync(100) expect(firstProbeListeners).toEqual({ pong: 1, message: 1, close: 1, error: 1 }) expect(events.slice(0, 4)).toEqual(['open', 'ping', 'ready', 'pong']) expect(socket.terminate).not.toHaveBeenCalled() expect(lifecycle.heartbeatConnections.size).toBe(1) - expect(vi.getTimerCount()).toBe(2) + expect(vi.getTimerCount()).toBe(1) events.push('close') socket.emit('close') @@ -94,15 +95,17 @@ describe('WebSocketTransport accepted socket ordering', () => { socket.emit('close') } }) - const { lifecycle } = createHarness(socket) + const { lifecycle, transport } = createHarness(socket) socket.once('open', () => events.push('open')) socket.emit('open') lifecycle.handleConnection(socket) + transport.setClientId(socket, 'client') + expect(socket.terminate).not.toHaveBeenCalled() // A single silent interval is not evidence: the socket is re-probed, not reaped. await vi.advanceTimersByTimeAsync(100) expect(socket.terminate).not.toHaveBeenCalled() - await vi.advanceTimersByTimeAsync(199) + await vi.advanceTimersByTimeAsync(299) expect(socket.terminate).not.toHaveBeenCalled() await vi.advanceTimersByTimeAsync(1) @@ -125,11 +128,12 @@ describe('WebSocketTransport accepted socket ordering', () => { firstSocket.emit('pong') } }) - const { lifecycle } = createHarness(firstSocket) + const { lifecycle, transport } = createHarness(firstSocket) lifecycle.handleConnection(firstSocket) + transport.setClientId(firstSocket, 'first-client') const sharedTimer = lifecycle.heartbeat.timer - expect(firstPingTimes).toEqual([0]) + expect(firstPingTimes).toEqual([]) await vi.advanceTimersByTimeAsync(50) const laterPingTimes: number[] = [] @@ -147,21 +151,25 @@ describe('WebSocketTransport accepted socket ordering', () => { expect(lifecycle.heartbeat.timer).toBe(sharedTimer) expect(laterPingTimes).toEqual([]) - expect(vi.getTimerCount()).toBe(3) + expect(vi.getTimerCount()).toBe(2) await vi.advanceTimersByTimeAsync(50) - expect(laterPingTimes).toEqual([100]) + expect(laterPingTimes).toEqual([]) expect(laterSocket.terminate).not.toHaveBeenCalled() + transport.setClientId(laterSocket, 'later-client') + await vi.advanceTimersByTimeAsync(100) + expect(laterPingTimes).toEqual([200]) + // Re-probed on every sweep while its misses bank, so a recovered path can answer immediately. await vi.advanceTimersByTimeAsync(299) - expect(laterPingTimes).toEqual([100, 200, 300]) + expect(laterPingTimes).toEqual([200, 300, 400]) expect(laterSocket.terminate).not.toHaveBeenCalled() await vi.advanceTimersByTimeAsync(1) expect(laterSocket.terminate).toHaveBeenCalledTimes(1) - expect(laterReapTimes).toEqual([400]) - expect(firstPingTimes).toEqual([0, 100, 200, 300, 400]) + expect(laterReapTimes).toEqual([500]) + expect(firstPingTimes).toEqual([100, 200, 300, 400, 500]) expect( ['pong', 'message', 'close', 'error'].map((event) => laterSocket.listenerCount(event)) ).toEqual([0, 0, 0, 0]) @@ -182,11 +190,12 @@ describe('WebSocketTransport accepted socket ordering', () => { socket.emit('error', new Error('probe failed')) } }) - const { lifecycle } = createHarness(socket) + const { lifecycle, transport } = createHarness(socket) socket.once('open', () => events.push('open')) socket.emit('open') lifecycle.handleConnection(socket) + transport.setClientId(socket, 'client') await vi.advanceTimersByTimeAsync(100) events.push('close') socket.emit('close') diff --git a/src/main/runtime/rpc/ws-transport-transient-packet-loss.test.ts b/src/main/runtime/rpc/ws-transport-transient-packet-loss.test.ts index a8a5eb9c4de..40f758f87a4 100644 --- a/src/main/runtime/rpc/ws-transport-transient-packet-loss.test.ts +++ b/src/main/runtime/rpc/ws-transport-transient-packet-loss.test.ts @@ -68,6 +68,12 @@ describe('WebSocketTransport under transient packet loss', () => { client.once('open', resolve) client.once('error', reject) }) + // Heartbeats intentionally begin after authentication; stamp this synthetic peer as authenticated + // so the liveness oracle exercises the production heartbeat path rather than pre-auth expiry. + const wss = (transport as unknown as { wss: { clients: Set } }).wss + for (const serverSocket of wss.clients) { + transport.setClientId(serverSocket, 'test-client') + } // Bounded by counted probe events, never by elapsed time. await vi.waitFor(() => expect(closed || probesReceived >= swallowedProbe + 2).toBe(true), { diff --git a/src/main/runtime/rpc/ws-transport.test.ts b/src/main/runtime/rpc/ws-transport.test.ts index 00256b29760..f1ac250bdc3 100644 --- a/src/main/runtime/rpc/ws-transport.test.ts +++ b/src/main/runtime/rpc/ws-transport.test.ts @@ -107,8 +107,7 @@ describe('WebSocketTransport', () => { await waitForHeartbeatLifecycle(transport, 1, true) const firstServerSocket = Array.from(lifecycle.wss.clients)[0] expect(firstServerSocket).toBeDefined() - // Note: arming probes immediately, so `alive` membership is racy here (the client's protocol-level - // pong re-adds the socket right after the arm sweep clears it). Assert the arm/disarm lifecycle only. + // Note: periodic probes run on timer interval ticks; assert the arm/disarm lifecycle only. const firstTimer = lifecycle.heartbeat.timer const secondClient = await connectWs(transport) diff --git a/src/main/runtime/rpc/ws-transport.ts b/src/main/runtime/rpc/ws-transport.ts index ec58f8697ae..3ba8a17fe60 100644 --- a/src/main/runtime/rpc/ws-transport.ts +++ b/src/main/runtime/rpc/ws-transport.ts @@ -319,11 +319,13 @@ export class WebSocketTransport implements RpcTransport { ws.on('close', finalizeConnection) ws.on('error', onError) - // Why: every lifecycle event must have an owner before the first synchronous probe. + // Why: install lifecycle ownership before periodic heartbeat ticks can observe this socket. this.heartbeatConnections.add(ws) this.heartbeat.noteAlive(ws) if (this.heartbeatConnections.size === 1) { - this.heartbeat.start(() => this.wss?.clients ?? []) + // Unauthenticated sockets are protected by the pre-auth timeout; heartbeat probes begin only + // after E2EE binds a client id, avoiding control frames during the handshake. + this.heartbeat.start(() => this.wsClientIds.keys()) } } diff --git a/src/main/runtime/runtime-git-conflict-operation-routing.test.ts b/src/main/runtime/runtime-git-conflict-operation-routing.test.ts new file mode 100644 index 00000000000..b9d42a0af70 --- /dev/null +++ b/src/main/runtime/runtime-git-conflict-operation-routing.test.ts @@ -0,0 +1,35 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type * as GitStatusModule from '../git/status' + +const detectConflictOperationMock = vi.hoisted(() => vi.fn()) + +vi.mock('../git/status', async () => ({ + ...(await vi.importActual('../git/status')), + detectConflictOperation: detectConflictOperationMock +})) + +import { RuntimeGitStatusCommands } from './runtime-git-status-commands' + +// Why: the conflict badge the runtime RPC serves is read from the worktree's `.git` pointer, so it +// must run in the same host namespace as the target's git — a WSL target's paths are guest-spelled. +describe('getRuntimeGitConflictOperation', () => { + beforeEach(() => { + detectConflictOperationMock.mockReset() + detectConflictOperationMock.mockResolvedValue('merge') + }) + + it("probes with the target's local git options", async () => { + const commands = new RuntimeGitStatusCommands({ + resolveRuntimeGitTarget: async () => ({ + worktree: { path: '/home/me/repo/feature' }, + localGitOptions: { wslDistro: 'Ubuntu' } + }) + } as never) + + await expect(commands.getRuntimeGitConflictOperation('id:wt-1')).resolves.toBe('merge') + + expect(detectConflictOperationMock).toHaveBeenCalledWith('/home/me/repo/feature', { + wslDistro: 'Ubuntu' + }) + }) +}) diff --git a/src/main/runtime/runtime-git-status-commands.ts b/src/main/runtime/runtime-git-status-commands.ts index e76342aca1a..28390cee7c9 100644 --- a/src/main/runtime/runtime-git-status-commands.ts +++ b/src/main/runtime/runtime-git-status-commands.ts @@ -116,7 +116,7 @@ export class RuntimeGitStatusCommands { } return provider.detectConflictOperation(target.worktree.path) } - return detectConflictOperation(target.worktree.path) + return detectConflictOperation(target.worktree.path, localGitOptionsForTarget(target)) } async checkoutRuntimeGitBranch( diff --git a/src/main/runtime/runtime-local-git-worktree-create.ts b/src/main/runtime/runtime-local-git-worktree-create.ts index 528aa318cd4..9875e5a07d0 100644 --- a/src/main/runtime/runtime-local-git-worktree-create.ts +++ b/src/main/runtime/runtime-local-git-worktree-create.ts @@ -185,7 +185,7 @@ export async function createRuntimeLocalGitWorktree(args: { )) ?? {}) let addResult: AddWorktreeResult try { - const preparedResult = + const preparedAttempt = sparseDirectories.length === 0 && !args.checkoutExistingBranch ? await consumePreparedWorktreeCreate({ repoPath: args.repo.path, @@ -197,8 +197,9 @@ export async function createRuntimeLocalGitWorktree(args: { ...(preparedWorktreeOptions ? { options: preparedWorktreeOptions } : {}) }) : null - if (preparedResult) { - addResult = preparedResult + // This path has no create-span recorder, so the miss reason is only observable on the IPC path. + if (preparedAttempt?.status === 'hit') { + addResult = preparedAttempt.result } else if (sparseDirectories.length > 0) { addResult = (await (addOptions diff --git a/src/main/runtime/runtime-managed-worktree-metadata-sweep.test.ts b/src/main/runtime/runtime-managed-worktree-metadata-sweep.test.ts new file mode 100644 index 00000000000..6df19517d64 --- /dev/null +++ b/src/main/runtime/runtime-managed-worktree-metadata-sweep.test.ts @@ -0,0 +1,123 @@ +// Why this file exists: the authoritative missing-metadata prune had exactly one caller, +// `ipcMain.handle('worktrees:listAll')`. A headless runtime host has no renderer, so it never swept +// its own repos and their `worktreeMeta` rows grew without bound (#17776). +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { mkdirSync, mkdtempSync, rmSync } from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import type { GitWorktreeInfo } from '../../shared/worktree/types' +import type { Repo } from '../../shared/repo-types' +import { testState, createStore, makeRepo } from '../persistence-test-harness' +import type { Store } from '../persistence/loading-store/store' +import { RuntimeManagedWorktreeQueries } from './runtime-managed-worktree-queries' +import type { RuntimeStore } from './runtime-store-contract' + +vi.mock('./ssh/ssh-config-parser', () => ({ + loadUserSshConfig: vi.fn(), + sshConfigHostsToTargets: vi.fn() +})) + +vi.mock('electron', () => ({ + app: { getPath: () => testState.dir }, + safeStorage: { isEncryptionAvailable: () => false } +})) + +vi.mock('./telemetry/client', () => ({ track: vi.fn() })) +vi.mock('./telemetry/cohort-classifier', () => ({ getCohortAtEmit: vi.fn().mockReturnValue({}) })) + +const gitWorktree = (path: string): GitWorktreeInfo => ({ + path, + branch: 'main', + head: 'abc1234', + isBare: false, + isMainWorktree: true +}) + +function queries( + store: Store, + repo: Repo, + worktrees: readonly GitWorktreeInfo[], + ok = true +): RuntimeManagedWorktreeQueries { + return new RuntimeManagedWorktreeQueries({ + getStore: () => store as unknown as RuntimeStore, + listResolved: async () => [], + resolveRepo: async () => repo, + selectRepos: () => [repo], + scanRepo: async () => ({ ok, worktrees: [...worktrees] }) + }) +} + +describe('runtime detected-worktree listing sweeps missing local metadata', () => { + let repoPath = '' + + beforeEach(() => { + testState.dir = mkdtempSync(join(tmpdir(), 'orca-runtime-sweep-')) + repoPath = join(testState.dir, 'repo') + mkdirSync(repoPath, { recursive: true }) + }) + + afterEach(() => { + rmSync(testState.dir, { recursive: true, force: true }) + }) + + // Paired with the off-host case below: same fixture, no `connectionId`. + it('drops a metadata row whose directory is gone and the scan does not list', async () => { + const store = createStore() + const repo = makeRepo({ id: 'repo-1', path: repoPath }) + store.addRepo(repo) + const missingId = `${repo.id}::${join(testState.dir, 'deleted-worktree')}` + store.setWorktreeMetaForHost(missingId, 'local', { displayName: 'Gone' }) + expect(store.getWorktreeMeta(missingId)).toBeDefined() + + await queries(store, repo, [gitWorktree(repoPath)]).listDetected(repo) + + expect(store.getWorktreeMeta(missingId)).toBeUndefined() + }) + + it('keeps a row whose directory still exists', async () => { + const store = createStore() + const repo = makeRepo({ id: 'repo-1', path: repoPath }) + store.addRepo(repo) + const livePath = join(testState.dir, 'live-worktree') + mkdirSync(livePath, { recursive: true }) + const liveId = `${repo.id}::${livePath}` + store.setWorktreeMetaForHost(liveId, 'local', { displayName: 'Live' }) + + await queries(store, repo, [gitWorktree(repoPath)]).listDetected(repo) + + expect(store.getWorktreeMeta(liveId)).toBeDefined() + }) + + // A non-authoritative scan is a failed listing, which is no evidence any checkout is gone. + it('keeps every row when the scan is not authoritative', async () => { + const store = createStore() + const repo = makeRepo({ id: 'repo-1', path: repoPath }) + store.addRepo(repo) + const missingId = `${repo.id}::${join(testState.dir, 'deleted-worktree')}` + store.setWorktreeMetaForHost(missingId, 'local', { displayName: 'Gone' }) + + await queries(store, repo, [], false).listDetected(repo) + + expect(store.getWorktreeMeta(missingId)).toBeDefined() + }) + + // The execution host owns this verdict: this host cannot stat a checkout that lives behind an SSH + // connection, so a local miss is not evidence of absence. See docs/reference/ssh-execution-boundary.md. + // + // Deliberately identical to the first case except for `connectionId`, and the row is stamped + // `local` so it is a real prune candidate. That pairing is the proof: the same fixture without a + // connection loses the row, so the connection is the only reason this one keeps it. Removing any + // single gate would not show that -- four independent checks derive from `connectionId` here. + it('never sweeps a repo whose git runs off-host', async () => { + const store = createStore() + const repo = makeRepo({ id: 'repo-1', path: repoPath, connectionId: 'build-box' }) + store.addRepo(repo) + const missingId = `${repo.id}::${join(testState.dir, 'deleted-worktree')}` + store.setWorktreeMetaForHost(missingId, 'local', { displayName: 'Gone' }) + + await queries(store, repo, [gitWorktree(repoPath)]).listDetected(repo) + + expect(store.getWorktreeMeta(missingId)).toBeDefined() + }) +}) diff --git a/src/main/runtime/runtime-managed-worktree-queries.ts b/src/main/runtime/runtime-managed-worktree-queries.ts index d444f024e84..b0ed2bc4a3b 100644 --- a/src/main/runtime/runtime-managed-worktree-queries.ts +++ b/src/main/runtime/runtime-managed-worktree-queries.ts @@ -20,6 +20,10 @@ import { } from '../../shared/worktree/visibility-sources' import { mergeWorktree } from '../ipc/worktree-logic' import { pruneLineageForMissingRepoWorktrees } from '../worktree-lineage-pruning' +import { pruneMetadataMissingFromAuthoritativeLocalScan } from '../ipc/worktrees/listing/authoritative-local-worktree-metadata-pruning' +import type { NativeLocalWorktreeMetadataScanExpectation } from '../persistence/tracking-repos/missing-local-worktree-metadata-pruning' +import { getLocalWorktreeScanGeneration } from '../local-worktree-scan-generation' +import { getLocalProjectWorktreeGitOptions } from '../project-runtime-git-options' import type { Store } from '../persistence' import type { RuntimeStore } from './runtime-store-contract' import type { RuntimeWorktreeScanResult } from './repo-worktree-resolution-scan' @@ -36,6 +40,31 @@ type Dependencies = { scanRepo(repo: Repo): Promise } +/** + * The destructive scan expectation for one repo, or undefined when this repo must not carry one. + * + * WSL-routed repos are excluded for the same reason the desktop listing excludes them: the listing + * runs in the distro and reports Linux paths while metadata can hold UNC ones, and v1 cannot prove + * those aliases equivalent. A runtime that needs repair throws rather than resolving routing, which + * is likewise no basis for deleting rows. + */ +function captureLocalMetadataPruneExpectation( + store: RuntimeStore, + repo: Repo +): NativeLocalWorktreeMetadataScanExpectation | undefined { + if (typeof store.captureNativeLocalWorktreeMetadataScanExpectation !== 'function') { + return undefined + } + try { + if (getLocalProjectWorktreeGitOptions(store as unknown as Store, repo).wslDistro) { + return undefined + } + } catch { + return undefined + } + return store.captureNativeLocalWorktreeMetadataScanExpectation(repo) +} + export class RuntimeManagedWorktreeQueries { constructor(private readonly deps: Dependencies) {} @@ -129,6 +158,10 @@ export class RuntimeManagedWorktreeQueries { worktrees: projectResolvedWorktreeLineage(detected, store.getAllWorktreeLineage?.() ?? {}) } } + // Why capture before the scan: listing can mutate metadata synchronously before its first + // await, and the prune revalidates against the rows as they stood when the scan was issued. + const metadataScanGeneration = getLocalWorktreeScanGeneration(repo.id) + const metadataPruneExpectation = captureLocalMetadataPruneExpectation(store, repo) let scan: RuntimeWorktreeScanResult try { scan = await this.deps.scanRepo(repo) @@ -136,6 +169,17 @@ export class RuntimeManagedWorktreeQueries { scan = { ok: false, worktrees: [] } } if (scan.ok) { + // Why the runtime sweeps too: the desktop listing that used to own this runs off `ipcMain`, + // so a headless host -- which has no renderer -- never pruned its own repos' rows (#17776). + if (metadataPruneExpectation) { + await pruneMetadataMissingFromAuthoritativeLocalScan({ + store: store as unknown as Store, + repo, + gitWorktrees: scan.worktrees, + scan: metadataPruneExpectation, + scanGeneration: metadataScanGeneration + }) + } pruneLineageForMissingRepoWorktrees(store as unknown as Store, repo, scan.worktrees) } const matcher = createWorktreeVisibilitySourceMatcher( diff --git a/src/main/runtime/runtime-remote-fetch-controller.ts b/src/main/runtime/runtime-remote-fetch-controller.ts index c48a8437a3d..b3b9df5dcba 100644 --- a/src/main/runtime/runtime-remote-fetch-controller.ts +++ b/src/main/runtime/runtime-remote-fetch-controller.ts @@ -1,4 +1,9 @@ import { GIT_FETCH_SKIP_AUTO_MAINTENANCE_CONFIG_ARGS } from '../../shared/git-fetch-auto-maintenance' +import { getCanonicalRepoKey } from '../git/canonical-repo-key' +import { + armLocalRepoRefMaintenance, + setRepoRefMaintenanceBusyProbe +} from '../git/local-repo-ref-maintenance' import { gitExecFileAsync } from '../git/runner' import { setBoundedMapEntry } from './runtime-async-boundaries' @@ -33,6 +38,11 @@ export class RuntimeRemoteFetchController { return this.fetchLastCompletedAt } + /** `${runtimeKey}::${gitCommonDir}` -- one repo on one execution host. */ + async getCanonicalRepoKey(repoPath: string, gitOptions: GitOptions = {}): Promise { + return getCanonicalRepoKey(repoPath, gitOptions) + } + async getCanonicalFetchKey( repoPath: string, remote: string, @@ -45,23 +55,41 @@ export class RuntimeRemoteFetchController { setBoundedMapEntry(this.canonicalFetchKeyCache, cacheKey, cached, REMOTE_FETCH_CACHE_MAX) return cached } - let resolved = cacheKey - try { - const { stdout } = await gitExecFileAsync( - ['rev-parse', '--path-format=absolute', '--git-common-dir'], - { cwd: repoPath, ...gitOptions } - ) - const commonDir = stdout.trim() - if (commonDir) { - resolved = `${runtimeKey}::${commonDir}::${remote}` - } - } catch { - // The caller path remains a safe serialization key when canonicalization fails. - } + const resolved = `${await this.getCanonicalRepoKey(repoPath, gitOptions)}::${remote}` setBoundedMapEntry(this.canonicalFetchKeyCache, cacheKey, resolved, REMOTE_FETCH_CACHE_MAX) return resolved } + /** + * Orca strips git's auto-maintenance off these fetches, so every one of them + * adds to a loose-ref backlog nothing else will ever pack. Arm the idle sweep + * that pays it back; each fetch pushes the attempt a further quiet period out. + */ + private armRefMaintenance(repoPath: string, gitOptions: GitOptions): void { + void this.getCanonicalRepoKey(repoPath, gitOptions) + .then((key) => { + setRepoRefMaintenanceBusyProbe(key, () => this.hasInflightFetchForRepo(key)) + armLocalRepoRefMaintenance({ + key, + repoPath, + ...(gitOptions.wslDistro ? { wslDistro: gitOptions.wslDistro } : {}) + }) + }) + .catch(() => { + // Maintenance is best effort; a repo we cannot name is a repo we skip. + }) + } + + private hasInflightFetchForRepo(repoKey: string): boolean { + const prefix = `${repoKey}::` + for (const key of this.fetchInflight.keys()) { + if (key.startsWith(prefix)) { + return true + } + } + return false + } + private enqueueRemoteFetch( remoteKey: string, runFetch: () => Promise @@ -123,6 +151,7 @@ export class RuntimeRemoteFetchController { }) ).finally(() => { this.fetchInflight.delete(key) + this.armRefMaintenance(repoPath, gitOptions) }) this.fetchInflight.set(key, promise) return promise @@ -178,6 +207,7 @@ export class RuntimeRemoteFetchController { }) }).finally(() => { this.fetchInflight.delete(key) + this.armRefMaintenance(repoPath, gitOptions) }) this.fetchInflight.set(key, promise) return promise diff --git a/src/main/runtime/runtime-remote-fetch-ref-maintenance.test.ts b/src/main/runtime/runtime-remote-fetch-ref-maintenance.test.ts new file mode 100644 index 00000000000..f577da24854 --- /dev/null +++ b/src/main/runtime/runtime-remote-fetch-ref-maintenance.test.ts @@ -0,0 +1,145 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +// Why: Orca's fetches are what create the loose-ref backlog (they suppress +// git's auto-maintenance), so the fetch controller is where the idle sweep has +// to be armed. These tests pin that wiring and the per-repo busy signal it +// hands the sweep. + +const gitExecFileAsyncMock = vi.hoisted(() => vi.fn()) +const armMock = vi.hoisted(() => vi.fn()) +const busyProbeMock = vi.hoisted(() => vi.fn()) + +vi.mock('../git/runner', async (importOriginal) => ({ + ...((await importOriginal()) as Record), + gitExecFileAsync: gitExecFileAsyncMock +})) + +vi.mock('../git/local-repo-ref-maintenance', async (importOriginal) => ({ + ...((await importOriginal()) as Record), + armLocalRepoRefMaintenance: armMock, + setRepoRefMaintenanceBusyProbe: busyProbeMock +})) + +import { _resetCanonicalRepoKeyCacheForTests } from '../git/canonical-repo-key' +import { RuntimeRemoteFetchController } from './runtime-remote-fetch-controller' + +function armedTargets(): { key: string }[] { + return armMock.mock.calls.map(([args]) => args as { key: string }) +} + +/** The per-repo "a fetch is in flight" answer the controller registers for a key. */ +function busyProbeFor(key: string): (() => boolean) | undefined { + return busyProbeMock.mock.calls.findLast(([registered]) => registered === key)?.[1] as + | (() => boolean) + | undefined +} + +beforeEach(() => { + _resetCanonicalRepoKeyCacheForTests() + gitExecFileAsyncMock.mockReset() + armMock.mockReset() + busyProbeMock.mockReset() + gitExecFileAsyncMock.mockImplementation(async (argv: string[]) => + argv[0] === 'rev-parse' ? { stdout: '/repo/.git\n', stderr: '' } : { stdout: '', stderr: '' } + ) +}) + +afterEach(() => { + vi.restoreAllMocks() +}) + +describe('fetch-armed ref maintenance', () => { + it('arms the sweep for the repo after a remote fetch, keyed by common dir', async () => { + const controller = new RuntimeRemoteFetchController() + + await controller.getOrStartRemoteFetch('/repo/worktrees/a', 'origin') + + expect(armedTargets().map((target) => target.key)).toEqual(['local::/repo/.git']) + }) + + it('gives every worktree of one repo the same maintenance key', async () => { + const controller = new RuntimeRemoteFetchController() + + await controller.getOrStartRemoteFetch('/repo/worktrees/a', 'origin') + await controller.getOrStartRemoteTrackingBaseRefresh('/repo/worktrees/b', { + remote: 'origin', + branch: 'main', + ref: 'refs/remotes/origin/main', + base: 'origin/main' + }) + + const keys = new Set(armedTargets().map((target) => target.key)) + expect(keys).toEqual(new Set(['local::/repo/.git'])) + }) + + it('scopes the key to the WSL distro that executes the repo', async () => { + const controller = new RuntimeRemoteFetchController() + + await controller.getOrStartRemoteFetch('//wsl$/Ubuntu/repo', 'origin', { + wslDistro: 'Ubuntu' + }) + + expect(armedTargets()[0]?.key).toBe('wsl:Ubuntu::/repo/.git') + }) + + it('does not collapse every repo onto one key on Git older than 2.31', async () => { + // Old Git echoes the unrecognized `--path-format` flag, exits 0, and prints a + // relative `.git`; taking that raw would name every repository identically. + gitExecFileAsyncMock.mockImplementation(async (argv: string[]) => + argv[0] === 'rev-parse' + ? { stdout: '--path-format=absolute\n.git\n', stderr: '' } + : { stdout: '', stderr: '' } + ) + const controller = new RuntimeRemoteFetchController() + + await controller.getOrStartRemoteFetch('/repo/one', 'origin') + await controller.getOrStartRemoteFetch('/repo/two', 'origin') + + expect(armedTargets().map((entry) => entry.key)).toEqual([ + 'local::/repo/one/.git', + 'local::/repo/two/.git' + ]) + }) + + it('reports the repo as busy while another fetch on it is in flight', async () => { + const controller = new RuntimeRemoteFetchController() + await controller.getOrStartRemoteFetch('/repo', 'first') + const isBusy = busyProbeFor('local::/repo/.git') + expect(isBusy?.()).toBe(false) + + let releaseFetch: (() => void) | undefined + gitExecFileAsyncMock.mockImplementation(async (argv: string[]) => { + if (argv[0] === 'rev-parse') { + return { stdout: '/repo/.git\n', stderr: '' } + } + await new Promise((resolve) => { + releaseFetch = resolve + }) + return { stdout: '', stderr: '' } + }) + const second = controller.getOrStartRemoteFetch('/repo', 'second') + await vi.waitFor(() => expect(releaseFetch).toBeDefined()) + expect(isBusy?.()).toBe(true) + + releaseFetch?.() + await second + expect(isBusy?.()).toBe(false) + }) + + it('arms even when the fetch fails, because a partial fetch still writes refs', async () => { + const controller = new RuntimeRemoteFetchController() + gitExecFileAsyncMock.mockImplementation(async (argv: string[]) => { + if (argv[0] === 'rev-parse') { + return { stdout: '/repo/.git\n', stderr: '' } + } + throw new Error('network is unreachable') + }) + vi.spyOn(console, 'warn').mockImplementation(() => {}) + + await expect(controller.getOrStartRemoteFetch('/repo', 'origin')).resolves.toEqual({ + ok: false, + errorKind: 'git_error' + }) + expect(armedTargets()).toHaveLength(1) + }) +}) diff --git a/src/main/runtime/runtime-store-contract.ts b/src/main/runtime/runtime-store-contract.ts index 692826b3c29..e160e039533 100644 --- a/src/main/runtime/runtime-store-contract.ts +++ b/src/main/runtime/runtime-store-contract.ts @@ -30,6 +30,9 @@ export type RuntimeStore = { removeProjectForHost?: Store['removeProjectForHost'] reorderRepos?: Store['reorderRepos'] getAllWorktreeMeta: Store['getAllWorktreeMeta'] + captureNativeLocalWorktreeMetadataScanExpectation?: Store['captureNativeLocalWorktreeMetadataScanExpectation'] + pruneSessionlessMissingLocalWorktreeMetadataForRepo?: Store['pruneSessionlessMissingLocalWorktreeMetadataForRepo'] + getProfileStorageDirectory?: Store['getProfileStorageDirectory'] getWorktreeMeta: Store['getWorktreeMeta'] setWorktreeMeta: Store['setWorktreeMeta'] setWorktreeMetaForHost?: Store['setWorktreeMetaForHost'] diff --git a/src/main/source-control/hosted-review-creation-git-state.ts b/src/main/source-control/hosted-review-creation-git-state.ts index b2457424d2a..d73a2f69f94 100644 --- a/src/main/source-control/hosted-review-creation-git-state.ts +++ b/src/main/source-control/hosted-review-creation-git-state.ts @@ -268,7 +268,7 @@ export async function hasUncommittedChanges( if (records.length === 0) { return false } - return await anyRecordIsUserDirt(repoPath, records, options.sharedLinkPaths ?? []) + return await anyRecordIsUserDirt(repoPath, records, options) } /** True when any record is real user work rather than a shared symlink Orca put @@ -280,14 +280,21 @@ export async function hasUncommittedChanges( async function anyRecordIsUserDirt( worktreePath: string, records: readonly PorcelainV1Record[], - sharedLinkPaths: readonly string[] + options: HostedReviewExecutionOptions ): Promise { + const sharedLinkPaths = options.sharedLinkPaths ?? [] if (sharedLinkPaths.length === 0 || !records.some((record) => record.xy === '??')) { return true } // Why: only entries that are configured AND really symlinks are excluded, so a // regular file the user created at a configured name still blocks creation. - const sharedLinks = new Set(await findExistingWorktreeSymlinkPaths(worktreePath, sharedLinkPaths)) + // Why the distro: git ran in the guest, so an untranslated lstat fails here and this + // fail-closed check would block review creation over Orca's own symlink. + const sharedLinks = new Set( + await findExistingWorktreeSymlinkPaths(worktreePath, sharedLinkPaths, { + wslDistro: getHostedReviewLocalGitOptions(options).wslDistro + }) + ) return records.some((record) => record.xy !== '??' || !sharedLinks.has(record.path)) } diff --git a/src/main/source-control/hosted-review-dirty-preflight-wsl-paths.test.ts b/src/main/source-control/hosted-review-dirty-preflight-wsl-paths.test.ts new file mode 100644 index 00000000000..aa95d30eb9d --- /dev/null +++ b/src/main/source-control/hosted-review-dirty-preflight-wsl-paths.test.ts @@ -0,0 +1,45 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { gitExecFileAsyncMock, findExistingWorktreeSymlinkPathsMock } = vi.hoisted(() => ({ + gitExecFileAsyncMock: vi.fn(), + findExistingWorktreeSymlinkPathsMock: vi.fn() +})) + +vi.mock('../github/gh-utils', () => ({ + acquire: vi.fn(), + release: vi.fn(), + ghExecFileAsync: vi.fn(), + gitExecFileAsync: gitExecFileAsyncMock +})) +vi.mock('../git/worktree-symlink-detection', () => ({ + findExistingWorktreeSymlinkPaths: findExistingWorktreeSymlinkPathsMock +})) + +import { hasUncommittedChanges } from './hosted-review-creation-git-state' + +// Why: git ran inside the distro and answered in its namespace, so the fail-closed shared-symlink +// check must lstat the host spelling — otherwise it never recognises Orca's own symlink and blocks +// review creation on a permanently "dirty" WSL worktree. +describe('hasUncommittedChanges shared-symlink probe', () => { + beforeEach(() => { + gitExecFileAsyncMock.mockReset() + findExistingWorktreeSymlinkPathsMock.mockReset() + gitExecFileAsyncMock.mockResolvedValue({ stdout: '?? node_modules\0', stderr: '' }) + findExistingWorktreeSymlinkPathsMock.mockResolvedValue(['node_modules']) + }) + + it('passes the configured distro through to the probe', async () => { + await expect( + hasUncommittedChanges('/home/me/repo/feature', null, { + localGitExecOptions: { wslDistro: 'Ubuntu' }, + sharedLinkPaths: ['node_modules'] + }) + ).resolves.toBe(false) + + expect(findExistingWorktreeSymlinkPathsMock).toHaveBeenCalledWith( + '/home/me/repo/feature', + ['node_modules'], + { wslDistro: 'Ubuntu' } + ) + }) +}) diff --git a/src/main/ssh-reattach-pane-cardinality.test.ts b/src/main/ssh-reattach-pane-cardinality.test.ts index 51cbdb26794..96362ffbfc2 100644 --- a/src/main/ssh-reattach-pane-cardinality.test.ts +++ b/src/main/ssh-reattach-pane-cardinality.test.ts @@ -2,7 +2,13 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { rmSync, mkdtempSync } from 'node:fs' import { join } from 'node:path' import { tmpdir } from 'node:os' -import { testState, createStore, makeTerminalTab, writeDataFile } from './persistence-test-harness' +import { + testState, + createStore, + makeRepo, + makeTerminalTab, + writeDataFile +} from './persistence-test-harness' import { TEST_LEAF_1, TEST_LEAF_2 } from './persistence-session-fixtures' import { getDefaultPersistedState } from '../shared/constants' @@ -196,6 +202,8 @@ describe('STA-3077: an SSH reattach binds panes without grafting them back', () it('does not clear and rebind a retired surface loaded from an older profile', async () => { const paneKey = `${TAB}:${TEST_LEAF_1}` const persisted = getDefaultPersistedState(testState.dir) + // Registered on purpose: rows owned by an unregistered repo id are swept as orphans on load. + persisted.repos = [makeRepo({ id: 'repo1', path: '/repo1' })] persisted.workspaceSession = { ...persisted.workspaceSession, ...sessionWithPane({ tabId: TAB, leafId: TEST_LEAF_1, ptyId: 'pty-1' }), diff --git a/src/main/ssh/ssh-relay-deploy.ts b/src/main/ssh/ssh-relay-deploy.ts index c9d82cbd744..911d185b0fd 100644 --- a/src/main/ssh/ssh-relay-deploy.ts +++ b/src/main/ssh/ssh-relay-deploy.ts @@ -733,13 +733,25 @@ function missingNativeDepsFromProbe(output: string): RelayNativeDepName[] { return RELAY_NATIVE_DEP_NAMES.filter((name) => reported.includes(name)) } +/** + * `ok` — the probe answered and both deps loaded. `blocked` — the probe answered and named deps + * that failed to load. `unverifiable` — the probe never answered, which is evidence about the + * transport, not about the deps. + * + * Why `unverifiable` is not `blocked`: repairing on it does `rm -rf node_modules/node-pty` and a + * node-gyp source build (no Linux prebuild) against a relay that was never shown to be broken. + * Same verdict discipline as `src/main/orcad/node-pty-precondition.ts` and + * docs/reference/ssh-execution-boundary.md — loss of contact is not evidence. + */ +type RelayNativeDepsProbeStatus = 'ok' | 'blocked' | 'unverifiable' + async function probeRequiredNativeDeps( conn: SshConnection, remoteDir: string, hostPlatform: RemoteHostPlatform, nodePath: string, signal?: AbortSignal -): Promise<{ available: boolean; missing: RelayNativeDepName[] }> { +): Promise<{ status: RelayNativeDepsProbeStatus; missing: RelayNativeDepName[] }> { const escapedNode = shellEscape(nodePath) const probeJs = nativeDepsProbeJs('ORCA-NATIVE-DEPS-OK') try { @@ -757,11 +769,14 @@ async function probeRequiredNativeDeps( `(${escapedNode} -e ${shellEscape(probeJs)} 2>/dev/null || echo MISSING)` ) const probe = await execHostCommand(conn, hostPlatform, command, { signal }) - const available = probe.includes('ORCA-NATIVE-DEPS-OK') - return { available, missing: available ? [] : missingNativeDepsFromProbe(probe) } + return probe.includes('ORCA-NATIVE-DEPS-OK') + ? { status: 'ok', missing: [] } + : { status: 'blocked', missing: missingNativeDepsFromProbe(probe) } } catch { signal?.throwIfAborted() - return { available: false, missing: [...RELAY_NATIVE_DEP_NAMES] } + // Why: an unanswered probe says nothing about the deps; reporting MISSING here reset and + // recompiled healthy relays, turning one dropped exec channel into a multi-minute reconnect. + return { status: 'unverifiable', missing: [] } } } @@ -810,7 +825,8 @@ async function repairInstalledNativeDeps( lockResult === 'busy' || lockResult === 'error' ? await acquireRelayLaunchGcFence(conn, remoteDir, hostPlatform, signal) : undefined - if (initialProbe.available) { + // Why: only a probe that answered may trigger repair; an unverifiable one launches as-is and the next reconnect re-probes. + if (initialProbe.status !== 'blocked') { // Why: even a healthy reconnect stays fenced until launch liveness is observable, or cross-version GC can rename after this probe. if (lockResult !== 'acquired') { return { ownsInstallLock: false, gcClaimToken } @@ -847,7 +863,9 @@ async function repairInstalledNativeDeps( // Why: older complete relay dirs predate @parcel/watcher; re-probe under the lock so only one reconnect mutates the dir. const probe = await probeRequiredNativeDeps(conn, remoteDir, hostPlatform, nodePath, signal) let repairNamespace: RelayInstallNamespace | undefined - if (!probe.available) { + if (probe.status !== 'ok') { + // Why: the locked re-probe can only narrow the repair; when it can't answer, the initial probe's answered evidence still stands. + const resetDeps = probe.status === 'unverifiable' ? initialProbe.missing : probe.missing // Why: only stamp ownership once the locked recheck proves this connection is the one about to write. repairNamespace = await createRelayLaunchNamespace( conn, @@ -863,7 +881,7 @@ async function repairInstalledNativeDeps( hostPlatform, nodePath, signal, - probe.missing, + resetDeps, repairNamespace ) await finalizeInstall(conn, remoteDir, hostPlatform, { signal, releaseLock: false }) diff --git a/src/main/ssh/ssh-relay-native-deps-probe-verdict.test.ts b/src/main/ssh/ssh-relay-native-deps-probe-verdict.test.ts new file mode 100644 index 00000000000..c67270db4c3 --- /dev/null +++ b/src/main/ssh/ssh-relay-native-deps-probe-verdict.test.ts @@ -0,0 +1,226 @@ +// Why: the repair path used to map ANY probe failure to "all deps missing", so one dropped exec +// channel rm -rf'd node-pty on a healthy relay and forced a node-gyp rebuild. Verdicts are +// ok / blocked / unverifiable — see docs/reference/ssh-execution-boundary.md. + +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type * as RelayInstallMarkerModule from './ssh-relay-install-marker' + +vi.mock('electron', () => ({ + app: { getAppPath: () => '/mock/app' } +})) + +vi.mock('fs', () => ({ + existsSync: vi.fn().mockReturnValue(true), + readFileSync: vi.fn().mockReturnValue('0.1.0+testhash') +})) + +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().mockResolvedValue(undefined), + waitForSentinel: vi.fn().mockResolvedValue({ + write: vi.fn(), + onData: vi.fn(), + onClose: vi.fn() + }), + isUnconfirmedSshCommandTermination: (error: unknown) => + error instanceof Error && + (error as Error & { sshChannelCloseConfirmed?: boolean }).sshChannelCloseConfirmed === false, + execCommand: vi.fn() +})) + +vi.mock('./ssh-remote-node-resolution', () => ({ + resolveRemoteNodePath: vi.fn().mockResolvedValue('/usr/bin/node') +})) + +vi.mock('./ssh-relay-install-marker', async (importOriginal) => ({ + ...(await importOriginal()), + createRelayInstallMarkerFileName: () => '.sftp-namespace-00000000000000000000000000000000' +})) + +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(true), + 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), + RELAY_INSTALL_LOCK_NAME: '.install-lock' +})) + +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, uploadDirectory } from './ssh-relay-deploy-helpers' +import { parseUnameToRelayPlatform } from './relay-protocol' +import { finalizeInstall, isRelayAlreadyInstalled } from './ssh-relay-versioned-install' +import { + makeMockConnection, + type ExecResponse, + type SftpWriteCapture +} from './ssh-relay-native-deps-install-fixture' + +const NODE_PTY_RESET = "rm -rf 'node_modules/node-pty'" +const WATCHER_RESET = "rm -rf 'node_modules/@parcel/watcher'" + +describe('native-deps repair probe verdicts', () => { + const sftpCapture: SftpWriteCapture = { paths: [], contents: {}, execCallCountAtWrite: {} } + let warnSpy: ReturnType + + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(execCommand).mockReset().mockResolvedValue('') + vi.mocked(uploadDirectory).mockResolvedValue(undefined) + sftpCapture.paths.length = 0 + for (const key of Object.keys(sftpCapture.contents)) { + delete sftpCapture.contents[key] + } + for (const key of Object.keys(sftpCapture.execCallCountAtWrite)) { + delete sftpCapture.execCallCountAtWrite[key] + } + vi.mocked(parseUnameToRelayPlatform).mockReturnValue('linux-x64') + vi.mocked(isRelayAlreadyInstalled).mockResolvedValue(true) + warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + }) + + function feed(execResponses: ExecResponse[]): void { + const mockExec = vi.mocked(execCommand) + for (const response of execResponses) { + if (typeof response === 'string') { + mockExec.mockResolvedValueOnce(response) + } else { + mockExec.mockRejectedValueOnce(new Error(response.reject)) + } + } + } + + function execCommands(): string[] { + return vi.mocked(execCommand).mock.calls.map(([, command]) => command) + } + + function warnings(): string[] { + return warnSpy.mock.calls.map((args) => String(args[0] ?? '')) + } + + it('launches an intact relay when the health probe never answers', async () => { + const conn = makeMockConnection(sftpCapture) + feed([ + '__ORCA_REMOTE_PLATFORM__ Linux x86_64', + '/home/u', + { reject: 'SSH channel closed unexpectedly' }, // health probe: unverifiable, not MISSING + '', // launch namespace marker + 'DEAD', + '', // publish the per-launch credential + 'READY' + ]) + + // Assert the repair-avoidance facts before the launch outcome so a regression names the defect + // rather than the fixture drift that follows from an unexpected repair. + const outcome = await deployAndLaunchRelay(conn).then( + (result) => result, + (err: Error) => err + ) + + const commands = execCommands() + expect(warnings().some((message) => message.includes('Repairing missing native deps'))).toBe( + false + ) + expect(commands.some((command) => command.includes(NODE_PTY_RESET))).toBe(false) + expect(commands.some((command) => command.includes(WATCHER_RESET))).toBe(false) + expect(commands.some((command) => command.includes('npm install'))).toBe(false) + // Exactly one probe: an unverifiable answer must not fall through to the locked re-probe. + expect(commands.filter((command) => command.includes('ORCA-NATIVE-DEPS-OK'))).toHaveLength(1) + expect(vi.mocked(finalizeInstall)).not.toHaveBeenCalled() + expect(outcome, 'lost contact must not abort the connection').not.toBeInstanceOf(Error) + }) + + it('still resets and repairs when the probe answers without the OK marker', async () => { + const conn = makeMockConnection(sftpCapture) + feed([ + '__ORCA_REMOTE_PLATFORM__ Linux x86_64', + '/home/u', + 'MISSING', // answered, no marker line: both deps are genuinely broken + 'MISSING', // re-probe under the repair lock + '', // SFTP-namespace install-owner marker (repair) + '', // npm install native deps + '', // chmod prebuilds + 'ORCA-NPTY-PROBE-OK\n', + '', // rm probe stderr + 'DEAD', + '', // publish the per-launch credential + 'READY' + ]) + + await expect(deployAndLaunchRelay(conn)).resolves.toBeDefined() + + const install = execCommands().find((command) => command.includes('npm install')) ?? '' + expect(install).toContain(NODE_PTY_RESET) + expect(install).toContain(WATCHER_RESET) + expect(vi.mocked(finalizeInstall)).toHaveBeenCalledTimes(1) + }) + + it('skips repair entirely when the probe answers OK', async () => { + const conn = makeMockConnection(sftpCapture) + feed([ + '__ORCA_REMOTE_PLATFORM__ Linux x86_64', + '/home/u', + 'ORCA-NATIVE-DEPS-OK', + '', // launch namespace marker + 'DEAD', + '', // publish the per-launch credential + 'READY' + ]) + + await expect(deployAndLaunchRelay(conn)).resolves.toBeDefined() + + expect(execCommands().some((command) => command.includes('npm install'))).toBe(false) + expect(vi.mocked(finalizeInstall)).not.toHaveBeenCalled() + }) + + it('keeps the answered reset scope when the locked re-probe cannot answer', async () => { + const conn = makeMockConnection(sftpCapture) + feed([ + '__ORCA_REMOTE_PLATFORM__ Linux x86_64', + '/home/u', + 'ORCA-NATIVE-DEPS-MISSING:@parcel/watcher\nMISSING', // answered: only the watcher is broken + { reject: 'SSH channel closed unexpectedly' }, // re-probe under the lock: unverifiable + '', // SFTP-namespace install-owner marker (repair) + '', // npm install native deps + '', // chmod prebuilds + 'ORCA-NPTY-PROBE-OK\n', + '', // rm probe stderr + 'DEAD', + '', // publish the per-launch credential + 'READY' + ]) + + await expect(deployAndLaunchRelay(conn)).resolves.toBeDefined() + + const install = execCommands().find((command) => command.includes('npm install')) ?? '' + expect(install).toContain(WATCHER_RESET) + // The unanswered re-probe must not widen the reset to a dep no probe ever reported broken. + expect(install).not.toContain(NODE_PTY_RESET) + }) +}) diff --git a/src/main/startup/branch-rename-hook.ts b/src/main/startup/branch-rename-hook.ts new file mode 100644 index 00000000000..578935132fb --- /dev/null +++ b/src/main/startup/branch-rename-hook.ts @@ -0,0 +1,135 @@ +import { existsSync } from 'node:fs' +import { parseWorkspaceKey } from '../../shared/workspace-scope' +import { getRepoIdFromWorktreeId } from '../../shared/worktree/id' +import { maybeAutoRenameBranchOnFirstWork } from '../agent-hooks/first-work-branch-rename' +import { rememberBranchRenameFailureOutput } from '../agent-hooks/branch-rename-failure-output' +import { renameWorktreeFolderOnFirstWork } from '../agent-hooks/first-work-folder-rename' +import { moveWorktree } from '../git/worktree' +import { mainProcessState as state } from './main-process-state' + +// Kill switch for the first-work on-disk folder rename; the renderer reconciles the id change (migrateWorktreeIdentity) so it isn't mistaken for a deletion. +const ENABLE_FIRST_WORK_FOLDER_RENAME = false + +// Why: inject the index.ts store/runtime singletons so the rename orchestrator stays module-state-free and unit-testable. +export function maybeAutoRenameBranchOnFirstWorkFromHook(event: { + paneKey: string + tabId: string | undefined + worktreeId: string | undefined + payload: { state: string; prompt?: string; lastAssistantMessage?: string } + isReplay: boolean | undefined +}): void { + const store = state.store + const runtime = state.runtime + if (!store || !runtime) { + return + } + void maybeAutoRenameBranchOnFirstWork( + { + paneKey: event.paneKey, + tabId: event.tabId, + worktreeId: event.worktreeId, + state: event.payload.state, + prompt: event.payload.prompt, + assistantMessage: event.payload.lastAssistantMessage, + isReplay: event.isReplay + }, + { + getSettings: () => store.getSettings(), + getRepo: (repoId) => store.getRepo(repoId), + getAgentEnvResolvers: () => runtime.getCommitMessageAgentEnvironmentResolvers(), + getCurrentDisplayName: (worktreeId) => { + const scope = parseWorkspaceKey(worktreeId) + return scope?.type === 'folder' + ? store.getFolderWorkspace(scope.folderWorkspaceId)?.name + : store.getWorktreeMeta(worktreeId)?.displayName + }, + getFolderWorkspacePath: (worktreeId) => { + const scope = parseWorkspaceKey(worktreeId) + return scope?.type === 'folder' + ? store.getFolderWorkspace(scope.folderWorkspaceId)?.folderPath + : undefined + }, + isPendingFirstAgentMessageRename: (worktreeId) => { + const scope = parseWorkspaceKey(worktreeId) + return scope?.type === 'folder' + ? store.getFolderWorkspace(scope.folderWorkspaceId)?.pendingFirstAgentMessageRename === + true + : store.getWorktreeMeta(worktreeId)?.pendingFirstAgentMessageRename === true + }, + canRenameOrcaCreatedBranch: (worktreeId) => { + const meta = store.getWorktreeMeta(worktreeId) + // Why: a user branch could coincidentally match a creature name; only Orca-stamped worktrees are safe to auto-rename. + return !!meta?.orcaCreationSource && meta.preserveBranchOnDelete !== true + }, + setDisplayName: (worktreeId, displayName) => { + rememberBranchRenameFailureOutput(worktreeId, null) + const scope = parseWorkspaceKey(worktreeId) + if (scope?.type === 'folder') { + store.updateFolderWorkspace(scope.folderWorkspaceId, { + name: displayName, + pendingFirstAgentMessageRename: false, + firstAgentMessageRenameError: null + }) + runtime.notifyFolderWorkspaceChanged() + return + } + store.setWorktreeMeta(worktreeId, { + displayName, + // The first-agent title is an intentional user-facing label; keep it stable after the + // generated branch is renamed and across subsequent catalog refreshes. + displayNameIsPinned: true, + pendingFirstAgentMessageRename: false, + // Success clears the failure badge (redundant with the explicit setRenameError(null)). + firstAgentMessageRenameError: null + }) + }, + renameWorktreeFolder: ENABLE_FIRST_WORK_FOLDER_RENAME + ? (worktreeId, newLeaf) => + renameWorktreeFolderOnFirstWork(worktreeId, newLeaf, { + getRepo: (repoId) => store.getRepo(repoId), + getSettings: () => store.getSettings(), + migrateWorktreeIdentity: (oldId, newId) => + store.migrateWorktreeIdentity(oldId, newId), + notifyWorktreeRenamed: (repoId, oldId, newId) => + runtime.notifyWorktreeFolderRenamed(repoId, oldId, newId), + pathExists: async (candidate) => existsSync(candidate), + moveWorktree + }) + : undefined, + setRenameError: (worktreeId, error, failureOutput) => { + // Refresh the full-output capture before the dedupe below — a repeat error string is still a fresh run. + rememberBranchRenameFailureOutput(worktreeId, error === null ? null : failureOutput) + // Skip the write + push when unchanged — most settled worktrees never had an error to clear. + const scope = parseWorkspaceKey(worktreeId) + if (scope?.type === 'folder') { + const current = store.getFolderWorkspace( + scope.folderWorkspaceId + )?.firstAgentMessageRenameError + if ((current ?? null) === (error ?? null)) { + return + } + store.updateFolderWorkspace(scope.folderWorkspaceId, { + firstAgentMessageRenameError: error + }) + runtime.notifyFolderWorkspaceChanged() + return + } + const current = store.getWorktreeMeta(worktreeId)?.firstAgentMessageRenameError + if ((current ?? null) === (error ?? null)) { + return + } + store.setWorktreeMeta(worktreeId, { firstAgentMessageRenameError: error }) + // Why: the hook only knows the worktreeId, so derive the repoId notifyBranchRenamed expects. + runtime.notifyBranchRenamed(getRepoIdFromWorktreeId(worktreeId)) + }, + resolveWorktreeIdForTab: (tabId) => store.getWorktreeIdForTab(tabId), + onRenamed: (repoIdOrWorktreeId) => { + if (parseWorkspaceKey(repoIdOrWorktreeId)?.type === 'folder') { + runtime.notifyFolderWorkspaceChanged() + return + } + runtime.notifyBranchRenamed(repoIdOrWorktreeId) + } + } + ) +} diff --git a/src/main/startup/codex-launch-preparation.ts b/src/main/startup/codex-launch-preparation.ts new file mode 100644 index 00000000000..3b94c00b11d --- /dev/null +++ b/src/main/startup/codex-launch-preparation.ts @@ -0,0 +1,95 @@ +import { app } from 'electron' +import type { CodexHomeLaunchContext } from '../ipc/pty' +import type { CodexAccountSelectionTarget } from '../codex-accounts/runtime-selection' +import { markCodexProjectTrusted } from '../agent-trust-presets' +import { codexHookService } from '../codex/hook-service' +import { getDefaultWslDistro } from '../wsl' +import { isAgentStatusHooksEnabled } from '../agent-hooks/managed-agent-hook-controls' +import { ensureRealHomeCodexHookState } from '../codex/codex-real-home-hook-install' +import { mainProcessState as state } from './main-process-state' + +export async function prepareCodexRuntimeHomeForLaunch( + target?: CodexAccountSelectionTarget, + launchEnv?: NodeJS.ProcessEnv, + launchContext?: CodexHomeLaunchContext +): Promise { + const runtimeHome = state.codexRuntimeHome + if (!runtimeHome) { + throw new Error('Codex runtime home service is not initialized') + } + if ( + target?.runtime !== 'wsl' && + launchContext?.launchAgent === 'codex' && + launchContext.workspacePath + ) { + try { + // Why: renderer quick-launch cannot await trust IPC before its PTY mounts; launch prep runs before every recognized Codex spawn. + await markCodexProjectTrusted(launchContext.workspacePath) + } catch (error) { + console.warn('[codex-project-trust] failed to pre-mark launch workspace:', error) + } + } + const ensureRealHomeHooksIfSelected = async (): Promise => { + if (target?.runtime === 'wsl' || !runtimeHome.isHostSystemDefaultRealHomeSelected(launchEnv)) { + return false + } + // Why (flag ON, system default): the hook entry must exist — appended last + // and trusted by codex's own app-server grant — in the real ~/.codex before + // the pane spawns. An incapable grant flips the lane gate so the launch + // below falls back to the managed home instead of a status-blind pane. + await ensureRealHomeCodexHookState({ + hooksEnabled: isAgentStatusHooksEnabled(state.store?.getSettings()), + userDataPath: app.getPath('userData') + }) + return true + } + let realHomeHooksPrepared = await ensureRealHomeHooksIfSelected() + // Why: a ManagedCodexHomeTemporarilyUnavailableError must escape uncaught — + // the fallbacks below all key off `null`, which means "system default", so + // swallowing the refusal would launch the wrong account (#STA-4422). + let runtimeHomePath = await runtimeHome.prepareForCodexLaunchAsync(target, launchEnv, { + unavailableManagedHomePath: launchContext?.unavailableManagedHomePath + }) + if (runtimeHomePath === null && !realHomeHooksPrepared) { + // Why: launch prep can reject an untrusted managed home and clear its + // selection. Establish hook capability for that newly selected lane, then + // re-resolve if the capability gate rejects it. + realHomeHooksPrepared = await ensureRealHomeHooksIfSelected() + if (realHomeHooksPrepared) { + runtimeHomePath = await runtimeHome.prepareForCodexLaunchAsync(target, launchEnv, { + unavailableManagedHomePath: launchContext?.unavailableManagedHomePath + }) + } + } + if (runtimeHomePath === null && target?.runtime !== 'wsl') { + // Why: Codex runs on the user's real ~/.codex; the managed-home hook + // install below would target a home Codex never reads on this lane. + return null + } + const hookTarget = + target?.runtime === 'wsl' + ? { runtime: 'wsl' as const, wslDistro: target.wslDistro?.trim() || getDefaultWslDistro() } + : target + const hooksEnabled = isAgentStatusHooksEnabled(state.store?.getSettings()) + try { + // Why: honor the persisted off switch so post-startup launches can't reinstall removed hooks. + const status = await codexHookService.prepareRuntimeHomeForLaunch( + runtimeHomePath, + hookTarget, + hooksEnabled + ) + if (status.state === 'error') { + console.warn( + `[codex-hook-service] failed to ${hooksEnabled ? 'refresh' : 'refresh user'} runtime hooks before launch`, + status.detail + ) + } + } catch (error) { + // Why: hook install is best-effort launch prep; a malformed hooks file must not block Codex from starting. + console.warn( + `[codex-hook-service] failed to ${hooksEnabled ? 'refresh' : 'refresh user'} runtime hooks before launch`, + error + ) + } + return runtimeHomePath +} diff --git a/src/main/startup/codex-session-resume-launch.ts b/src/main/startup/codex-session-resume-launch.ts new file mode 100644 index 00000000000..c537c4f75ef --- /dev/null +++ b/src/main/startup/codex-session-resume-launch.ts @@ -0,0 +1,120 @@ +import { app } from 'electron' +import type { AgentProviderSessionMetadata } from '../../shared/agent-session-resume' +import type { CodexAccountSelectionTarget } from '../codex-accounts/runtime-selection' +import type { CodexSessionResumePreparation } from '../codex/codex-session-resume-home' +import { prepareCodexSessionResume } from '../codex/codex-session-resume-preparation' +import { prepareLegacySharedCodexSessionResume } from '../codex/codex-legacy-session-resume' +import { ManagedCodexHomeTemporarilyUnavailableError } from '../codex-accounts/host-codex-managed-home-ownership' +import { codexHookService } from '../codex/hook-service' +import { ensureRealHomeCodexHookState } from '../codex/codex-real-home-hook-install' +import { isAgentStatusHooksEnabled } from '../agent-hooks/managed-agent-hook-controls' +import { markCodexProjectTrusted } from '../agent-trust-presets' +import { getOrcaManagedCodexHomePath, getSystemCodexHomePath } from '../codex/codex-home-paths' +import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path' +import { mainProcessState as state } from './main-process-state' + +export async function prepareCodexSessionResumeForLaunch(args: { + providerSession: AgentProviderSessionMetadata + target: CodexAccountSelectionTarget + launchEnv?: NodeJS.ProcessEnv + workspacePath?: string +}): Promise { + const runtimeHome = state.codexRuntimeHome + const store = state.store + if (args.target.runtime === 'wsl' || !runtimeHome || !store) { + return null + } + const systemHomePath = getSystemCodexHomePath() + // Why: codexSessionSourceHome is import-only; treating it as CODEX_HOME would mutate history sources and bypass account auth. + const trustedHomes = [systemHomePath, ...runtimeHome.getHostCodexHomePathsForSessionDiscovery()] + // Why: resolved eagerly, once, before any ranking or provenance match. The + // marker read used to be deferred into the ranking thunk so a + // provenance-present resume never paid for it, but that optimisation let an + // unreadable selected home reach the PTY as "no selection": the provenance + // branch simply omits the account from `trustedHomes` and another account's + // readable alias wins. A throw here refuses the whole resume instead + // (#STA-4422). + const selectedAccountCodexHome = runtimeHome.resolveSelectedHostAccountCodexHomePathForResume() + // Why: a `fresh` outcome must skip migration, trust and hook repair entirely — there is + // no verified origin home to prepare, so the PTY layer drops the resume argv (#10793). + const preparation = await prepareCodexSessionResume({ + sessionId: args.providerSession.id, + transcriptPath: args.providerSession.transcriptPath, + trustedCodexHomes: trustedHomes, + // Why: the legacy id rescan's winning home becomes this pane's CODEX_HOME, i.e. its account; + // rank it by the current selection so settings insertion order can never decide the account. + getSelectedAccountCodexHome: () => selectedAccountCodexHome, + systemCodexHomePath: systemHomePath, + // Why: the mirror winning is what triggers the migration into ~/.codex below, so it must + // outrank the path-sorted account homes or a system-default selection resumes as an account. + sharedRuntimeCodexHomePath: getOrcaManagedCodexHomePath(), + resolveVerifiedResumeHome: async (sessionSource) => { + let migrated = { useRealCodexHome: false } + try { + migrated = await prepareLegacySharedCodexSessionResume( + { + agent: 'codex', + executionHostId: 'local', + filePath: sessionSource.transcriptPath, + codexHome: sessionSource.homePath + }, + { + isHostSystemDefaultRealHome: () => runtimeHome.isHostSystemDefaultRealHome(), + systemCodexHomePath: systemHomePath + } + ) + } catch (error) { + // Why: this launch path pins CODEX_HOME to the account that OWNS the + // rollout and deliberately refuses to repin onto whichever account is + // selected now (#10793), so it does not wire + // getSelectedHostAccountCodexHomePath and this branch cannot fire today. + // It stays as a contract guard: the blanket catch below must never + // silently swallow a typed refusal if that ever changes. + if (error instanceof ManagedCodexHomeTemporarilyUnavailableError) { + throw error + } + // Why: migration is a compatibility repair; its failure must not prevent the PTY from resuming from its trusted origin home. + console.warn( + '[codex-session-resume] Legacy rollout migration failed; using origin home:', + error + ) + } + const resumeHome = migrated.useRealCodexHome ? systemHomePath : sessionSource.homePath + if (args.workspacePath) { + try { + await markCodexProjectTrusted(args.workspacePath) + } catch (error) { + console.warn('[codex-project-trust] failed to pre-mark resumed workspace:', error) + } + } + const isSystemHome = + normalizeRuntimePathForComparison(resumeHome) === + normalizeRuntimePathForComparison(systemHomePath) + const hooksEnabled = isAgentStatusHooksEnabled(store.getSettings()) + try { + if (isSystemHome) { + await ensureRealHomeCodexHookState({ + hooksEnabled, + userDataPath: app.getPath('userData') + }) + } else if (hooksEnabled) { + await codexHookService.installForLaunchPrep(resumeHome) + } else { + await codexHookService.refreshRuntimeUserHooksForLaunchPrep(resumeHome) + } + } catch (error) { + // Why: hook repair is best-effort; session provenance must still win over the currently selected home. + console.warn('[codex-hook-service] failed to prepare automatic resume home:', error) + } + return resumeHome + } + }) + return preparation.outcome === 'resume' + ? { + ...preparation, + reconcileSharedRuntimeAuth: + normalizeRuntimePathForComparison(preparation.codexHomePath) === + normalizeRuntimePathForComparison(getOrcaManagedCodexHomePath()) + } + : preparation +} diff --git a/src/main/startup/configure-process.test.ts b/src/main/startup/configure-process.test.ts index 232626ca9f5..4747f797cf9 100644 --- a/src/main/startup/configure-process.test.ts +++ b/src/main/startup/configure-process.test.ts @@ -775,9 +775,9 @@ describe('safe graphics mode startup switches', () => { // Why: the defect was the call site, not the switch — a win32 safe-graphics launch runs // `if (!gpuFallbackActiveThisLaunch) enableMainProcessGpuFeatures()` and skips everything // parked inside it, so only an unconditional call site reaches the users a GPU crash already hit. - it('calls the throttling opt-out outside the GPU-fallback gate in index.ts', () => { - const mainSource = readFileSync(join(__dirname, '..', 'index.ts'), 'utf8') - const gateStart = mainSource.indexOf('if (!gpuFallbackActiveThisLaunch) {') + it('calls the throttling opt-out outside the GPU-fallback gate in preflight', () => { + const mainSource = readFileSync(join(__dirname, 'main-process-preflight.ts'), 'utf8') + const gateStart = mainSource.indexOf('if (!state.gpuFallbackActiveThisLaunch) {') expect(gateStart).toBeGreaterThanOrEqual(0) const gateEnd = mainSource.indexOf('\n }', gateStart) expect(gateEnd).toBeGreaterThan(gateStart) @@ -789,14 +789,20 @@ describe('safe graphics mode startup switches', () => { // Why: Chromium consumes the command line at ready, so this must stay in the pre-ready // top-level block and never move into the whenReady callback, where appendSwitch is a silent // no-op — the same invisible failure as parking it behind the GPU gate. - it('appends the throttling opt-out before app ready in index.ts', () => { - const mainSource = readFileSync(join(__dirname, '..', 'index.ts'), 'utf8') - const readyStart = mainSource.indexOf('void app.whenReady()') + it('appends the throttling opt-out before app ready in preflight', () => { + const mainSource = readFileSync(join(__dirname, 'main-process-preflight.ts'), 'utf8') + const entrySource = readFileSync(join(__dirname, '..', 'index.ts'), 'utf8') + const preflightEnd = mainSource.indexOf('\n return true') + const readyStart = entrySource.indexOf('void app.whenReady()') + const preflightCall = entrySource.indexOf('runMainProcessPreflight({') + expect(preflightEnd).toBeGreaterThan(0) expect(readyStart).toBeGreaterThan(0) + expect(preflightCall).toBeGreaterThanOrEqual(0) + expect(preflightCall).toBeLessThan(readyStart) const callIndex = mainSource.indexOf('optOutOfHiddenPageWakeUpThrottling()') expect(callIndex).toBeGreaterThan(0) - expect(callIndex).toBeLessThan(readyStart) + expect(callIndex).toBeLessThan(preflightEnd) }) // Why: Chromium enables IntensiveWakeUpThrottling on every desktop platform, so the opt-out diff --git a/src/main/startup/desktop-startup-ordering.test.ts b/src/main/startup/desktop-startup-ordering.test.ts index 58339246287..e432ed383d2 100644 --- a/src/main/startup/desktop-startup-ordering.test.ts +++ b/src/main/startup/desktop-startup-ordering.test.ts @@ -4,17 +4,35 @@ import { describe, expect, it } from 'vitest' describe('startup ordering', () => { it('passes the startup barrier into PTY handlers without blocking window creation', () => { - const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') - const attachStart = source.indexOf('attachMainWindowServices(') - const attachEnd = source.indexOf('rateLimits.attach(window)', attachStart) - const attachBlock = source.slice(attachStart, attachEnd) + const attachSource = readFileSync( + join(process.cwd(), 'src/main/window/attach-main-window-services.ts'), + 'utf8' + ) + const startupSource = readFileSync( + join(process.cwd(), 'src/main/startup/main-process-pty-startup.ts'), + 'utf8' + ) + const coreSource = readFileSync( + join(process.cwd(), 'src/main/startup/main-window-core-services.ts'), + 'utf8' + ) + const runtimeLaunchSource = readFileSync( + join(process.cwd(), 'src/main/startup/main-process-runtime-launch.ts'), + 'utf8' + ) + const attachStart = attachSource.indexOf('export function attachMainWindowServices(') + const attachEnd = attachSource.indexOf(' registerSshHandlers(', attachStart) + const attachBlock = attachSource.slice(attachStart, attachEnd) // Why: anchor on the destructure head only — the settled-result variable's name is not the // contract, and pinning it turns a rename into a cryptic `expected -1` failure here. - const desktopStart = source.indexOf('const [win') + const desktopStart = runtimeLaunchSource.indexOf('async function launchDesktopMode(') // Why: anchor on code, not a comment — the previous comment anchor was silently reworded, so // this was -1 and sliced to EOF, letting the assertions below pass against never-run code. - const desktopEnd = source.indexOf("win.once('show'", desktopStart) - const desktopStartup = source.slice(desktopStart, desktopEnd) + const desktopEnd = runtimeLaunchSource.indexOf( + '\nexport async function initializeMainProcessRuntimeLaunch', + desktopStart + ) + const desktopStartup = runtimeLaunchSource.slice(desktopStart, desktopEnd) // Why: bound every anchor, not just the desktop pair — an unresolved one slices to EOF. expect(attachStart).toBeGreaterThanOrEqual(0) @@ -22,14 +40,20 @@ describe('startup ordering', () => { expect(desktopStart).toBeGreaterThanOrEqual(0) expect(desktopEnd).toBeGreaterThan(desktopStart) - expect(attachBlock).toContain('awaitLocalPtyStartup: () => localPtyStartupReady') - expect(attachBlock).toContain( - 'awaitLocalPtyProviderStartup: () => localPtyProviderStartupReady' + expect(coreSource).toContain('awaitLocalPtyStartup: () => state.localPtyStartupReady') + expect(coreSource).toContain( + 'awaitLocalPtyProviderStartup: () => state.localPtyProviderStartupReady' ) - expect(source).toContain( + expect(attachBlock).toContain('awaitLocalPtyStartup: options?.awaitLocalPtyStartup') + expect(attachBlock).toContain( + 'awaitLocalPtyProviderStartup: options?.awaitLocalPtyProviderStartup' + ) + expect(startupSource).toContain( 'firstWindowStartupServicesReady = services.then((value) => value.firstWindowReady)' ) - expect(source).toContain('localPtyStartupReady = services.then((value) => value.localPtyReady)') + expect(startupSource).toContain( + 'localPtyStartupReady = services.then((value) => value.localPtyReady)' + ) const windowIndex = desktopStartup.indexOf('Promise.resolve(desktopWindow ?? openMainWindow())') const rpcStartIndex = desktopStartup.indexOf('desktopRuntimeRpc.start()') @@ -50,25 +74,43 @@ describe('startup ordering', () => { }) it('resolves the browser hosting identity with nothing awaited before it', () => { - const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') - const readyIndex = source.indexOf('app.whenReady().then(') - const initIndex = source.indexOf('initializeBrowserClientHostId(') + const entrySource = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') + const foundationSource = readFileSync( + join(process.cwd(), 'src/main/startup/main-process-ready-foundation.ts'), + 'utf8' + ) + const readyIndex = entrySource.indexOf('void app.whenReady().then(async () => {') + const initReadyIndex = entrySource.indexOf('initializeMainProcessReady({') + const profileIndex = foundationSource.indexOf('const profile = ensureActiveOrcaProfile()') + const initIndex = foundationSource.indexOf( + 'initializeBrowserClientHostId(profile.profileDirectory)' + ) expect(readyIndex).toBeGreaterThanOrEqual(0) - expect(initIndex).toBeGreaterThan(readyIndex) + expect(initReadyIndex).toBeGreaterThan(readyIndex) + expect(profileIndex).toBeGreaterThanOrEqual(0) + expect(initIndex).toBeGreaterThan(profileIndex) // Why nothing may be awaited first: the identity is stamped into the renderer's argv when the // window is created, and a suspension here lets a window be created against a process-local // stand-in that the durable id then contradicts. The constraint is positional, so only a source // census can hold it — no behavioural test distinguishes "resolved" from "resolved in time". - expect(source.slice(readyIndex, initIndex)).not.toMatch(/\bawait\b/) + expect(foundationSource.slice(profileIndex, initIndex)).not.toMatch(/\bawait\b/) // Why the count: a second call site would leave the ordering claim above ambiguous. - expect(source.split('initializeBrowserClientHostId(')).toHaveLength(2) + expect(foundationSource.split('initializeBrowserClientHostId(')).toHaveLength(2) }) it('requires daemon authority before restored-subagent liveness runs', () => { - const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') - const sweepStart = source.indexOf('function reapRestoredSubagentsWithoutLiveAgent()') - const sweepEnd = source.indexOf('function startTerminalRuntimeStartupServices()', sweepStart) + const source = readFileSync( + join(process.cwd(), 'src/main/startup/main-process-pty-startup.ts'), + 'utf8' + ) + const sweepStart = source.indexOf( + 'export async function reapRestoredSubagentsWithoutLiveAgent()' + ) + const sweepEnd = source.indexOf( + 'export function startTerminalRuntimeStartupServices()', + sweepStart + ) const sweep = source.slice(sweepStart, sweepEnd) expect(sweepStart).toBeGreaterThanOrEqual(0) @@ -79,47 +121,53 @@ describe('startup ordering', () => { }) it('bounds WSL reconciliation before serve RPC while leaving desktop startup independent', () => { - const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') - const barrierStart = source.indexOf("ipcMain.handle('app:awaitFirstWindowStartupServices'") - const barrierEnd = source.indexOf("'app:startupDiagnostic'", barrierStart) - const barrier = source.slice(barrierStart, barrierEnd) - const reconciliationStart = source.indexOf( - 'managedWslCliReconciliationReady = reconcileManagedWslCliRegistrations(' + const barrierSource = readFileSync( + join(process.cwd(), 'src/main/startup/main-process-ipc-bootstrap.ts'), + 'utf8' ) - const serveStart = source.indexOf('if (serveOptions) {', reconciliationStart) - const serveReady = source.indexOf('await printServeReady(serveOptions)', serveStart) - const serveEnd = source.indexOf('return', serveReady) - const desktopWindowStart = source.indexOf( - 'const desktopStartup = startWindowsDesktopBeforeShellPathReady(' + const foundationSource = readFileSync( + join(process.cwd(), 'src/main/startup/main-process-ready-foundation.ts'), + 'utf8' ) - const desktopWindowJoin = source.indexOf( - 'Promise.resolve(desktopWindow ?? openMainWindow())', - serveEnd + const runtimeSource = readFileSync( + join(process.cwd(), 'src/main/startup/main-process-runtime-launch.ts'), + 'utf8' ) - const serveStartup = source.slice(serveStart, serveEnd) - const desktopStartup = source.slice(reconciliationStart, serveStart) + const barrierStart = barrierSource.indexOf( + "ipcMain.handle('app:awaitFirstWindowStartupServices'" + ) + const barrierEnd = barrierSource.indexOf("'app:startupDiagnostic'", barrierStart) + const barrier = barrierSource.slice(barrierStart, barrierEnd) + const reconciliationStart = foundationSource.indexOf( + 'state.managedWslCliReconciliationReady = reconcileManagedWslCliRegistrations(' + ) + const serveStart = runtimeSource.indexOf('async function launchServeMode(') + const serveEnd = runtimeSource.indexOf('\nasync function launchDesktopMode', serveStart) + const serveStartup = runtimeSource.slice(serveStart, serveEnd) + const desktopStart = runtimeSource.indexOf( + " if (process.platform === 'win32' && app.isPackaged && !serveOptions)" + ) + const desktopEnd = runtimeSource.indexOf(" app.on('activate'", desktopStart) + const desktopStartup = runtimeSource.slice(desktopStart, desktopEnd) expect(barrierStart).toBeGreaterThanOrEqual(0) expect(barrierEnd).toBeGreaterThan(barrierStart) expect(reconciliationStart).toBeGreaterThanOrEqual(0) - expect(serveStart).toBeGreaterThan(reconciliationStart) + expect(serveStart).toBeGreaterThanOrEqual(0) expect(serveEnd).toBeGreaterThan(serveStart) - // Why: bound against serveEnd, not reconciliationStart — an earlier openMainWindow() call - // would steal this anchor, collapse desktopStartup to '', and pass the negative check below. - expect(desktopWindowStart).toBeGreaterThan(reconciliationStart) - expect(desktopWindowStart).toBeLessThan(serveStart) - expect(desktopWindowJoin).toBeGreaterThan(serveEnd) - expect(serveStartup).toContain('await managedWslCliStartupBarrierReady') - expect(serveStartup).not.toContain('await managedWslCliReconciliationReady') - expect(serveStartup.indexOf('await managedWslCliStartupBarrierReady')).toBeLessThan( + expect(desktopStart).toBeGreaterThanOrEqual(0) + expect(desktopEnd).toBeGreaterThan(desktopStart) + expect(serveStartup).toContain('await state.managedWslCliStartupBarrierReady') + expect(serveStartup).not.toContain('await state.managedWslCliReconciliationReady') + expect(serveStartup.indexOf('await state.managedWslCliStartupBarrierReady')).toBeLessThan( serveStartup.indexOf('await runtimeRpc.start()') ) - expect(desktopStartup).not.toContain('await managedWslCliReconciliationReady') + expect(desktopStartup).not.toContain('await state.managedWslCliReconciliationReady') expect(desktopStartup).toContain( "process.platform === 'win32' && app.isPackaged && !serveOptions" ) expect(desktopStartup).toContain( - 'openWindow: () => openMainWindow({ revealOnDidFinishLoad: true })' + 'openWindow: () => options.openMainWindow({ revealOnDidFinishLoad: true })' ) expect(desktopStartup).toContain('bindServices: bindTerminalRuntimeStartupServices') expect(desktopStartup).toContain('shellPathReady,') @@ -129,31 +177,32 @@ describe('startup ordering', () => { expect(barrier).toContain("ipcMain.handle('app:recoverLegacyWorkerTerminalsForRendererStartup'") expect(barrier).toContain('recoverLegacyWorkerTerminalsForRendererStartup({') expect(barrier).toContain('localPtyProviderStartupReady,') - expect(barrier).toContain('await runtime?.refreshRestoredOrchestrationAuthority()') + expect(barrier).toContain('await state.runtime?.refreshRestoredOrchestrationAuthority()') expect(barrier).toContain( - 'return runtime?.reconcileLegacyWorkerTerminals({ materializeRenderer: true })' + 'return state.runtime?.reconcileLegacyWorkerTerminals({ materializeRenderer: true })' ) }) it('reconciles retained Codex homes after authoritative daemon inventory', () => { - const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') - const daemonInitIndex = source.indexOf('await initDaemonPtyProvider(signal') - const retainedPaneGateIndex = source.indexOf( + const source = readFileSync( + join(process.cwd(), 'src/main/startup/main-process-pty-startup.ts'), + 'utf8' + ) + const startupStart = source.indexOf('export function startTerminalRuntimeStartupServices()') + expect(startupStart).toBeGreaterThanOrEqual(0) + const startup = source.slice(startupStart) + const daemonInitIndex = startup.indexOf('await initDaemonPtyProvider(signal') + const retainedPaneGateIndex = startup.indexOf( 'hasRecordedManagedHostCodexPane()', daemonInitIndex ) - const inventoryIndex = source.indexOf('await listLiveDaemonPtyIds()', daemonInitIndex) - const reconciliation = 'codexRuntimeHome?.reconcileLegacySharedHomeForRetainedPanes()' - const reconciliationIndex = source.indexOf(reconciliation, inventoryIndex) - const hookReconciliationIndex = source.indexOf( + const inventoryIndex = startup.indexOf('await listLiveDaemonPtyIds()', daemonInitIndex) + const reconciliation = 'state.codexRuntimeHome?.reconcileLegacySharedHomeForRetainedPanes()' + const reconciliationIndex = startup.indexOf(reconciliation, inventoryIndex) + const hookReconciliationIndex = startup.indexOf( 'reconcileRetainedCodexHookHomes({', inventoryIndex ) - const serveIndex = source.indexOf('if (serveOptions) {', reconciliationIndex) - const desktopIndex = source.indexOf( - 'Promise.resolve(desktopWindow ?? openMainWindow())', - serveIndex - ) expect(daemonInitIndex).toBeGreaterThanOrEqual(0) expect(retainedPaneGateIndex).toBeGreaterThan(daemonInitIndex) @@ -162,35 +211,51 @@ describe('startup ordering', () => { expect(hookReconciliationIndex).toBeGreaterThan(inventoryIndex) expect(hookReconciliationIndex).toBeLessThan(reconciliationIndex) expect(reconciliationIndex).toBeGreaterThan(inventoryIndex) - expect(serveIndex).toBeGreaterThan(reconciliationIndex) - expect(desktopIndex).toBeGreaterThan(serveIndex) - expect(source.split(reconciliation)).toHaveLength(2) + // The call is intentionally kept after the authoritative inventory; anchoring on the state + // receiver avoids matching any prose that mentions the same operation. + expect(startup).toContain(reconciliation) }) it('exposes managed WSL reconciliation status to headless serve clients and diagnostics', () => { - const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') + const serveSource = readFileSync( + join(process.cwd(), 'src/main/startup/main-process-serve.ts'), + 'utf8' + ) + const runtimeSource = readFileSync( + join(process.cwd(), 'src/main/startup/main-process-runtime-launch.ts'), + 'utf8' + ) + const foundationSource = readFileSync( + join(process.cwd(), 'src/main/startup/main-process-ready-foundation.ts'), + 'utf8' + ) // Why: the barrier fails open, so the serve-ready payload must carry the // reconciliation state and the bounded wait must be traceable via a milestone. - const readyStart = source.indexOf('await serveReadinessPublisher.publish(') - const readyEnd = source.indexOf('pairing: pairing.available', readyStart) - const readyPayload = source.slice(readyStart, readyEnd) + const readyStart = serveSource.indexOf('await state.serveReadinessPublisher.publish(') + const readyEnd = serveSource.indexOf('pairing: pairing.available', readyStart) + const readyPayload = serveSource.slice(readyStart, readyEnd) // Why: unbounded, a renamed pairing key slices to EOF and the status only has to survive // somewhere later in the file — not in the serve-ready payload this test is about. expect(readyStart).toBeGreaterThanOrEqual(0) expect(readyEnd).toBeGreaterThan(readyStart) - expect(readyPayload).toContain('managedWslCliReconciliation: managedWslCliReconciliationStatus') + expect(readyPayload).toContain( + 'managedWslCliReconciliation: state.managedWslCliReconciliationStatus' + ) - expect(source).toContain("managedWslCliReconciliationStatus = 'pending'") - expect(source).toContain("managedWslCliReconciliationStatus = 'settled'") - expect(source).toContain("managedWslCliReconciliationStatus = 'failed'") - expect(source).toContain("logStartupMilestone('wsl-cli-barrier-resolved'") + expect(foundationSource).toContain("state.managedWslCliReconciliationStatus = 'pending'") + expect(foundationSource).toContain("state.managedWslCliReconciliationStatus = 'settled'") + expect(foundationSource).toContain("state.managedWslCliReconciliationStatus = 'failed'") + expect(runtimeSource).toContain("logStartupMilestone('wsl-cli-barrier-resolved'") }) it('notifies the serve supervisor only after publishing readiness', () => { - const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') - const readyStart = source.indexOf('await serveReadinessPublisher.publish(') + const source = readFileSync( + join(process.cwd(), 'src/main/startup/main-process-serve.ts'), + 'utf8' + ) + const readyStart = source.indexOf('await state.serveReadinessPublisher.publish(') const supervisorReady = source.indexOf('notifyServeSupervisorReady(', readyStart) expect(readyStart).toBeGreaterThanOrEqual(0) @@ -198,7 +263,10 @@ describe('startup ordering', () => { }) it('does not run the rate-limit quota fetch before the first window can show results', () => { - const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') + const source = readFileSync( + join(process.cwd(), 'src/main/startup/main-window-core-services.ts'), + 'utf8' + ) const attachIndex = source.indexOf('rateLimits.attach(window)') const startIndex = source.indexOf('rateLimits.start({ fetchImmediately: false })') @@ -207,60 +275,67 @@ describe('startup ordering', () => { }) it('wires bounded teardown state to reporting but not recovery or close behavior', () => { - const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') - const scopeStart = source.indexOf('function getExpectedTeardownScope(') - const scopeEnd = source.indexOf('function markRecoveryReloadInFlight(', scopeStart) - const scope = source.slice(scopeStart, scopeEnd) - const windowStart = source.indexOf('const window = createMainWindow(store, {') - const windowEnd = source.indexOf('onRendererRecoveryExhausted:', windowStart) - const windowOptions = source.slice(windowStart, windowEnd) - const recorderStart = source.indexOf('function recordProcessGoneCrash(') - const recorderEnd = source.indexOf('function shutdownWatchersOnce(', recorderStart) - const recorder = source.slice(recorderStart, recorderEnd) + const lifecycleSource = readFileSync( + join(process.cwd(), 'src/main/startup/main-window-lifecycle-flags.ts'), + 'utf8' + ) + const windowSource = readFileSync( + join(process.cwd(), 'src/main/startup/main-window-controller.ts'), + 'utf8' + ) - expect(scopeStart).toBeGreaterThanOrEqual(0) - expect(scopeEnd).toBeGreaterThan(scopeStart) - expect(scope).toContain('resolveExpectedTeardownScope({') - expect(scope).toContain('includeSystemSessionEnd') - expect(windowStart).toBeGreaterThanOrEqual(0) - expect(windowEnd).toBeGreaterThan(windowStart) - expect(windowOptions).toContain('getIsQuitting: () => isQuitting') - expect(windowOptions).toContain( + expect(lifecycleSource).toContain('export function getExpectedTeardownScope(') + expect(lifecycleSource).toContain('resolveExpectedTeardownScope({') + expect(lifecycleSource).toContain('includeSystemSessionEnd') + expect(windowSource).toContain('const window = createMainWindow(store, {') + expect(windowSource).toContain('getIsQuitting: () => state.isQuitting') + expect(windowSource).toContain( 'expectedTeardown: getExpectedTeardownScope(webContentsId, false)' ) - expect(recorderStart).toBeGreaterThanOrEqual(0) - expect(recorderEnd).toBeGreaterThan(recorderStart) - expect(recorder).toContain('expectedTeardown: getExpectedTeardownScope(webContentsId)') + expect(lifecycleSource).toContain('expectedTeardown: getExpectedTeardownScope(webContentsId)') }) it('attaches renderer services before starting the TCC prompt watcher', () => { - const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') - const attachIndex = source.indexOf('attachMainWindowServices(') - const tccNoticeIndex = source.indexOf('initTccPromptNotice(window', attachIndex) - const quitAbortStart = source.indexOf('onQuitAborted:') - const quitAbortEnd = source.indexOf('onRendererProcessGone:', quitAbortStart) + const coreSource = readFileSync( + join(process.cwd(), 'src/main/startup/main-window-core-services.ts'), + 'utf8' + ) + const windowSource = readFileSync( + join(process.cwd(), 'src/main/startup/main-window-controller.ts'), + 'utf8' + ) + const quitSource = readFileSync( + join(process.cwd(), 'src/main/startup/main-process-quit.ts'), + 'utf8' + ) + const attachIndex = coreSource.indexOf('attachMainWindowServices(') + const tccNoticeIndex = coreSource.indexOf('initTccPromptNotice(window', attachIndex) + const quitAbortStart = windowSource.indexOf('onQuitAborted:') + const quitAbortEnd = windowSource.indexOf('onRendererProcessGone:', quitAbortStart) expect(attachIndex).toBeGreaterThanOrEqual(0) expect(tccNoticeIndex).toBeGreaterThan(attachIndex) - expect(source.slice(tccNoticeIndex, tccNoticeIndex + 120)).toContain( + expect(coreSource.slice(tccNoticeIndex, tccNoticeIndex + 120)).toContain( 'deferWatchUntilReadyToShow: true' ) - expect(source.slice(quitAbortStart, quitAbortEnd)).not.toContain('initTccPromptNotice') - expect(source).toContain("process.once('exit', stopTccPromptNotice)") - const willQuitStart = source.indexOf("app.on('will-quit'") - const windowAllClosedStart = source.indexOf("app.on('window-all-closed'", willQuitStart) - expect(source.slice(willQuitStart, windowAllClosedStart)).toContain('stopTccPromptNotice()') - expect(source.slice(0, willQuitStart)).not.toContain('stopTccPromptNoticeForQuit') + expect(windowSource.slice(quitAbortStart, quitAbortEnd)).not.toContain('initTccPromptNotice') + expect(quitSource).toContain("process.once('exit', stopTccPromptNotice)") + const willQuitStart = quitSource.indexOf("app.on('will-quit'") + expect(quitSource.slice(willQuitStart)).toContain('stopTccPromptNotice()') + expect(quitSource).not.toContain('stopTccPromptNoticeForQuit') }) it('keeps the power bridge through vetoable before-quit and disposes after commit', () => { - const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') + const source = readFileSync( + join(process.cwd(), 'src/main/startup/main-process-quit.ts'), + 'utf8' + ) const beforeQuitStart = source.indexOf("app.on('before-quit'") const willQuitStart = source.indexOf("app.on('will-quit'", beforeQuitStart) const windowAllClosedStart = source.indexOf("app.on('window-all-closed'", willQuitStart) const beforeQuit = source.slice(beforeQuitStart, willQuitStart) const willQuit = source.slice(willQuitStart, windowAllClosedStart) - const commitIndex = willQuit.indexOf('quitTeardownStartGate.tryStart(e)') + const commitIndex = willQuit.indexOf('quitTeardownStartGate.tryStart(event)') const disposeIndex = willQuit.indexOf('unsubscribeSystemResumeBroadcast?.()') expect(beforeQuitStart).toBeGreaterThanOrEqual(0) @@ -272,7 +347,10 @@ describe('startup ordering', () => { }) it('joins structured agent sessions to the committed quit barrier', () => { - const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') + const source = readFileSync( + join(process.cwd(), 'src/main/startup/main-process-quit.ts'), + 'utf8' + ) const willQuitStart = source.indexOf("app.on('will-quit'") const willQuitEnd = source.indexOf("app.on('window-all-closed'", willQuitStart) const willQuit = source.slice(willQuitStart, willQuitEnd) @@ -286,7 +364,10 @@ describe('startup ordering', () => { }) it('joins agent-browser cleanup before the committed quit exits', () => { - const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') + const source = readFileSync( + join(process.cwd(), 'src/main/startup/main-process-quit.ts'), + 'utf8' + ) const willQuitStart = source.indexOf("app.on('will-quit'") const windowAllClosedStart = source.indexOf("app.on('window-all-closed'", willQuitStart) const willQuit = source.slice(willQuitStart, windowAllClosedStart) @@ -309,8 +390,11 @@ describe('startup ordering', () => { }) it('registers repeatable serve signal handling before headless startup completes', () => { - const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') - const serveStart = source.indexOf('if (serveOptions) {') + const source = readFileSync( + join(process.cwd(), 'src/main/startup/main-process-runtime-launch.ts'), + 'utf8' + ) + const serveStart = source.indexOf('async function launchServeMode(') const signalHandlers = source.indexOf('registerServeSignalHandlers(process', serveStart) const serveReady = source.indexOf('await printServeReady(serveOptions)', serveStart) @@ -320,22 +404,34 @@ describe('startup ordering', () => { }) it('starts the automation scheduler before headless serve reports ready', () => { - const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') - const serveStart = source.indexOf('if (serveOptions) {') + const source = readFileSync( + join(process.cwd(), 'src/main/startup/main-process-runtime-launch.ts'), + 'utf8' + ) + const windowSource = readFileSync( + join(process.cwd(), 'src/main/startup/main-window-core-services.ts'), + 'utf8' + ) + const serveStart = source.indexOf('async function launchServeMode(') const serveReady = source.indexOf('await printServeReady(serveOptions)', serveStart) - const serveReturn = source.indexOf('return', serveReady) + const serveEnd = source.indexOf('\nasync function launchDesktopMode', serveStart) const runtimeRpcStart = source.indexOf('await runtimeRpc.start()', serveStart) - const automationStart = source.indexOf('automations.start()', serveStart) - const desktopSetWebContents = source.indexOf('automations.setWebContents(window.webContents)') - const desktopAutomationStart = source.indexOf('automations.start()', desktopSetWebContents + 1) + const automationStart = source.indexOf('state.automations?.start()', serveStart) + const desktopSetWebContents = windowSource.indexOf( + 'automations.setWebContents(window.webContents)' + ) + const desktopAutomationStart = windowSource.indexOf( + 'automations.start()', + desktopSetWebContents + 1 + ) expect(serveStart).toBeGreaterThanOrEqual(0) expect(serveReady).toBeGreaterThan(serveStart) - expect(serveReturn).toBeGreaterThan(serveReady) + expect(serveEnd).toBeGreaterThan(serveReady) expect(runtimeRpcStart).toBeGreaterThan(serveStart) expect(automationStart).toBeGreaterThan(runtimeRpcStart) expect(automationStart).toBeLessThan(serveReady) - expect(automationStart).toBeLessThan(serveReturn) + expect(automationStart).toBeLessThan(serveEnd) expect(desktopSetWebContents).toBeGreaterThanOrEqual(0) expect(desktopAutomationStart).toBeGreaterThan(desktopSetWebContents) }) @@ -345,28 +441,35 @@ describe('startup ordering', () => { // scope that accessor throws by design, so every `orca serve` process on macOS died at startup // before it could listen. serve-update-handoff.test.ts mocks the resolver, so only ordering // catches this; serve-update-handoff.app-environment.test.ts pins the throw it depends on. - const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') - const install = 'installServeSupervisorDisconnectQuit(isServeMode)' + const source = readFileSync( + join(process.cwd(), 'src/main/startup/main-process-preflight.ts'), + 'utf8' + ) + const install = 'installServeSupervisorDisconnectQuit(state.isServeMode)' const appEnvironmentIndex = source.indexOf('setAppEnvironment(new ElectronAppEnvironment())') const dataPathIndex = source.indexOf('initDataPath()') const installIndex = source.indexOf(install) + // Why this anchor: preflight returns early when the lock is lost, so this gate is the split's + // equivalent of the old `if (hasSingleInstanceLock)` block head. + const lockGateIndex = source.indexOf('if (!hasLock) {') expect(source.split(install).length - 1, `${install} should appear exactly once`).toBe(1) expect(appEnvironmentIndex).toBeGreaterThanOrEqual(0) expect(dataPathIndex).toBeGreaterThan(appEnvironmentIndex) expect(installIndex).toBeGreaterThan(dataPathIndex) + expect(lockGateIndex).toBeGreaterThanOrEqual(0) + expect(installIndex).toBeGreaterThan(lockGateIndex) - // Why also pin it synchronous: 'disconnect' cannot be delivered while this module is still - // evaluating, which is the whole reason deferring it is free. Parked behind an await — say + // Why also pin it synchronous: 'disconnect' cannot be delivered while preflight is still + // running, which is the whole reason deferring it is free. Parked behind an await — say // inside app.whenReady() — the ordering above still holds but a parent that dies in the gap // leaves the serve process orphaned on its port, which is the failure this handler prevents. - expect(installIndex).toBeLessThan(source.indexOf('void app.whenReady().then(')) - expect(installIndex).toBeGreaterThan(source.indexOf('if (hasSingleInstanceLock) {')) - // Why only statements at block indentation: the span now covers unrelated helper functions, - // and an `await` inside one of those bodies is not what this guards against — the risk is this - // call itself being parked behind one. + expect(source).toContain('export function runMainProcessPreflight(') + // Why only statements at block indentation: the span covers unrelated helper bodies, and an + // `await` inside one of those is not what this guards against — the risk is this call itself + // being parked behind one. const blockStatements = source - .slice(source.indexOf('if (hasSingleInstanceLock) {'), installIndex) + .slice(lockGateIndex, installIndex) .split('\n') .filter((line) => /^ {2}\S/.test(line) && !line.trim().startsWith('//')) .join('\n') diff --git a/src/main/startup/gpu-lifecycle.ts b/src/main/startup/gpu-lifecycle.ts new file mode 100644 index 00000000000..da852bcb491 --- /dev/null +++ b/src/main/startup/gpu-lifecycle.ts @@ -0,0 +1,183 @@ +import { app, type BrowserWindow } from 'electron' +import { relaunchApp } from '../app-relaunch' +import { destroySystemTray } from '../tray/system-tray' +import { applyGpuFallbackCommandLineSwitches } from './gpu-fallback-switches' +import { + clearGpuFallbackMarker, + readActiveGpuFallbackMarker, + writeGpuFallbackMarker, + type WindowsGpuFallbackEnvironment +} from './gpu-fallback-marker' +import { + handleGpuFallbackRecoveredLaunch, + promptForGpuFallbackRecoveredLaunch +} from '../crash-reporting/gpu-fallback-recovered-launch' +import { promptForGpuFallbackRestart } from '../crash-reporting/gpu-fallback-restart-prompt' +import { engageGpuFallbackAfterCrashBurst } from '../crash-reporting/gpu-fallback-engagement' +import { recordCrashBreadcrumb } from '../crash-reporting/crash-breadcrumb-store' +import { recordDurableCrashBreadcrumb } from '../crash-reporting/durable-crash-breadcrumb' +import { mainProcessState as state, gpuFallbackEnvironment } from './main-process-state' +import { createGpuAccelerationAboutPanelOptions } from '../menu/gpu-acceleration-about-panel' + +export function updateGpuAccelerationAboutPanel(): void { + app.setAboutPanelOptions( + createGpuAccelerationAboutPanelOptions({ + appName: app.name, + appVersion: app.getVersion(), + platform: process.platform, + gpuFallbackActive: state.gpuFallbackActiveThisLaunch, + gpuFeatureStatus: state.gpuFeatureStatus + }) + ) +} + +function getWindowsGpuFallbackEnvironment(): WindowsGpuFallbackEnvironment | null { + const environment = gpuFallbackEnvironment() + return environment.platform === 'win32' ? { ...environment, platform: 'win32' } : null +} + +// Writes both crash-time and post-recovery consent states through one build-scoped path. +function persistGpuFallbackMarker( + userDataPath: string, + info: { engagedAt: number; crashesInWindow: number; userConfirmed: boolean } +): boolean { + const environment = getWindowsGpuFallbackEnvironment() + if (!environment) { + return false + } + try { + writeGpuFallbackMarker(userDataPath, info, environment) + return true + } catch (error) { + console.warn('[gpu-fallback] failed to persist marker:', error) + return false + } +} + +// Read before app.whenReady() so app.disableHardwareAcceleration() takes effect. Windows desktop only. +export function maybeApplyGpuFallbackForThisLaunch(): void { + if (state.isServeMode || process.platform !== 'win32') { + return + } + const marker = readActiveGpuFallbackMarker(app.getPath('userData'), gpuFallbackEnvironment()) + if (!marker) { + return + } + state.activeGpuFallbackMarker = marker + app.disableHardwareAcceleration() + const appliedSwitches = applyGpuFallbackCommandLineSwitches(app.commandLine, process.platform) + state.gpuFallbackActiveThisLaunch = true + // Why: with no GPU child left, child-process-gone can't report a GPU fault, so + // name the applied switches in the trail any later crash report carries. + recordCrashBreadcrumb('gpu_fallback_applied', { + crashesInWindow: marker.crashesInWindow, + switches: appliedSwitches.join(',') + }) +} + +export async function presentGpuFallbackRecoveredLaunchPrompt( + window: BrowserWindow +): Promise { + const marker = state.activeGpuFallbackMarker + if (!marker || marker.userConfirmed || window.isDestroyed() || state.isQuitting) { + return + } + // One prompt per process. A failure leaves the on-disk marker unconfirmed so the next launch retries. + state.activeGpuFallbackMarker = null + const userDataPath = app.getPath('userData') + await handleGpuFallbackRecoveredLaunch({ + isQuitting: () => state.isQuitting, + prompt: () => promptForGpuFallbackRecoveredLaunch(window), + confirmSafeGraphics: () => { + persistGpuFallbackMarker(userDataPath, { + engagedAt: marker.engagedAt, + crashesInWindow: marker.crashesInWindow, + userConfirmed: true + }) + }, + clearSafeGraphics: () => clearGpuFallbackMarker(userDataPath), + onPromptFailed: (error) => + console.warn('[gpu-fallback] failed to show recovered-launch prompt:', error), + onSafeGraphicsKept: () => + recordDurableCrashBreadcrumb('gpu_fallback_safe_graphics_kept', { + crashesInWindow: marker.crashesInWindow + }), + restartWithHardware: () => { + state.isQuitting = true + relaunchApp('gpu-fallback', { + mode: 'hardware-retry', + crashesInWindow: marker.crashesInWindow + }) + destroySystemTray() + app.exit(0) + } + }) +} + +// Why: a burst of GPU child crashes means HW acceleration is unusable — persist a build-scoped marker and offer software rendering. +export async function handleGpuChildCrash( + reason: string, + exitCode: number | null, + crashedAt: number +): Promise { + // Software rendering already active or shutting down: nothing more to do. + if (state.gpuFallbackActiveThisLaunch || state.isQuitting || state.isServeMode) { + return + } + const result = state.gpuCrashFallbackTracker.recordGpuCrash(crashedAt) + if (!result.shouldEngageFallback) { + return + } + const fallbackData = { processReason: reason, exitCode, crashesInWindow: result.crashesInWindow } + const userDataPath = app.getPath('userData') + await engageGpuFallbackAfterCrashBurst( + { reason, exitCode, crashesInWindow: result.crashesInWindow, engagedAt: Date.now() }, + { + isQuitting: () => state.isQuitting, + onEngaged: (engagement) => + recordCrashBreadcrumb('gpu_fallback_engaged', { + reason: engagement.reason, + exitCode: engagement.exitCode, + crashesInWindow: engagement.crashesInWindow + }), + persistMarker: (engagement) => + persistGpuFallbackMarker(userDataPath, { + engagedAt: engagement.engagedAt, + crashesInWindow: engagement.crashesInWindow, + userConfirmed: false + }), + confirmMarker: (engagement) => { + persistGpuFallbackMarker(userDataPath, { + engagedAt: engagement.engagedAt, + crashesInWindow: engagement.crashesInWindow, + userConfirmed: true + }) + }, + clearMarker: () => clearGpuFallbackMarker(userDataPath), + promptForRestart: () => + promptForGpuFallbackRestart( + state.mainWindow && !state.mainWindow.isDestroyed() ? state.mainWindow : undefined + ), + onPromptFailed: (error) => + console.warn('[gpu-fallback] failed to show restart prompt:', error), + onRestartDeferred: () => + recordDurableCrashBreadcrumb('gpu_fallback_restart_deferred', fallbackData), + restartIntoSafeGraphics: () => { + state.isQuitting = true + relaunchApp('gpu-fallback', fallbackData) + destroySystemTray() + app.exit(0) + } + } + ) +} + +export function registerGpuLifecycleHandlers(): void { + app.on('gpu-info-update', () => { + state.gpuFeatureStatus = app.getGPUFeatureStatus() + state.gpuCrashDiagnostics?.warm() + if (app.isReady()) { + updateGpuAccelerationAboutPanel() + } + }) +} diff --git a/src/main/startup/headless-pty-hydration-ordering.test.ts b/src/main/startup/headless-pty-hydration-ordering.test.ts index 2263cdbf94f..e866a5d1926 100644 --- a/src/main/startup/headless-pty-hydration-ordering.test.ts +++ b/src/main/startup/headless-pty-hydration-ordering.test.ts @@ -21,9 +21,12 @@ describe('headless PTY registry hydration ordering', () => { }) it('hydrates Electron serve after provider and handler readiness but before RPC', () => { - const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') - const serve = source.indexOf('if (serveOptions) {') - const provider = source.indexOf('await localPtyProviderStartupReady', serve) + const source = readFileSync( + join(process.cwd(), 'src/main/startup/main-process-runtime-launch.ts'), + 'utf8' + ) + const serve = source.indexOf('async function launchServeMode(') + const provider = source.indexOf('await state.localPtyProviderStartupReady', serve) const handlersAndHydration = source.indexOf('await registerHeadlessPtyRuntime(', provider) const rpc = source.indexOf('await runtimeRpc.start()', handlersAndHydration) const readiness = source.indexOf('await printServeReady(serveOptions)', rpc) diff --git a/src/main/startup/host-port-bootstrap-wiring.test.ts b/src/main/startup/host-port-bootstrap-wiring.test.ts index 5e6fbfdf367..6d46d52679d 100644 --- a/src/main/startup/host-port-bootstrap-wiring.test.ts +++ b/src/main/startup/host-port-bootstrap-wiring.test.ts @@ -17,7 +17,11 @@ import { describe, expect, it } from 'vitest' * startup, so there is no seam to assert against at runtime. */ describe('host port bootstrap wiring', () => { - const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') + const source = readFileSync( + join(process.cwd(), 'src/main/startup/main-process-preflight.ts'), + 'utf8' + ) + const entrySource = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') const INSTALLS = [ 'setAppEnvironment(new ElectronAppEnvironment())', @@ -37,18 +41,23 @@ describe('host port bootstrap wiring', () => { } }) - it('installs every port before the runtime, the PTY handlers, or any window exists', () => { - // Why these three: they are the first things that resolve a path, seal a credential, - // or register against an injected surface. - const firstUse = Math.min( - ...['new OrcaRuntimeService(', 'registerHeadlessPtyRuntime(', 'function openMainWindow('] - .map((marker) => source.indexOf(marker)) - .filter((index) => index >= 0) - ) - expect(firstUse).toBeGreaterThan(0) - + it('installs every port during preflight before it hands off to ready services', () => { + // Why: the ready phase creates the runtime, PTY handlers, and windows. Keeping all host-port + // installs in the preflight phase preserves process-level defaults for both desktop and serve. + const preflightStart = source.indexOf('export function runMainProcessPreflight(') + const preflightReturn = source.indexOf('\n return true', preflightStart) + const readyPhase = entrySource.indexOf('void app.whenReady().then(async () => {') + const preflightCall = entrySource.indexOf('runMainProcessPreflight({') + expect(preflightStart).toBeGreaterThanOrEqual(0) + expect(preflightReturn).toBeGreaterThan(preflightStart) + expect(preflightCall).toBeGreaterThanOrEqual(0) + expect(readyPhase).toBeGreaterThan(preflightCall) for (const install of INSTALLS) { - expect(source.indexOf(install), `${install} must run before first use`).toBeLessThan(firstUse) + const installIndex = source.indexOf(install) + expect(installIndex, `${install} should run in preflight`).toBeGreaterThan(preflightStart) + expect(installIndex, `${install} should run before preflight completes`).toBeLessThan( + preflightReturn + ) } }) @@ -58,7 +67,7 @@ describe('host port bootstrap wiring', () => { // port is a window where an early path resolve either kills the process — which is what took // down every macOS `orca serve` — or caches the pre-override directory for the whole session. // Keeping the four statements adjacent is what makes that window zero rather than merely small. - const decide = source.indexOf('configureDevUserDataPath(is.dev)') + const decide = source.indexOf('configureDevUserDataPath(isDev)') const install = source.indexOf('setAppEnvironment(new ElectronAppEnvironment())') const capture = source.indexOf('initDataPath()') @@ -73,7 +82,7 @@ describe('host port bootstrap wiring', () => { .filter((line) => line.length > 0 && !line.startsWith('//')) expect(statements).toEqual([ - 'configureDevUserDataPath(is.dev)', + 'configureDevUserDataPath(isDev)', 'configureOrcaUserDataPathEnv()', 'setAppEnvironment(new ElectronAppEnvironment())' ]) @@ -82,12 +91,12 @@ describe('host port bootstrap wiring', () => { it('installs the ports at process level, not per window', () => { // Why: installing per window registered the PTY surfaces against no-ops on the // serve path, where no window ever opens. Caught in CI by the SSH docker E2E. - const openWindow = source.indexOf('function openMainWindow(') + const readyPhase = entrySource.indexOf('void app.whenReady().then(async () => {') + const preflightCall = entrySource.indexOf('runMainProcessPreflight({') + expect(preflightCall).toBeGreaterThanOrEqual(0) + expect(readyPhase).toBeGreaterThan(preflightCall) for (const install of INSTALLS) { - expect( - source.indexOf(install, openWindow), - `${install} must not be re-installed per window` - ).toBe(-1) + expect(source.split(install).length - 1, `${install} should be owned by preflight`).toBe(1) } }) }) diff --git a/src/main/startup/main-process-account-services.ts b/src/main/startup/main-process-account-services.ts new file mode 100644 index 00000000000..c4575c7a0a3 --- /dev/null +++ b/src/main/startup/main-process-account-services.ts @@ -0,0 +1,161 @@ +import { app } from 'electron' +import { RateLimitService } from '../rate-limits/service' +import { CodexRuntimeHomeService } from '../codex-accounts/runtime-home-service' +import { CodexAccountService } from '../codex-accounts/service' +import { ClaudeRuntimeAuthService } from '../claude-accounts/runtime-auth-service' +import { ClaudeAccountService } from '../claude-accounts/service' +import { KeybindingService } from '../keybindings/keybinding-service' +import { createCodexSessionMigrationScheduler } from '../codex/codex-session-migration-scheduler' +import { startCodexSessionBackfillInBackground } from '../codex/codex-session-backfill' +import { startCodexSessionIndexHealInBackground } from '../codex/codex-session-index-heal' +import { startCodexStateDbBackfillRecoveryInBackground } from '../codex/codex-state-db-backfill-recovery' +import { getOrcaManagedCodexHomePath } from '../codex/codex-home-paths' +import { getInitialCodexRateLimitTarget } from '../rate-limits/codex-rate-limit-target' +import { getInitialClaudeRateLimitTarget } from '../rate-limits/claude-rate-limit-target' +import { getKimiRuntimeTarget, resolveKimiHome } from '../kimi/kimi-runtime-home' +import { readMiniMaxSessionCookie } from '../minimax/minimax-cookie-store' +import { createAccountRuntimeTargetSettingsSync } from '../rate-limits/account-runtime-target-sync' +import { normalizeCodexRuntimeSelection } from '../codex-accounts/runtime-selection' +import { normalizeClaudeRuntimeSelection } from '../claude-accounts/runtime-selection' +import { isAgentStatusHooksEnabled } from '../agent-hooks/managed-agent-hook-controls' +import { agentHookServer } from '../agent-hooks/server' +import { setSystemCodexHomeHookSweepSuppressed } from '../codex/hook-service' +import { isRealHomeCodexHookLaneUsable } from '../codex/codex-real-home-hook-install' +import { resolveHostCodexSessionSourceHome } from '../codex/codex-session-source-home' +import { browserManager } from '../browser/browser-manager' +import { mainProcessState as state } from './main-process-state' + +export function initializeMainProcessAccountServices(): void { + const store = state.store + if (!store || !state.claudeUsage || !state.codexUsage || !state.openCodeUsage) { + throw new Error('Usage stores must be initialized before account services') + } + state.rateLimits = new RateLimitService() + state.codexRuntimeHome = new CodexRuntimeHomeService(store) + void startCodexStateDbBackfillRecoveryInBackground(getOrcaManagedCodexHomePath()) + // Why: an incapable trust-grant host must fall back to the managed home for + // every consumer (PTY env, rate limits, commit messages) in one place. + state.codexRuntimeHome.setRealHomeLaneGate(() => isRealHomeCodexHookLaneUsable()) + // Why: while the real-home lane owns ~/.codex/hooks.json, the legacy + // system-home sweep inside managed installs would delete the entry the + // real-home installer just appended. Flag OFF, hooks off, or an incapable + // trust lane re-arms the sweep so downgrade, opt-out, and rollback converge. + setSystemCodexHomeHookSweepSuppressed( + () => + state.codexRuntimeHome !== null && + state.codexRuntimeHome.isHostSystemDefaultRealHome() && + isAgentStatusHooksEnabled(state.store?.getSettings()) + ) + state.codexSessionMigration = createCodexSessionMigrationScheduler({ + isEligible: () => + state.codexRuntimeHome?.isHostSystemDefaultSessionMigrationEligible() === true, + isQuitting: () => state.isQuitting, + resolveSystemCodexHomePathOverride: () => + resolveHostCodexSessionSourceHome(store.getSettings()), + prepareScheduledRun: (scanDates) => + state.codexRuntimeHome?.prepareHostSystemDefaultSessionMigrationPass(scanDates), + finishScheduledRun: () => state.codexRuntimeHome?.finishHostSystemDefaultSessionMigrationPass(), + startBackfill: startCodexSessionBackfillInBackground, + startIndexHeal: startCodexSessionIndexHealInBackground + }) + state.codexAccounts = new CodexAccountService(store, state.rateLimits, state.codexRuntimeHome, { + onHostSystemDefaultSelected: state.codexSessionMigration.requestRun + }) + // Why: migrate historical shared-home sessions after startup; compatibility + // launches re-arm the non-destructive pass for new rollouts (#4444, #8612, #12480). + state.codexSessionMigration.scheduleInitialRun() + state.claudeRuntimeAuth = new ClaudeRuntimeAuthService(store) + state.claudeAccounts = new ClaudeAccountService(store, state.rateLimits, state.claudeRuntimeAuth) + state.rateLimits.setCodexHomePathResolver((target) => + state.codexRuntimeHome!.prepareForRateLimitFetch(target) + ) + state.rateLimits.setCodexFetchTarget(getInitialCodexRateLimitTarget(store.getSettings())) + // Why: Kimi's CLI refreshes its OAuth token in whichever runtime it runs in, so the + // usage fetch must read the WSL-side credentials when that's the configured runtime (#12370). + state.rateLimits.setKimiHomeResolver(() => + resolveKimiHome(getKimiRuntimeTarget(store.getSettings())) + ) + state.rateLimits.setClaudeFetchTarget(getInitialClaudeRateLimitTarget(store.getSettings())) + const syncAccountRuntimeTargets = createAccountRuntimeTargetSettingsSync( + state.rateLimits, + store.getSettings() + ) + store.onSettingsChanged((updates, settings) => { + // Why: auto is a live policy; retarget only providers whose settings-derived runtime changed. + void syncAccountRuntimeTargets(updates, settings).catch((error) => + console.warn('[rate-limits] Failed to apply account runtime target:', error) + ) + }) + state.rateLimits.setClaudeAuthPreparationResolver((target) => + state.claudeRuntimeAuth!.prepareForRateLimitFetch(target) + ) + // Why: live Claude sessions stream usage windows through their statusLine command; feeding them here avoids OAuth usage-endpoint polling (and its 429s). + agentHookServer.setClaudeStatusLineListener((event) => { + state.rateLimits!.ingestLiveClaudeRateLimits(event) + }) + state.rateLimits.setOpenCodeGoConfigResolver(() => { + const settings = store.getSettings() + return { + sessionCookie: settings.opencodeSessionCookie, + workspaceIdOverride: settings.opencodeWorkspaceId + } + }) + state.rateLimits.setMiniMaxConfigResolver(() => { + const settings = store.getSettings() + return { + sessionCookie: readMiniMaxSessionCookie() ?? '', + groupId: settings.minimaxGroupId, + models: settings.minimaxUsageModels + } + }) + state.rateLimits.setGeminiCliOAuthEnabledResolver(() => store.getSettings().geminiCliOAuthEnabled) + state.rateLimits.setNetworkProxySettingsResolver(() => store.getSettings()) + state.keybindings = new KeybindingService({ + homePath: app.getPath('home'), + getLegacyOverrides: () => store.getSettings().keybindings, + legacyTabSwitchSeed: { + isPending: () => store.getSettings().tabSwitchKeybindingSeed === 'pending', + markSeeded: () => store.updateSettings({ tabSwitchKeybindingSeed: 'done' }) + } + }) + browserManager.setSettingsResolver(() => ({ keybindings: state.keybindings?.getOverrides() })) + state.rateLimits.setInactiveClaudeAccountsResolver(() => { + const settings = store.getSettings() + const activeIds = new Set( + [ + normalizeClaudeRuntimeSelection(settings).host, + ...Object.values(normalizeClaudeRuntimeSelection(settings).wsl) + ].filter(Boolean) + ) + return settings.claudeManagedAccounts + .filter((account) => !activeIds.has(account.id)) + .map((account) => ({ + id: account.id, + managedAuthPath: account.managedAuthPath, + managedAuthRuntime: account.managedAuthRuntime, + wslDistro: account.wslDistro, + wslLinuxAuthPath: account.wslLinuxAuthPath + })) + }) + state.rateLimits.setInactiveCodexAccountsResolver(() => { + const settings = store.getSettings() + const activeIds = new Set( + [ + normalizeCodexRuntimeSelection(settings).host, + ...Object.values(normalizeCodexRuntimeSelection(settings).wsl) + ].filter(Boolean) + ) + return settings.codexManagedAccounts + .filter((account) => !activeIds.has(account.id)) + .map((account) => ({ + id: account.id, + resolveHome: () => { + const resolved = + state.codexRuntimeHome!.resolveCodexManagedAccountHomeForInactiveFetch(account) + return resolved.kind === 'ready' + ? { kind: 'ready' as const, managedHomePath: resolved.homePath } + : { kind: 'skip' as const } + } + })) + }) +} diff --git a/src/main/startup/main-process-automations.ts b/src/main/startup/main-process-automations.ts new file mode 100644 index 00000000000..a980c272070 --- /dev/null +++ b/src/main/startup/main-process-automations.ts @@ -0,0 +1,100 @@ +import { AutomationService } from '../automations/service' +import { createHeadlessAutomationOutputSnapshotBuffer } from '../automations/headless-dispatch' +import { buildHeadlessAutomationWorktreeCreateArgs } from '../automations/headless-workspace-create' +import { createRuntimeAutomationRunTerminalObserver } from '../automations/runtime-terminal-run-observer' +import { mainProcessState as state } from './main-process-state' + +export function initializeMainProcessAutomations(): AutomationService { + const store = state.store + const runtime = state.runtime + const claudeUsage = state.claudeUsage + const codexUsage = state.codexUsage + if (!store || !runtime || !claudeUsage || !codexUsage) { + throw new Error('Runtime and usage stores must be initialized before automations') + } + const service = new AutomationService(store, { + claudeUsage, + codexUsage, + terminalObserver: createRuntimeAutomationRunTerminalObserver(runtime), + onAutomationsChanged: (payload) => runtime.notifyAutomationsChanged(payload), + // Why: desktop clients mirror remote-host automations, but only a server process should execute remote_host_service-owned schedules. + allowRemoteHostScheduling: state.isServeMode, + headlessDispatcher: state.isServeMode + ? async ({ automation, run, target }) => { + const terminalSnapshotLimit = 2_000 + let terminalHandle: string + let terminalSessionId: string | null = null + let terminalPaneKey: string | null = null + let terminalPtyId: string | null = null + let workspaceId: string + let workspaceDisplayName: string | null = null + if (automation.workspaceMode === 'new_per_run') { + const created = await runtime.createManagedWorktree( + buildHeadlessAutomationWorktreeCreateArgs({ automation, run, repo: target.repo }) + ) + terminalHandle = created.startupTerminal?.handle ?? '' + terminalSessionId = created.startupTerminal?.tabId ?? null + terminalPaneKey = created.startupTerminal?.paneKey ?? null + terminalPtyId = created.startupTerminal?.ptyId ?? null + workspaceId = created.worktree.id + workspaceDisplayName = created.worktree.displayName ?? null + if (!terminalHandle) { + throw new Error( + created.warning || + 'Automation workspace was created, but no agent terminal started.' + ) + } + } else { + if (!automation.workspaceId) { + throw new Error('The target workspace is no longer available.') + } + const terminal = await runtime.launchAgentTerminal(`id:${automation.workspaceId}`, { + agent: automation.agentId, + prompt: automation.prompt, + title: run.title + }) + terminalHandle = terminal.handle + terminalSessionId = terminal.tabId ?? null + terminalPaneKey = terminal.paneKey ?? null + terminalPtyId = terminal.ptyId ?? null + workspaceId = terminal.worktreeId + const worktree = await runtime.showManagedWorktree(`id:${workspaceId}`) + workspaceDisplayName = worktree.displayName ?? null + } + const completion = (async () => { + const wait = await runtime.waitForTerminal(terminalHandle, { condition: 'tui-idle' }) + const read = await runtime.readTerminal(terminalHandle, { + limit: terminalSnapshotLimit + }) + const snapshotBuffer = createHeadlessAutomationOutputSnapshotBuffer() + snapshotBuffer.append(read.tail.join('\n')) + if (wait.satisfied) { + return { + status: 'completed' as const, + outputSnapshot: snapshotBuffer.snapshot(), + error: null + } + } + return { + status: 'dispatch_failed' as const, + outputSnapshot: snapshotBuffer.snapshot(), + error: wait.blockedReason + ? `Automation agent is blocked: ${wait.blockedReason}.` + : 'Automation agent did not report completion.' + } + })() + return { + workspaceId, + workspaceDisplayName, + terminalSessionId, + terminalPaneKey, + terminalPtyId, + completion + } + } + : undefined + }) + state.automations = service + runtime.setAutomationService(service) + return service +} diff --git a/src/main/startup/main-process-i18n-menu.ts b/src/main/startup/main-process-i18n-menu.ts new file mode 100644 index 00000000000..0de7780d9db --- /dev/null +++ b/src/main/startup/main-process-i18n-menu.ts @@ -0,0 +1,99 @@ +import { app, BrowserWindow } from 'electron' +import { ensureMainI18n, setMainUiLanguage } from '../i18n/main-i18n' +import { + registerAppMenu, + rebuildAppMenu, + getNextDefaultOnAppearanceSettingValue +} from '../menu/register-app-menu' +import { zoomDashboardPopoutIfFocused } from '../window/dashboard-popout-window' +import { recordCrashBreadcrumb } from '../crash-reporting/crash-breadcrumb-store' +import { mainProcessState as state } from './main-process-state' +import { + openSettingsFromSystemMenu, + runUserInitiatedUpdateCheck, + sendOpenCrashReport, + sendOpenFeatureTour, + sendOpenSetupGuide +} from './main-window-actions' +import { ensureAutoUpdaterConfigured } from '../window/attach-main-window-services' +import { logStartupMilestone } from './startup-diagnostics' + +export async function initializeMainProcessI18nAndMenu(): Promise { + const store = state.store + if (!store) { + throw new Error('Store must be initialized before menu') + } + await ensureMainI18n() + await setMainUiLanguage(store.getSettings().uiLanguage) + logStartupMilestone('i18n-ready') + registerAppMenu({ + appMenuLabel: state.devInstanceIdentity?.name ?? app.name, + onCheckForUpdates: (options) => { + ensureAutoUpdaterConfigured() + runUserInitiatedUpdateCheck(options) + }, + onBeforeReload: ({ ignoreCache, webContentsId }) => { + if (state.mainWindow?.webContents.id === webContentsId) { + state.expectedRendererReload.mark(webContentsId) + } + recordCrashBreadcrumb('manual_reload_requested', { ignoreCache }) + }, + onOpenSettings: openSettingsFromSystemMenu, + onOpenSetupGuide: (targetWindow) => { + recordCrashBreadcrumb('setup_guide_opened') + sendOpenSetupGuide(targetWindow instanceof BrowserWindow ? targetWindow : null) + }, + onOpenCrashReport: (targetWindow) => { + recordCrashBreadcrumb('crash_report_opened') + sendOpenCrashReport(targetWindow instanceof BrowserWindow ? targetWindow : null) + }, + onOpenFeatureTour: (targetWindow) => { + recordCrashBreadcrumb('feature_tour_opened') + // Why: use the invoking BrowserWindow so hidden/E2E and multi-window flows route to the right renderer, not global focus. + sendOpenFeatureTour(targetWindow instanceof BrowserWindow ? targetWindow : null) + }, + // Why: menu zoom must act on the window the user is looking at — routing to + // the main window while the dashboard pop-out is focused zooms behind it. + onZoomIn: () => { + if (!zoomDashboardPopoutIfFocused('in')) { + state.mainWindow?.webContents.send('terminal:zoom', 'in') + } + }, + onZoomOut: () => { + if (!zoomDashboardPopoutIfFocused('out')) { + state.mainWindow?.webContents.send('terminal:zoom', 'out') + } + }, + onZoomReset: () => { + if (!zoomDashboardPopoutIfFocused('reset')) { + state.mainWindow?.webContents.send('terminal:zoom', 'reset') + } + }, + onToggleLeftSidebar: () => state.mainWindow?.webContents.send('ui:toggleLeftSidebar'), + onToggleRightSidebar: () => state.mainWindow?.webContents.send('ui:toggleRightSidebar'), + onToggleAppearance: (key) => { + if (key === 'statusBarVisible') { + // Why: status bar visibility lives in persisted UI state (not settings) and the renderer owns the toggle — forward the event, let it flip + store. + state.mainWindow?.webContents.send('ui:toggleStatusBar') + return + } + const current = store.getSettings() + // Why: these appearance settings are default-on, so a missing persisted value must toggle from visible -> hidden. + const next = getNextDefaultOnAppearanceSettingValue(current[key]) + store.updateSettings({ [key]: next }, { notifyListeners: true }) + rebuildAppMenu() + }, + getAppearanceState: () => { + const settings = store.getSettings() + const ui = store.getUI() + return { + showTasksButton: settings.showTasksButton !== false, + showAutomationsButton: settings.showAutomationsButton !== false, + showMobileButton: settings.showMobileButton !== false, + showTitlebarAppName: settings.showTitlebarAppName !== false, + statusBarVisible: ui.statusBarVisible !== false + } + }, + getKeybindings: () => state.keybindings?.getOverrides() + }) +} diff --git a/src/main/startup/main-process-ipc-bootstrap.ts b/src/main/startup/main-process-ipc-bootstrap.ts new file mode 100644 index 00000000000..89be84d2119 --- /dev/null +++ b/src/main/startup/main-process-ipc-bootstrap.ts @@ -0,0 +1,63 @@ +import { ipcMain } from 'electron' +import { recoverLegacyWorkerTerminalsForRendererStartup } from './legacy-worker-renderer-recovery' +import { logStartupMilestone } from './startup-diagnostics' +import { mainProcessState as state } from './main-process-state' +import { resolveOpenedMarkdownDocuments } from './os-opened-markdown-files' + +export function registerMainProcessIpcHandlers(): void { + ipcMain.handle('app:awaitFirstWindowStartupServices', async () => { + await Promise.all([ + state.firstWindowStartupServicesReady, + state.managedWslCliStartupBarrierReady + ]) + }) + ipcMain.handle('app:prepareTerminalStartupRestoration', async () => { + await Promise.all([ + state.firstWindowStartupServicesReady, + state.managedWslCliStartupBarrierReady + ]) + await state.runtime?.prepareStructuredAgentSessionStartupRestoration() + }) + ipcMain.handle('app:recoverLegacyWorkerTerminalsForRendererStartup', () => + recoverLegacyWorkerTerminalsForRendererStartup({ + firstWindowStartupServicesReady: state.firstWindowStartupServicesReady, + managedWslCliStartupBarrierReady: state.managedWslCliStartupBarrierReady, + localPtyProviderStartupReady: state.localPtyProviderStartupReady, + reconcile: async () => { + await state.runtime?.refreshRestoredOrchestrationAuthority() + return state.runtime?.reconcileLegacyWorkerTerminals({ materializeRenderer: true }) + }, + onDeferredRecoveryError: (error) => { + console.warn('[orchestration] legacy worker provider-ready recovery failed', error) + } + }) + ) + // Why: the renderer pulls this once its ui:openSettings listener attaches, so a Settings request queued before mount isn't lost. + ipcMain.handle('ui:consumePendingOpenSettings', (event) => + state.pendingOpenSettings.matches(event.sender.id, { consume: true }) + ) + ipcMain.handle('ui:consumePendingSkillShare', () => state.skillShareDeepLinks.consume()) + // Why: the renderer pulls this once its ui:openMarkdownFiles listener attaches, so a + // cold-start "Open With" queued before mount still opens. The pull doubles as the proof + // that the listener is live, which is what lets main start pushing. + ipcMain.handle('ui:consumePendingMarkdownFileOpens', async () => { + state.markdownFileOpenListenerReady = true + const filePaths = state.osOpenedMarkdownFiles.consume() + try { + return await resolveOpenedMarkdownDocuments(filePaths) + } catch (error) { + // Why restored: the renderer never received these, so a later mount must still get them. + state.osOpenedMarkdownFiles.restore(filePaths) + throw error + } + }) + ipcMain.handle( + 'app:startupDiagnostic', + (_event, event: string, details?: Record) => { + if (!state.startupDiagnosticsEnabled || !event.startsWith('renderer-')) { + return + } + logStartupMilestone(event, details && typeof details === 'object' ? details : {}) + } + ) +} diff --git a/src/main/startup/main-process-observers.ts b/src/main/startup/main-process-observers.ts new file mode 100644 index 00000000000..ba37b0a312f --- /dev/null +++ b/src/main/startup/main-process-observers.ts @@ -0,0 +1,163 @@ +import { app } from 'electron' +import { join } from 'node:path' +import { AgentAwakeService } from '../agent-awake-service' +import { normalizeComputerAwakeMode } from '../../shared/computer-awake-mode' +import { registerSystemResumeBroadcast } from '../system-resume-broadcast' +import { agentHookServer, type AgentHookProviderSessionIdentity } from '../agent-hooks/server' +import { createHookProviderSessionInvalidator } from '../agent-hooks/hook-provider-session-invalidation' +import { createHookStatusSessionTabsInvalidator } from '../agent-hooks/hook-status-session-tabs-invalidation' +import { initTelemetry, track } from '../telemetry/client' +import { setCodexTrustGrantTelemetry } from '../codex/codex-trust-grant-telemetry' +import { initObservability } from '../observability' +import { recordDurableCrashBreadcrumb } from '../crash-reporting/durable-crash-breadcrumb' +import { recoverPendingSkillTransactions } from '../skills/skill-transaction-startup-recovery' +import { initCohortClassifier } from '../telemetry/cohort-classifier' +import { initOnboardingCohortClassifier } from '../telemetry/onboarding-cohort-classifier' +import { StatsCollector } from '../stats/collector' +import { AgentSessionTransitionRecorder } from '../stats/agent-session-transition-recorder' +import { ClaudeUsageStore } from '../claude-usage/store' +import { CodexUsageStore } from '../codex-usage/store' +import { OpenCodeUsageStore } from '../opencode-usage/store' +import { installRepoMaintenanceIdleGate } from '../repo-maintenance-idle-gate' +import { mainProcessState as state } from './main-process-state' + +export function initializeMainProcessObservers(): void { + const store = state.store + if (!store) { + throw new Error('Store must be initialized before observers') + } + state.unsubscribeSystemResumeBroadcast = registerSystemResumeBroadcast() + state.agentAwakeService = new AgentAwakeService() + state.agentAwakeService.setMode( + normalizeComputerAwakeMode( + store.getSettings().computerAwakeMode, + store.getSettings().keepComputerAwakeWhileAgentsRun + ) + ) + // Why: start from empty — disk-hydrated status rows are UI continuity only; only this runtime's hook events keep the computer awake. + state.agentAwakeService.setStatuses([]) + state.uninstallRepoMaintenanceIdleGate = installRepoMaintenanceIdleGate({ + isQuitting: () => state.isQuitting, + getWorkingAgentCount: () => state.agentAwakeService?.getWorkingAgentCount() ?? 0 + }) + const collectChangedProviderSessionWorktrees = createHookProviderSessionInvalidator() + const publishProviderSessionChanges = (identities: AgentHookProviderSessionIdentity[]): void => { + const ownedIdentities = identities.map((identity) => ({ + ...identity, + worktreeId: + identity.worktreeId ?? + state.runtime?.getTerminalWorktreeIdForPaneKey(identity.paneKey) ?? + undefined + })) + for (const worktreeId of collectChangedProviderSessionWorktrees(ownedIdentities)) { + // Why not `notifyMobileSessionTabsChanged` alone: it re-emits at the unchanged + // `snapshotVersion`, which every client drops on its monotonic gate. + state.runtime?.touchMobileSessionTabsForWorktree(worktreeId, { immediate: true }) + } + } + state.publishProviderSessionChanges = publishProviderSessionChanges + const unsubscribeStatusChanges = agentHookServer.subscribeStatusChanges((statuses) => { + state.agentAwakeService?.setStatuses(statuses) + }) + // Healthy session.tabs streams need a push when transcript identity changes. + const unsubscribeProviderSessionChanges = agentHookServer.subscribeProviderSessionChanges( + (sessions) => publishProviderSessionChanges(sessions) + ) + // Why: hook rows are the only carrier of live agent state on a headless host, and + // nothing else republishes `session.tabs` when one changes — so a paired client + // would keep the pane's last projection until an unrelated PTY touch came along. + const hookStatusChangedSessionTabs = createHookStatusSessionTabsInvalidator() + const unsubscribeHookStatusSessionTabs = agentHookServer.subscribeEnrichedStatus((enriched) => { + if (hookStatusChangedSessionTabs(enriched)) { + state.runtime?.touchMobileSessionTabsForPane(enriched.paneKey, enriched.worktreeId ?? null) + } + }) + // Teardown: agent exit, pane close, and the SSH transient-disconnect batch all land + // here. Without it the live state published above becomes a zombie question card. + const unsubscribeHookStatusClear = agentHookServer.subscribePaneStatusClear((clear) => { + const clearedPaneKeys = + 'paneKey' in clear + ? [clear.paneKey] + : hookStatusChangedSessionTabs.forgetConnection(clear.connectionId) + for (const paneKey of clearedPaneKeys) { + hookStatusChangedSessionTabs.forgetPane(paneKey) + state.runtime?.touchMobileSessionTabsForPane(paneKey) + } + }) + state.unsubscribeAgentAwakeStatusChanges = () => { + unsubscribeStatusChanges() + unsubscribeProviderSessionChanges() + unsubscribeHookStatusSessionTabs() + unsubscribeHookStatusClear() + } + // Why: telemetry must init before any IPC handler/renderer can call track(); it's a no-op in dev and while TELEMETRY_ENABLED is false, so it's safe early. + initTelemetry(store) + // Why: the breadcrumb alone never leaves the machine — it rides crash reports, and a hang is not + // a crash (the app is force-quit, so no report is ever generated). Without this the incidence + // number the watchdog exists to produce would sit unread on the user's disk. Must run after + // initTelemetry: track() drops silently until the client and store are wired. + if (state.hangDetection) { + track('main_thread_hang_detected', { + unresponsive_ms: Math.round(state.hangDetection.unresponsiveMs), + self_recovered: state.hangDetection.selfRecovered + }) + } + // Why: the trust-grant module is bundled into plain-node CLI entries where + // the telemetry client cannot load, so the tracker is injected here instead + // of imported there. + setCodexTrustGrantTelemetry(({ outcome, hostKind, lane, reason, errorClass, verifyClass }) => { + track('codex_trust_grant', { + outcome, + host_kind: hostKind, + lane, + ...(reason !== undefined ? { fallback_reason: reason } : {}), + ...(errorClass !== undefined ? { error_class: errorClass } : {}), + ...(verifyClass !== undefined ? { verify_class: verifyClass } : {}) + }) + }) + // Why: the error-tracking lane (telemetry-error-tracking.md) is its own + // composition root — independent of product telemetry — and must + // initialize before any IPC handler / runtime span is created so the + // tracer's active sink is populated at the moment the first span fires. + // Honors DO_NOT_TRACK / ORCA_TELEMETRY_DISABLED / ORCA_DIAGNOSTICS_DISABLED + // / CI internally; those gates do not need to be re-checked here. + initObservability() + recordDurableCrashBreadcrumb('main_process_lifecycle_started', { + packaged: app.isPackaged, + platform: process.platform + }) + state.skillTransactionRecovery = recoverPendingSkillTransactions( + join(app.getPath('userData'), 'skill-installs') + ) + void state.skillTransactionRecovery + .then((report) => { + const result = report as { + scanned: number + recovered: number + failures: { code: string }[] + truncated: boolean + } + if (result.scanned || result.failures.length || result.truncated) { + console.info('[skills] startup transaction recovery:', { + scanned: result.scanned, + recovered: result.recovered, + failures: result.failures.map((failure) => failure.code), + truncated: result.truncated + }) + } + }) + .catch((error) => console.warn('[skills] startup transaction recovery failed:', error)) + // Why: cohort-classifier reads repo count synchronously at every emit, so hydrate it here — before any IPC handler or window can trigger track(). + initCohortClassifier(store) + initOnboardingCohortClassifier(store) + state.stats = new StatsCollector() + // Agent-session stats come from hook status transitions, the same truth the + // sidebar and dashboard read — never from OSC terminal titles, which miss + // hook-only agents and count any spinner TUI as an agent (#10201). + const agentSessionRecorder = new AgentSessionTransitionRecorder(state.stats) + agentHookServer.subscribeEnrichedStatus((enriched) => agentSessionRecorder.onStatus(enriched)) + agentHookServer.subscribePaneStatusClear((clear) => agentSessionRecorder.onCleared(clear)) + state.claudeUsage = new ClaudeUsageStore(store) + state.codexUsage = new CodexUsageStore(store) + state.openCodeUsage = new OpenCodeUsageStore(store) +} diff --git a/src/main/startup/main-process-plugins.ts b/src/main/startup/main-process-plugins.ts new file mode 100644 index 00000000000..8c3aeeed10c --- /dev/null +++ b/src/main/startup/main-process-plugins.ts @@ -0,0 +1,168 @@ +import { app, BrowserWindow } from 'electron' +import { performance } from 'node:perf_hooks' +import { PluginService } from '../plugins/plugin-service' +import { PluginKillListService } from '../plugins/plugin-kill-list-service' +import { PluginMarketplaceService } from '../plugins/plugin-marketplace-service' +import { PluginMarketplaceInstaller } from '../plugins/plugin-marketplace-installer' +import { PluginBundledBootstrapCoordinator } from '../plugins/plugin-bundled-bootstrap-coordinator' +import { getPluginsDataDir } from '../plugins/plugin-discovery' +import { resolveBundledPluginRoot } from '../plugins/plugin-bundled-bootstrap' +import { resolvePluginHostEntryPath } from '../plugins/plugin-host-process' +import { applyPluginConsent, applyPluginEnablement } from '../plugins/plugin-enablement' +import { setPluginServiceForRpc } from '../runtime/rpc/methods/plugins' +import { + normalizePluginConsents, + normalizePluginIdList +} from '../../shared/plugins/plugin-consent-state' +import { setMainPluginLanguagePacks, setMainUiLanguage } from '../i18n/main-i18n' +import { rebuildAppMenu } from '../menu/register-app-menu' +import { logStartupMilestone } from './startup-diagnostics' +import { agentHookServer } from '../agent-hooks/server' +import { emitPluginWorktreeLifecycle } from './main-process-pty-startup' +import { mainProcessState as state } from './main-process-state' +import type { OrcaRuntimeService } from '../runtime/orca-runtime' + +export async function initializeMainProcessPlugins(runtime: OrcaRuntimeService): Promise { + const store = state.store + const keybindings = state.keybindings + if (!store || !keybindings) { + throw new Error('Store and keybindings must be initialized before plugins') + } + const pluginSystemStartupStartedAt = performance.now() + state.pluginKillListService = new PluginKillListService({ + pluginsDataDir: getPluginsDataDir(app.getPath('userData')) + }) + await state.pluginKillListService.initialize() + state.pluginMarketplaceService = new PluginMarketplaceService({ + pluginsDataDir: getPluginsDataDir(app.getPath('userData')), + getKillListEntry: (pluginKey) => state.pluginKillListService?.find(pluginKey) ?? null + }) + const requestOfficialMarketplaceSeed = (): void => { + if (store.getSettings().pluginSystemEnabled !== true) { + return + } + void state.pluginMarketplaceService + ?.seedOfficialSource() + .catch((error) => + console.warn('[plugins] failed to configure the official marketplace:', error) + ) + } + state.pluginMarketplaceInstaller = new PluginMarketplaceInstaller({ + marketplace: state.pluginMarketplaceService, + userDataPath: app.getPath('userData'), + hostVersion: app.getVersion(), + blockedPluginReason: (pluginKey) => state.pluginKillListService?.reason(pluginKey) ?? null + }) + state.pluginService = new PluginService({ + userDataPath: app.getPath('userData'), + hostVersion: app.getVersion(), + // Feature flag: with the setting off, discovery returns nothing and no + // plugin code path runs at all. + isPluginSystemEnabled: () => state.store?.getSettings().pluginSystemEnabled === true, + getDisabledPlugins: () => normalizePluginIdList(state.store?.getSettings().disabledPlugins), + getPluginConsents: () => normalizePluginConsents(state.store?.getSettings().pluginConsents), + getDevPluginPaths: () => normalizePluginIdList(state.store?.getSettings().devPluginPaths), + getKeybindings: () => state.keybindings?.getOverrides() ?? {}, + getPluginKillListEntry: (pluginKey) => state.pluginKillListService?.find(pluginKey) ?? null, + hostEntryPath: resolvePluginHostEntryPath(app.getAppPath(), app.isPackaged) + }) + const bundledPluginBootstrap = new PluginBundledBootstrapCoordinator({ + root: resolveBundledPluginRoot({ + isPackaged: app.isPackaged, + resourcesPath: process.resourcesPath, + appPath: app.getAppPath() + }), + userDataPath: app.getPath('userData'), + hostVersion: app.getVersion(), + isEnabled: () => state.store?.getSettings().pluginSystemEnabled === true, + blockedPluginReason: (pluginKey) => state.pluginKillListService?.reason(pluginKey) ?? null, + refreshPlugins: () => state.pluginService?.refresh() ?? Promise.resolve() + }) + const requestBundledPluginBootstrap = (): void => { + void bundledPluginBootstrap + .request() + .then((result) => { + for (const failure of result?.errors ?? []) { + console.warn(`[plugins] failed to publish bundled ${failure.pluginKey}:`, failure.error) + } + }) + .catch((error) => console.warn('[plugins] failed to bootstrap bundled plugins:', error)) + } + state.pluginKillListService.onChanged(() => { + void state.pluginService + ?.reconcileActivationState() + .catch((error) => + console.warn('[plugins] failed to apply plugin safety-list refresh:', error) + ) + }) + store.onSettingsChanged((updates) => { + if (updates.pluginSystemEnabled === true) { + requestBundledPluginBootstrap() + requestOfficialMarketplaceSeed() + } + if (app.isPackaged && updates.pluginSystemEnabled === true) { + void state.pluginKillListService + ?.refresh() + .catch((error) => + console.warn('[plugins] failed to refresh plugin safety list; using cached state:', error) + ) + } + }) + // Why: headless `orca serve` clients reach plugins through the runtime RPC + // methods, which resolve the service via this module-level setter. Consent + // over RPC uses the same hash-keyed write path as the desktop dialog. + setPluginServiceForRpc(state.pluginService, { + applyConsent: (request) => + applyPluginConsent({ store, pluginService: state.pluginService!, ...request }), + applyEnablement: (pluginKey, enabled) => + applyPluginEnablement({ store, pluginService: state.pluginService!, pluginKey, enabled }) + }) + // Lazy kernel: initialize() only discovers manifests — no worker forks, no + // panel reads. Zero plugin code runs before an explicit trigger. + void state.pluginService + .initialize() + .then(() => { + logStartupMilestone('plugin-system-initialized', { + durationMs: Number((performance.now() - pluginSystemStartupStartedAt).toFixed(2)), + installedPlugins: state.pluginService?.getDiscovered().length ?? 0 + }) + }) + .catch((error) => console.warn('[plugins] failed to initialize plugin service:', error)) + if (app.isPackaged && store.getSettings().pluginSystemEnabled === true) { + void state.pluginKillListService + .refresh() + .catch((error) => + console.warn('[plugins] failed to refresh plugin safety list; using cached state:', error) + ) + } + state.pluginService.onChanged((event) => { + if ( + event.contentPacksChanged && + setMainPluginLanguagePacks(state.pluginService?.contentPacks.languagePacks.list() ?? []) + ) { + void setMainUiLanguage(store.getSettings().uiLanguage).then(() => rebuildAppMenu()) + } + for (const window of BrowserWindow.getAllWindows()) { + if (!window.isDestroyed()) { + window.webContents.send('plugins:changed', event) + } + } + }) + requestBundledPluginBootstrap() + requestOfficialMarketplaceSeed() + // v0 plugin event seams: agent status (hook pipeline tap) + worktree + // lifecycle (runtime tap). Server-side filtered per plugin subscription. + agentHookServer.subscribeEnrichedStatus((enriched) => { + // Why: plugins may automate on `working`; restored rows are historical claims, not fresh activity. + if (enriched.restoredUnconfirmed) { + return + } + state.pluginService?.emitEvent('agent.status.changed', { + worktreeId: enriched.worktreeId ?? null, + paneKey: enriched.paneKey, + state: enriched.payload.state, + receivedAt: enriched.receivedAt + }) + }) + runtime.onWorktreeLifecycle(emitPluginWorktreeLifecycle) +} diff --git a/src/main/startup/main-process-preflight.ts b/src/main/startup/main-process-preflight.ts new file mode 100644 index 00000000000..eb2a51cb7ff --- /dev/null +++ b/src/main/startup/main-process-preflight.ts @@ -0,0 +1,323 @@ +import { app, ipcMain, powerMonitor, session } from 'electron' +import { is } from '@electron-toolkit/utils' +import os from 'node:os' +import { join } from 'node:path' +import { maybeRedirectAppImageCliLaunch } from './appimage-cli-redirect' +import { maybeRedirectPackagedCliEntryLaunch } from './packaged-cli-entry-redirect' +import { argvRequestsServeMode, normalizeServeModeArgv } from './serve-mode-argv' +import { + configureDevUserDataPath, + configureElectronNetworkCompatibility, + configureOrcaUserDataPathEnv, + disableUnsupportedChromiumFeatures, + enableMainProcessGpuFeatures, + installDevParentDisconnectQuit, + installDevParentSignalQuit, + installDevParentWatchdog, + patchPackagedProcessPath, + optOutOfHiddenPageWakeUpThrottling +} from './configure-process' +import { installServeSupervisorDisconnectQuit } from '../serve-update-handoff' +import { + installUncaughtPipeErrorGuard, + installUnhandledRejectionLogging +} from './main-process-error-guards' +import { hydrateShellPath, mergePathSegments } from './hydrate-shell-path' +import { configureRemoteServerUpdater } from '../runtime/remote-server-updater' +import { + getRemoteServerUpdaterSnapshot, + checkForRemoteServerUpdate, + downloadRemoteServerUpdate, + installRemoteServerUpdate, + isQuittingForUpdate +} from '../updater' +import { getDevInstanceIdentity, shouldApplyPreReadyAppName } from './dev-instance-identity' +import { enableRendererHeapHeadroom } from './renderer-heap-headroom' +import { isStartupDiagnosticsEnabled, logStartupDiagnostic } from './startup-diagnostics' +import { startEventLoopStallProbe } from './event-loop-stall-probe' +import { startMainThreadChurnProbe } from '../diagnostics/main-thread-churn-probe' +import { settledDiffCache } from '../git/source-control/git-read-cache-invalidation' +import { reserveServeStdoutForReadiness } from '../server/serve-stdout-boundary' +import { createServeDesktopActivationGate } from './serve-desktop-activation' +import { + shouldBypassSingleInstanceLock, + shouldSkipSingleInstanceLock, + acquireSingleInstanceLock, + logSingleInstanceLockBypass, + logSingleInstanceLockFailure, + SINGLE_INSTANCE_ALREADY_RUNNING_EXIT_CODE +} from './single-instance-lock' +import { setAppEnvironment } from '../../shared/app-environment' +import { ElectronAppEnvironment } from '../host/electron-app-environment' +import { setSecretStore } from '../../shared/secret-store' +import { ElectronSecretStore } from '../host/electron-secret-store' +import { setPtyHostBindings } from '../ipc/pty-host-bindings' +import { electronRuntimeDesktopSurface } from '../host/electron-runtime-desktop-surface' +import { setRuntimeDesktopSurface } from '../runtime/runtime-desktop-surface' +import { electronRuntimeBrowserCommandsFactory } from '../host/electron-browser-commands' +import { setRuntimeBrowserCommandsFactory } from '../runtime/runtime-browser-commands-factory' +import { electronHttpClient } from '../host/electron-http-client' +import { setMainHttpClient } from '../network/http-client' +import { electronSpeechServiceFactories } from '../host/electron-speech-services' +import { setSpeechServiceFactories } from '../speech/speech-runtime-service' +import { setWorktreeWatcherRemoval } from '../ipc/worktree-watcher-removal' +import { desktopWorktreeWatcherRemoval } from '../ipc/filesystem-watcher' +import { setDefaultProxySessionResolver } from '../network/proxy-settings' +import { initDataPath, getCanonicalUserDataPath } from '../persistence' +import { applyMacPressAndHoldDefaultAtStartup } from '../macos-press-and-hold-default' +import { initSessionParseCachePersistence } from '../ai-vault/session-parse-cache-persistence' +import { initOrcaProfilePaths } from '../orca-profiles/profile-index-store' +import { initStatsPath } from '../stats/collector' +import { initClaudeUsagePath } from '../claude-usage/store' +import { initCodexUsagePath } from '../codex-usage/store' +import { initOpenCodeUsagePath } from '../opencode-usage/store' +import { registerDocPreviewSchemePrivileges } from '../browser/doc-preview-protocol' +import { startCrashpadCapture } from '../crash-reporting/crashpad-capture' +import { CrashReportStore } from '../crash-reporting/crash-report-store' +import { recordCrashBreadcrumb } from '../crash-reporting/crash-breadcrumb-store' +import { recordDurableCrashBreadcrumb } from '../crash-reporting/durable-crash-breadcrumb' +import { GpuCrashDiagnosticsRecorder } from '../crash-reporting/gpu-crash-diagnostics' +import { getMainProcessLifecycleIdentity } from '../crash-reporting/main-process-lifecycle-identity' +import { ensureVirtualDisplayForHeadlessServe } from './ensure-virtual-display' +import { maybeApplyGpuFallbackForThisLaunch, registerGpuLifecycleHandlers } from './gpu-lifecycle' +import { mainProcessState as state } from './main-process-state' +import { initializeSyntheticTitleRuntime } from './synthetic-title-runtime' + +export type MainProcessPreflightOptions = { + focusExistingWindow: () => void + requestDesktopActivation: (argv?: readonly string[]) => void +} + +/** Performs all module-scope work that must happen before Electron's ready event. */ +export function runMainProcessPreflight(options: MainProcessPreflightOptions): boolean { + // Why: on Windows a CLI launch that lost ELECTRON_RUN_AS_NODE would boot the GUI and exit silently; redirect to node mode before the lock gate below. + // Both redirects run before the serve-argv rewrite so they still match on the launch argv verbatim. + // It is load-bearing for the AppImage one: rewriting first replaces the `serve` positional, so its + // command-name lookup finds a port number and strands the launch in an in-process serve. The + // packaged-CLI one matches on the entry path instead, so order cannot affect it either way. + const packagedRedirect = maybeRedirectPackagedCliEntryLaunch({ + isPackaged: app.isPackaged, + resourcesPath: process.resourcesPath, + execPath: process.execPath + }) + if (packagedRedirect.redirected) { + app.exit(packagedRedirect.status) + } + const appImageRedirect = maybeRedirectAppImageCliLaunch({ + isPackaged: app.isPackaged, + resourcesPath: process.resourcesPath, + execPath: process.execPath + }) + if (appImageRedirect.redirected) { + app.exit(appImageRedirect.status) + } + // Why: extracted AppRun / binary launches can land CLI-form `serve` args on the + // Electron process without the CLI rewrite that injects `--serve` (#12677). + // Guarded so a normal GUI launch keeps its original argv array identity. + if (argvRequestsServeMode(process.argv)) { + process.argv = normalizeServeModeArgv(process.argv) + } + state.isServeMode = process.argv.includes('--serve') + if (state.isServeMode) { + reserveServeStdoutForReadiness() + } + state.devInstanceIdentity = getDevInstanceIdentity(is.dev) + state.devAgentHookEndpointNamespace = state.devInstanceIdentity.isDev + ? state.devInstanceIdentity.appUserModelId + : undefined + state.desktopActivationGate = createServeDesktopActivationGate({ + initialState: state.isServeMode ? 'initializing' : 'ready', + activateWindow: () => { + // Why: an updater replacement must not resurrect the old app bundle. + if (!isQuittingForUpdate()) { + options.focusExistingWindow() + } + }, + onBlocked: (reason) => console.error(`[serve] Desktop activation blocked: ${reason}`) + }) + installUncaughtPipeErrorGuard() + // Why (issue #9441): without this, one rejected background promise during startup restore kills main silently (exit 1, no crash report). + installUnhandledRejectionLogging() + // Why: expose the app version via process.env so main and the forked daemon can set TERM_PROGRAM_VERSION without importing electron. + process.env.ORCA_APP_VERSION = app.getVersion() + configureRemoteServerUpdater({ + getSnapshot: getRemoteServerUpdaterSnapshot, + check: checkForRemoteServerUpdate, + download: downloadRemoteServerUpdate, + install: installRemoteServerUpdate + }) + patchPackagedProcessPath() + // Why: the sync seed above covers early IPC (homebrew/nix); the async login-shell probe below (packaged only) then adds the user's rc PATH. + if (app.isPackaged && process.platform !== 'win32') { + void hydrateShellPath().then((result) => { + if (result.ok) { + mergePathSegments(result.segments) + } else { + // Why: on failure the seeded fallbacks stay in front. For an nvm user that is + // now their `default` version rather than the newest install, so it is usually + // survivable — but it is still not what their shell would have resolved. Name + // the reason so it shows up in a log bundle instead of as a missing CLI. + console.warn( + `[shell-path] login-shell probe failed (${result.failureReason}); using seeded PATH` + ) + } + }) + } + const isDev = is.dev + configureDevUserDataPath(isDev) + configureOrcaUserDataPathEnv() + // Why these four lines are one step (#16761): the two above decide where userData lives, and + // everything below may resolve a path. Installing the accessor any later leaves a window where an + // early resolve either throws — which is what killed `orca serve` — or, worse, memoizes the + // pre-override directory and silently writes user state to the wrong place for the whole session. + // Safe this early: ElectronAppEnvironment holds no state and calls `app` lazily per accessor, so it + // changes no timing, and initDataPath only joins strings. + setAppEnvironment(new ElectronAppEnvironment()) + // Why captured now: after the dev/E2E override above, and before app.setName('Orca') (whenReady) + // changes how userData resolves on a case-sensitive filesystem. See persistence.ts:20-28. + initDataPath() + state.startupDiagnosticsEnabled = isStartupDiagnosticsEnabled() + if (state.startupDiagnosticsEnabled) { + logStartupDiagnostic('before-single-instance-lock', { + version: app.getVersion(), + packaged: app.isPackaged, + platform: process.platform, + osRelease: os.release(), + userData: app.getPath('userData'), + e2eUserData: Boolean(process.env.ORCA_E2E_USER_DATA_DIR) + }) + startEventLoopStallProbe() + } + // Self-gated on ORCA_MAIN_THREAD_DIAGNOSTICS; runs the whole session to catch steady-state churn (issue #7576). + // Why the diff-cache counters ride along: a stamp the filesystem reports unstably makes the cache + // look exactly like a cold start, and only the hit/miss/unprovable split tells the two apart. + startMainThreadChurnProbe({ extraStats: () => ({ diffCache: settledDiffCache.stats() }) }) + // Why: acquire AFTER configureDevUserDataPath — Electron derives lock identity from `userData`, so dev/packaged lock in separate namespaces. + // Why skip in dev: parallel `pnpm dev` from multiple worktrees would make the second exit silently; packaged keeps the lock (corruption PR #1326 / #1312). + const bypass = shouldBypassSingleInstanceLock({ isDev, isServeMode: state.isServeMode }) + const skip = shouldSkipSingleInstanceLock({ isDev, isServeMode: state.isServeMode }) + if (bypass) { + // Why: diagnostic escape hatch for macOS builds where Electron reports a false lock loss before any app logs exist. + logSingleInstanceLockBypass() + } + const hasLock = skip || bypass || acquireSingleInstanceLock(app, options.requestDesktopActivation) + if (state.startupDiagnosticsEnabled) { + logStartupDiagnostic('single-instance-lock-result', { + acquired: hasLock, + bypassed: bypass, + skippedForDev: skip + }) + } + if (!hasLock) { + // Why: a false-negative lock loss otherwise looks like a silent crash on packaged macOS; `open --stderr` can capture this line. + logSingleInstanceLockFailure() + // Why: a graceful quit is deferred pre-ready, so this launch would still walk into Linux display init and SIGSEGV (#11935). + app.exit(SINGLE_INSTANCE_ALREADY_RUNNING_EXIT_CODE) + return false + } + // Why first in this block: the accessor throws until installed and everything below may read a + // credential. The constructor does not touch `safeStorage` — it resolves lazily per call — so + // installing here changes no timing, in particular not the pre-ready Keychain service-name + // resolution. The app-environment port and the userData capture install earlier still, next to + // the path decision they depend on. + setSecretStore(new ElectronSecretStore()) + // Why at process level, not per-window: pty.ts registers against injected surfaces so + // it can load without electron, and an Electron main process always has ipcMain — + // whether a window exists is irrelevant. Installing this in attachMainWindowServices + // meant `orca serve` registered its PTY handlers against no-ops before any window + // attached, so a paired desktop owner never received them. + setPtyHostBindings({ ipc: ipcMain, power: powerMonitor }) + // Why also at process level: the runtime's notification, window-lookup and + // tab-create-reply channel are desktop-only. A Node host installs none and the + // runtime routes notifications to paired clients instead. + setRuntimeDesktopSurface(electronRuntimeDesktopSurface) + // Why here: constructing RuntimeBrowserCommands is what pulls the Chromium browser + // cluster into the graph. The desktop installs it; a Node host installs none and every + // browser RPC rejects, which capability filtering already tells clients about. + setRuntimeBrowserCommandsFactory(electronRuntimeBrowserCommandsFactory) + // Why here: proxy-settings only needed electron for `session.defaultSession`. The + // desktop supplies it; a Node host has no Chromium proxy config to consult, so the + // environment variables are the whole answer there. + setDefaultProxySessionResolver(() => session.defaultSession) + // Why here: integrations use Chromium's network stack on the desktop. A Node host + // falls back to the platform default, which is a real behavioural difference (proxy + // read from the environment, Node's user agent) rather than a transparent swap. + setMainHttpClient(electronHttpClient) + // Why here: constructing the speech services is what pulls Electron's streaming net + // request in. A host without them rejects speech calls rather than pretending. + setSpeechServiceFactories(electronSpeechServiceFactories) + setWorktreeWatcherRemoval(desktopWorktreeWatcherRemoval) + // Why: couple to dev-parent only for electron-vite desktop runs; `orca serve`'s parent (CLI shim/background shell) isn't the intended server lifetime. + const shouldCoupleToDevParent = isDev && !state.isServeMode + installDevParentDisconnectQuit(shouldCoupleToDevParent) + installDevParentWatchdog(shouldCoupleToDevParent) + installDevParentSignalQuit(shouldCoupleToDevParent) + // Why not at module scope with the other lifetime couplings (#16761): this resolves the handoff + // path, so it throws until setAppEnvironment() above installs the accessor — which killed every + // `orca serve` process before it could listen. After initDataPath() specifically, so the + // path-equality check against the CLI's env var uses the dir captured before app.setName(). + // Safe to defer, and must stay synchronous: no 'disconnect' can be delivered until this module + // finishes evaluating, so moving this behind an await would open a real orphan window. + installServeSupervisorDisconnectQuit(state.isServeMode) + // Why here: initDataPath above gives the canonical userData path for the record file; the write + // itself lands for the next launch (see macos-press-and-hold-default.ts). + applyMacPressAndHoldDefaultAtStartup(getCanonicalUserDataPath()) + // Why: use the canonical userData path — late app.getPath('userData') can resolve differently across restarts, defeating persistence. + initSessionParseCachePersistence({ + filePath: join(getCanonicalUserDataPath(), 'ai-vault', 'session-parse-cache.json'), + appVersion: app.getVersion() + }) + initOrcaProfilePaths() + // Why: same timing as initDataPath — capture userData before app.setName changes it. See persistence.ts:20-28. + initStatsPath() + initClaudeUsagePath() + initCodexUsagePath() + initOpenCodeUsagePath() + // Why: Electron resolves the macOS safeStorage Keychain service name + // (" Safe Storage") before `ready`, so the setName in whenReady is + // too late to move it — dev otherwise lands on the package.json name. Dev-only + // so a packaged build keeps deriving the key from its own CFBundleName. + // Safe here: dev always pins userData via app.setPath (configure-process.ts), + // so setName cannot shift the paths captured just above. + if (state.devInstanceIdentity && shouldApplyPreReadyAppName(state.devInstanceIdentity)) { + app.setName(state.devInstanceIdentity.appName) + } + // Why: Electron freezes the privileged scheme table at ready, so the doc-preview + // scheme must be declared here or its webview loses fetch/secure-origin privileges. + registerDocPreviewSchemePrivileges() + // Why: must precede app.whenReady() so Crashpad is installed before the + // first renderer spawns; a CHECK before this point is still exit-code-only. + startCrashpadCapture() + state.crashReports = CrashReportStore.fromUserData() + state.gpuCrashDiagnostics = + process.platform === 'win32' + ? new GpuCrashDiagnosticsRecorder({ + provider: { + getGPUInfo: (infoType) => app.getGPUInfo(infoType), + getGPUFeatureStatus: () => app.getGPUFeatureStatus() + }, + recordBreadcrumb: (data) => recordDurableCrashBreadcrumb('gpu_crash_hardware', data) + }) + : null + recordCrashBreadcrumb('app_started', { + packaged: app.isPackaged, + platform: process.platform, + ...getMainProcessLifecycleIdentity() + }) + disableUnsupportedChromiumFeatures() + // Why: unconditional — a GPU-fallback launch skips enableMainProcessGpuFeatures() below. + optOutOfHiddenPageWakeUpThrottling() + configureElectronNetworkCompatibility() + enableRendererHeapHeadroom() + maybeApplyGpuFallbackForThisLaunch() + if (!state.gpuFallbackActiveThisLaunch) { + enableMainProcessGpuFeatures() + } + // Why: headless serve's offscreen BrowserWindows need an X display (Xvfb) on Linux; the result gates whether the offscreen backend is installed. + state.headlessBrowserDisplayAvailable = ensureVirtualDisplayForHeadlessServe({ + isServeMode: state.isServeMode + }) + initializeSyntheticTitleRuntime() + registerGpuLifecycleHandlers() + return true +} diff --git a/src/main/startup/main-process-pty-startup.ts b/src/main/startup/main-process-pty-startup.ts new file mode 100644 index 00000000000..162faf66b2f --- /dev/null +++ b/src/main/startup/main-process-pty-startup.ts @@ -0,0 +1,215 @@ +import { app } from 'electron' +import { classifyError } from '../telemetry/classify-error' +import { track } from '../telemetry/client' +import { getPtyIdForPaneKey } from '../ipc/pty' +import { + getDaemonProvider, + initDaemonPtyProvider, + listLiveDaemonPtyIds +} from '../daemon/daemon-init' +import { + getCodexPaneAccount, + hasAnyRecordedLegacyWslCodexPane, + hasRecordedManagedHostCodexPane, + isCodexPaneHomeRouteProvenAwayFromSharedHome, + reconcileCodexPaneAccountsWithLivePtys, + type CodexPaneHomeRoute +} from '../codex/codex-pane-account-registry' +import { reconcileRetainedCodexHookHomes } from '../codex/retained-codex-hook-state' +import { codexHookService } from '../codex/hook-service' +import { isAgentStatusHooksEnabled } from '../agent-hooks/managed-agent-hook-controls' +import { agentHookServer } from '../agent-hooks/server' +import { + indexPersistedPaneKeyPtyIds, + isLocalExecutionHost, + resolveAgentWorkspaceExecutionHostId, + sweepRestoredSubagentsWithoutLiveAgent +} from '../agent-hooks/restored-subagent-liveness-sweep' +import { startFirstWindowStartupServices } from './first-window-startup-services' +import { logStartupMilestone } from './startup-diagnostics' +import type { WindowsDesktopStartupServices } from './windows-desktop-shell-path-startup' +import type { RuntimeWorktreeLifecycleEvent } from '../runtime/orca-runtime' +import { mainProcessState as state } from './main-process-state' + +export function emitPluginWorktreeLifecycle(event: RuntimeWorktreeLifecycleEvent): void { + state.pluginService?.emitEvent( + event.kind === 'created' ? 'worktree.created' : 'worktree.removed', + event.kind === 'created' + ? { worktreeId: event.worktreeId, path: event.path, branch: event.branch } + : { worktreeId: event.worktreeId, path: event.path } + ) +} + +export function handleCodexHomePtySpawned(args: { + id: string + codexHomePath: string | null + reattached?: boolean + reattachedHomeRoute?: CodexPaneHomeRoute | null + launchEnv?: NodeJS.ProcessEnv + startedAt?: Date + startedSequence?: number +}): void { + // Why: only shared or ambiguous retained shells can create rollout logs that still need publication. + if (args.reattached && args.startedSequence !== undefined) { + const paneAccount = getCodexPaneAccount(args.id) + const homeRoute = + args.reattachedHomeRoute !== undefined + ? (args.reattachedHomeRoute ?? undefined) + : paneAccount?.homeRoute + if (state.codexSessionMigration && isCodexPaneHomeRouteProvenAwayFromSharedHome(homeRoute)) { + state.codexSessionMigration.ignoreLaunch(args.id, args.startedSequence) + return + } + } + const fullScanRequired = + state.codexRuntimeHome?.beginHostSystemDefaultSessionMigrationLaunch(args.codexHomePath, { + reattached: args.reattached, + launchEnv: args.launchEnv + }) ?? null + if (fullScanRequired !== null) { + state.codexSessionMigration?.beginLaunch( + args.id, + args.reattached === true || fullScanRequired, + args.startedAt, + args.startedSequence + ) + } +} + +export function handlePtyExit(id: string, exitSequence: number): void { + state.codexSessionMigration?.finishLaunch(id, exitSequence) +} + +/** A PTY that dies while Orca is down never runs the teardown that clears pane + * state, so hydrate can rebuild a Claude subagent roster that no later hook can + * retire — pinning the pane 'working' and locking its agent out of hibernation + * for good. Once provider and hook hydration settle, targeted PTY liveness can + * retire only rows whose local owner is proven gone. */ +export async function reapRestoredSubagentsWithoutLiveAgent(): Promise { + const store = state.store + if (!store) { + return + } + const provider = getDaemonProvider() + if (!provider) { + return + } + const persistedPtyIdByPaneKey = indexPersistedPaneKeyPtyIds( + store.getWorkspaceSession().terminalLayoutsByTabId ?? {} + ) + await sweepRestoredSubagentsWithoutLiveAgent({ + probeLiveLocalPty: (ptyId) => provider.probePtyLiveness(ptyId), + isLocalExecutionHost: (worktreeId) => + isLocalExecutionHost( + resolveAgentWorkspaceExecutionHostId(worktreeId, { + getRepo: (repoId) => store.getRepo(repoId), + getWorktreeMeta: (resolvedWorktreeId) => store.getWorktreeMeta(resolvedWorktreeId), + getFolderWorkspace: (folderWorkspaceId) => store.getFolderWorkspace(folderWorkspaceId), + getProjectGroups: () => store.getProjectGroups() + }) + ), + getBoundPtyIdForPaneKey: getPtyIdForPaneKey, + getPersistedPtyIdForPaneKey: (paneKey) => persistedPtyIdByPaneKey.get(paneKey), + reap: (isLocalHost, isLocalPaneAgentLive, isLocalPaneLivenessEvidenceCurrent) => + agentHookServer.reapRestoredClaudeSubagentsWithoutLiveAgent( + isLocalHost, + isLocalPaneAgentLive, + isLocalPaneLivenessEvidenceCurrent + ) + }) +} + +export function startTerminalRuntimeStartupServices(): WindowsDesktopStartupServices { + logStartupMilestone('first-window-startup-services-start') + const startupServices = startFirstWindowStartupServices({ + // Why: both desktop and headless serve must adopt the same persistent provider before creating terminals or a renderer. + startDaemonPtyProvider: async (signal) => { + logStartupMilestone('startup-service-start', { service: 'daemon-pty-provider' }) + // Why: only GUI-spawned macOS daemons watch for login-session death; a headless + // serve daemon must survive its spawning session ending (SSH disconnect). + await initDaemonPtyProvider(signal, { + macosLoginSessionWatch: process.platform === 'darwin' && !state.isServeMode + }) + // Why: a retained shell keeps its launch-time Codex home even when the current routing lane changes. + const hasRetainedManagedHostPane = hasRecordedManagedHostCodexPane() + if ( + state.codexRuntimeHome && + (hasRetainedManagedHostPane || hasAnyRecordedLegacyWslCodexPane()) + ) { + const livePtyIds = await listLiveDaemonPtyIds() + if (livePtyIds) { + reconcileCodexPaneAccountsWithLivePtys(livePtyIds) + const settings = state.store?.getSettings() + // Why (#16441): each retained home can run a codex app-server grant + // session. Awaiting them here delayed the first window by N sessions; + // a retained shell cannot invoke Codex before this provider serves. + if (hasRetainedManagedHostPane) { + void reconcileRetainedCodexHookHomes({ + hookService: codexHookService, + hooksEnabled: + isAgentStatusHooksEnabled(settings) && + settings?.disabledTuiAgents.includes('codex') !== true, + runtimeHomePaths: state.codexRuntimeHome.getRetainedHostCodexHookHomePaths(livePtyIds) + }).catch((error) => + console.warn('[codex-hook-service] retained Codex home reconcile failed:', error) + ) + } + } + } + // Why: retained shells can invoke Codex immediately after the startup gate. + state.codexRuntimeHome?.reconcileLegacySharedHomeForRetainedPanes() + logStartupMilestone('startup-service-done', { service: 'daemon-pty-provider' }) + }, + // Why: PTY spawn env reads ORCA_AGENT_HOOK_* from live server state, so the renderer awaits this before restored terminals reconnect. + startAgentHookServer: async () => { + const settings = state.store?.getSettings() + if (!isAgentStatusHooksEnabled(settings)) { + return + } + logStartupMilestone('startup-service-start', { service: 'agent-hook-server' }) + // Why (#11217): the hook listener fails open on every request error, so an IDS resetting + // loopback POSTs mid-body stops agent status for every runtime with no symptom but staleness. + // Log + telemetry (the daemon_start_failed pattern) so it is diagnosable without a packet capture. + agentHookServer.setTransportInterferenceListener((report) => { + track('agent_hook_transport_blocked', { count: report.count }) + }) + await agentHookServer.start({ + env: app.isPackaged ? 'production' : 'development', + // Why: hooks source this endpoint file at invocation time so old PTY env reaches the current process after restart; dev namespaces it (worktrees share `orca-dev`). + userDataPath: app.getPath('userData'), + endpointNamespace: state.devAgentHookEndpointNamespace + }) + logStartupMilestone('startup-service-done', { service: 'agent-hook-server' }) + }, + onDaemonError: (error) => { + // Why: daemon failure silently falls back to non-persistent local PTYs; log + telemetry so a fleet-wide outage is observable (was invisible in v1.4.129-rc.1). + const reason = error instanceof Error ? error.message : String(error) + console.error( + `[daemon] STARTUP FAILED — falling back to local PTYs; terminals will not persist across quit. Reason: ${reason}` + ) + track('daemon_start_failed', classifyError(error)) + }, + onAgentHookServerError: (error) => { + // Why: hook callbacks are sidebar enrichment only; Orca must still boot if the loopback receiver fails. + console.error('[agent-hooks] Failed to start local hook server:', error) + } + }) + void startupServices.firstWindowReady.then(() => + logStartupMilestone('first-window-startup-services-ready') + ) + void startupServices.localPtyReady.then(() => { + logStartupMilestone('local-pty-startup-ready') + void reapRestoredSubagentsWithoutLiveAgent().catch((error) => + console.warn('[agent-hooks] restored-subagent liveness probe failed:', error) + ) + }) + return startupServices +} + +export function bindTerminalRuntimeStartupServices( + services: Promise +): void { + state.firstWindowStartupServicesReady = services.then((value) => value.firstWindowReady) + state.localPtyStartupReady = services.then((value) => value.localPtyReady) + state.localPtyProviderStartupReady = services.then((value) => value.localPtyProviderReady) +} diff --git a/src/main/startup/main-process-quit.ts b/src/main/startup/main-process-quit.ts new file mode 100644 index 00000000000..61203bc3164 --- /dev/null +++ b/src/main/startup/main-process-quit.ts @@ -0,0 +1,286 @@ +import { app, type Event } from 'electron' +import { closeAllWatchers } from '../ipc/filesystem-watcher' +import { disposeWorktreeBaseDirectoryWatchers } from '../ipc/worktree-base-directory-watcher' +import { stopFolderRepoGitUpgradeWatch } from '../ipc/folder-repo-git-upgrade' +import { killAllPty } from '../ipc/pty' +import { disconnectDaemon, shutdownDaemon } from '../daemon/daemon-init' +import { beginSshShutdown } from '../ipc/ssh-shutdown-drain' +import { agentHookServer } from '../agent-hooks/server' +import { wslHookRelayManager } from '../agent-hooks/wsl-hook-relay-manager' +import { removeManagedAgentHooksAsync } from '../agent-hooks/managed-agent-hook-controls' +import { stopStructuredAgentSessionRuntime } from '../runtime/structured-agent-session-runtime' +import { awaitRuntimeFileWatcherUnsubscribes } from '../runtime/orca-runtime-files' +import { clearRuntimeMetadataIfOwned } from '../runtime/runtime-metadata' +import { shutdownPairedRuntimeBrowserClientHosts } from '../browser/paired-runtime-browser-client-host-runtime' +import { browserManager } from '../browser/browser-manager' +import { stopCodexStateDbBackfillRecoveries } from '../codex/codex-state-db-backfill-recovery' +import { awaitPackedRefsLockRelease } from '../git/local-repo-ref-maintenance' +import { settleTeardownWithinDeadline, settleWithinMs } from '../quit-teardown-deadline' +import { quitTeardownStartGate } from '../quit-teardown-start-gate' +import { setUnreadDockBadgeCount } from '../dock/unread-badge' +import { destroySystemTray } from '../tray/system-tray' +import { shutdownTelemetry } from '../telemetry/client' +import { shutdownObservability } from '../observability' +import { isQuittingForUpdate } from '../updater' +import { recordUpdaterLifecycle } from '../updater-lifecycle-diagnostics' +import { stopTccPromptNotice } from '../macos-tcc-prompt-notice' +import { shouldQuitWhenAllWindowsClosed } from './window-all-closed-quit-policy' +import { mainProcessState as state } from './main-process-state' +import { isDevParentShutdownRequested } from './configure-process' +import { getCanonicalUserDataPath } from '../persistence' + +// Why: will-quit fires twice — first pass preventDefaults and runs teardown; second pass exits. +let daemonDisconnectDone = false +let watcherShutdownPromise: Promise | null = null +// Why 2s: a config delete is best-effort, not durable state. +const GROK_HOOK_CLEANUP_DEADLINE_MS = 2_000 +// Why 2s: long enough for a `pack-refs` child to take SIGTERM and unlink its lock. +const REF_MAINTENANCE_QUIT_DEADLINE_MS = 2_000 + +function shutdownWatchersOnce(): Promise { + if (state.watcherShutdownDone) { + return Promise.resolve() + } + if (!watcherShutdownPromise) { + // Why: @parcel/watcher tears down native async work on unsubscribe; Electron must await it before Node's environment exits. + stopFolderRepoGitUpgradeWatch() + watcherShutdownPromise = Promise.allSettled([ + closeAllWatchers(), + disposeWorktreeBaseDirectoryWatchers() + ]) + .then((results) => { + for (const result of results) { + if (result.status === 'rejected') { + console.error('[filesystem-watcher] shutdown failed:', result.reason) + } + } + }) + .then(() => { + state.watcherShutdownDone = true + }) + } + return watcherShutdownPromise +} + +function installBeforeQuitHandler(): void { + app.on('before-quit', () => { + if (isQuittingForUpdate()) { + recordUpdaterLifecycle('before_quit_allowed', undefined, { + message: 'before-quit allowed for update install' + }) + } + state.isQuitting = true + state.desktopRelayService?.fenceAndCloseNow() + state.runtimeRpc?.setMobileRelayPairingProvider(null) + state.unsubscribeAgentAwakeStatusChanges?.() + state.unsubscribeAgentAwakeStatusChanges = null + state.agentAwakeService?.dispose() + state.agentAwakeService = null + // Why wait but not uninstall: a renderer beforeunload can still veto this + // quit, and tearing the sweep down here would kill it for the rest of the + // session. `isQuitting` already vetoes new attempts; will-quit does the teardown. + state.repoMaintenanceShutdown = awaitPackedRefsLockRelease() + // Why: defer PTY cleanup to will-quit so the renderer captures scrollback before PTY-exit events unmount TerminalPane (dropping its capture callbacks). + state.rateLimits?.stop() + }) +} + +function installWillQuitHandler(): void { + app.on('will-quit', (event: Event) => { + // Why return instead of re-running teardown: the second pass is Electron re-firing after + // our own app.quit(), so every step below already ran and every durable write already + // landed. Re-entering would start a fresh unawaited write that the exit then tears down. + if (daemonDisconnectDone) { + return + } + // Why preventDefault before any work: everything below must be free to await, and a + // synchronous durable write here parks the main thread — uninterruptibly, on a stalled + // network profile mount. The teardown deadline cannot rescue that, because its timer + // lives on the same thread it would need to bound (#9447 covers the wedged-transport + // half; this covers the blocked-syscall half). + if (!quitTeardownStartGate.tryStart(event)) { + return + } + state.unsubscribeSystemResumeBroadcast?.() + state.unsubscribeSystemResumeBroadcast = null + // Why: renderer guards can still cancel before this committed phase; `log stream` must survive those vetoes. + stopTccPromptNotice() + const updateQuitInProgress = isQuittingForUpdate() + if (updateQuitInProgress) { + recordUpdaterLifecycle( + 'will_quit_cleanup_started', + { daemonTeardown: 'disconnect' }, + { message: 'will-quit cleanup for update install; daemonTeardown=disconnect' } + ) + } + // Why: before-quit can still be aborted by renderer beforeunload; only remove the Windows tray icon on the committed quit path. + destroySystemTray() + // Why: an agent still working at quit gets no terminating hook, so stats.flushAsync() closes those sessions out synchronously (only the write is deferred) — otherwise their duration is lost. + state.starNag?.stop() + state.automations?.stop() + // Why: plugin hosts are forked children; dispose sends shutdown and + // escalates to SIGKILL so they cannot outlive the app. The promise joins + // the teardown barrier below — quitting before it resolves would let + // Electron exit first and orphan the hosts. + state.pluginKillListService = null + state.pluginMarketplaceService = null + state.pluginMarketplaceInstaller = null + const pluginHostShutdown = state.pluginService?.dispose() ?? Promise.resolve() + const codexBackfillRecoveryShutdown = stopCodexStateDbBackfillRecoveries() + const structuredAgentSessionShutdown = stopStructuredAgentSessionRuntime() + state.pluginService = null + setUnreadDockBadgeCount(0) + // Why wait rather than kill: the child finishes fine orphaned, and signalling + // it mid-prune strands a ref lock Git never clears. The wait is only for the + // short rewrite window, and is bounded so a quit can never hang on it. + const refMaintenanceShutdown = settleWithinMs( + Promise.all([state.repoMaintenanceShutdown, state.uninstallRepoMaintenanceIdleGate?.()]).then( + () => {} + ), + REF_MAINTENANCE_QUIT_DEADLINE_MS + ).then(() => {}) + state.uninstallRepoMaintenanceIdleGate = null + agentHookServer.stop() + // Why Windows only: POSIX hooks short-circuit on ORCA_PANE_KEY, while Windows must register a + // bare script path that cannot express the guard and would otherwise keep spawning after quit. + // Why bounded here: every other teardown member carries its own ceiling, and this one reaches + // $GROK_HOME -- which can be a stalled network mount, where the fs calls never settle and the + // shared 20s deadline becomes the only thing ending the quit. + const grokHookCleanup = + process.platform === 'win32' + ? settleWithinMs( + removeManagedAgentHooksAsync({ agents: ['grok'] }), + GROK_HOOK_CLEANUP_DEADLINE_MS + ).then((settled) => { + if (settled.outcome === 'timed-out') { + console.warn('[agent-hooks] Grok hook cleanup on quit timed out') + return + } + if (settled.outcome === 'failed') { + console.warn('[agent-hooks] Grok hook cleanup on quit failed:', settled.error) + return + } + // Why: removers report failures as statuses, so inspect details even after fulfillment. + for (const status of settled.value.filter((entry) => entry.detail)) { + console.warn(`[agent-hooks] ${status.agent} hook cleanup on quit: ${status.detail}`) + } + }) + : Promise.resolve() + // Why: cancels relay restart/reinstall timers and kills wsl.exe children deterministically, not via stdio-pipe teardown. + wslHookRelayManager.disposeAll() + const statsFlush = state.stats?.flushAsync() ?? Promise.resolve() + // Why: agent-browser daemon processes would otherwise linger after quit, holding ports and stale session state on disk. + // Why the barrier below: each session's close is its own agent-browser child taking hundreds of ms, + // so an unawaited call reaches app.quit() first and every open tab's daemon survives the quit (#16367). + // Why retire headless page owners first: it closes those helpers without a duplicate close fanout. + const browserShutdown = (async (): Promise => { + await state.runtime?.getOffscreenBrowserBackend()?.destroyAll?.() + await state.runtime?.getAgentBrowserBridge()?.destroyAllSessions() + })() + // Why (review P2-4): local SSH browser routes own loopback listeners and, on the + // system-ssh path, `ssh -N -D` children that would otherwise outlive the app. + const localSshRouteShutdown = import('../browser/local-ssh-browser-route') + .then((routes) => routes.closeAllLocalSshBrowserRoutes()) + .catch(() => {}) + browserManager.setBrowserGuestStateChangedListener(null) + const emulatorShutdown = + state.runtime?.getEmulatorBridge()?.destroyAllSessions() ?? Promise.resolve() + // Why immediately before store.flushAsync() with no await in between: beginSshShutdown() marks every + // active SSH lease detached in memory synchronously, and that flush is what persists it. + const sshShutdown = beginSshShutdown() + killAllPty() + const watcherShutdown = shutdownWatchersOnce() + const storeFlush = state.store?.flushAsync() ?? Promise.resolve() + // Why: usage-cache writes are queued off the main thread, so a quit right after setEnabled or a + // scan completion would drop the final snapshot. Captured before any await; joins the barrier below. + const usageCacheFlush = Promise.all([ + state.claudeUsage?.flush(), + state.codexUsage?.flush(), + state.openCodeUsage?.flush() + ]).then(() => {}) + const browserClientHostShutdown = shutdownPairedRuntimeBrowserClientHosts() + const skillUploadShutdown = state.runtime?.disposeSkillUploadSessions() ?? Promise.resolve() + // Why: capture pid/runtimeId synchronously (before any await) so a later teardown path can't null them out mid-chain. + const ownedPid = process.pid + const ownedRuntimeId = state.runtime?.getRuntimeId() + const rpcStopAndClear = state.runtimeRpc + ? state.runtimeRpc + .stop() + .then(() => awaitRuntimeFileWatcherUnsubscribes()) + .then(() => { + if (ownedRuntimeId) { + // Why: must match the path the runtime server wrote metadata to (getCanonicalUserDataPath), not late app.getPath('userData'). + clearRuntimeMetadataIfOwned(getCanonicalUserDataPath(), ownedPid, ownedRuntimeId) + } + }) + .catch((error) => console.error('[runtime] Failed to stop local RPC transport:', error)) + : Promise.resolve() + // Why: allSettled (not all) keeps fail-open — a daemon-disconnect rejection still quits instead of hanging. + // Why: telemetry flush folds in before app.quit() (bounded 2s); catch defensively so a flush failure can't cancel the quit chain. + // Why: normal quits keep the detached daemon for warm reattach, but a dead dev parent leaves the temp/dev profile ownerless. + const daemonTeardown = isDevParentShutdownRequested() ? shutdownDaemon() : disconnectDaemon() + // Why: a wedged transport (half-open post-sleep socket) can leave one + // member unsettled forever and block app.quit() until Force Quit (#9447). + // Why stats/state join here: their writes are durable but not worth hanging the app for. + // Losing at most the last debounce interval beats a quit that never completes, and the + // temp+rename swap means a write cut short by the deadline leaves the old file intact. + settleTeardownWithinDeadline([ + { name: 'daemon', promise: daemonTeardown }, + { name: 'browser', promise: browserShutdown }, + { name: 'runtime-rpc', promise: rpcStopAndClear }, + { name: 'watchers', promise: watcherShutdown }, + { name: 'emulator', promise: emulatorShutdown }, + { name: 'browser-client-hosts', promise: browserClientHostShutdown }, + { name: 'local-ssh-browser-routes', promise: localSshRouteShutdown }, + { name: 'ssh', promise: sshShutdown }, + { name: 'plugin-hosts', promise: pluginHostShutdown }, + { name: 'skill-uploads', promise: skillUploadShutdown }, + { name: 'grok-hooks', promise: grokHookCleanup }, + { name: 'ref-maintenance', promise: refMaintenanceShutdown }, + { name: 'codex-backfill-recovery', promise: codexBackfillRecoveryShutdown }, + { name: 'structured-agent-session', promise: structuredAgentSessionShutdown }, + { name: 'usage-cache', promise: usageCacheFlush }, + { name: 'stats', promise: statsFlush }, + { name: 'state', promise: storeFlush } + ]) + .then((pendingTeardowns) => { + if (pendingTeardowns.length > 0) { + console.warn('[shutdown] Quit teardown deadline reached', { pendingTeardowns }) + } + }) + .then(() => shutdownTelemetry()) + .then(() => shutdownObservability()) + .catch(() => { + /* swallow — telemetry must never prevent app.quit() */ + }) + .then(() => { + daemonDisconnectDone = true + app.quit() + }) + }) +} + +function installWindowAllClosedHandler(): void { + app.on('window-all-closed', () => { + // Why: serve mode / disposable offscreen browser windows must not take down runtime RPC — the policy fn keeps the app alive. + // Why: on macOS a quit-in-progress (Cmd+Q) is canceled by the renderer buffer-capture deferral; re-trigger quit so it actually exits. + if ( + shouldQuitWhenAllWindowsClosed({ + platform: process.platform, + isQuitting: state.isQuitting, + isServeMode: state.isServeMode + }) + ) { + app.quit() + } + }) +} + +/** Installs the process-level shutdown listeners once during bootstrap. */ +export function installMainProcessQuitHandlers(): void { + // Why: app.exit() skips Electron quit events, so keep its log child from surviving forced exits. + process.once('exit', stopTccPromptNotice) + installBeforeQuitHandler() + installWillQuitHandler() + installWindowAllClosedHandler() +} diff --git a/src/main/startup/main-process-ready-foundation.ts b/src/main/startup/main-process-ready-foundation.ts new file mode 100644 index 00000000000..e122961b0c9 --- /dev/null +++ b/src/main/startup/main-process-ready-foundation.ts @@ -0,0 +1,274 @@ +import { app, session } from 'electron' +import { electronApp, is } from '@electron-toolkit/utils' +import { applyBackgroundActivationPolicy } from '../window/foreground-activation-policy' +import { applyElectronProxySettings } from '../network/proxy-settings' +import { installElectronProxyRequestGuard } from '../network/electron-proxy-request-guard' +import { handleElectronProxyLogin } from '../network/electron-proxy-credentials' +import { installMainThreadHangWatchdog } from '../hang-watchdog/main-thread-hang-watchdog' +import { + consumeHangDetectionMarker, + hangDetectionMarkerPath +} from '../hang-watchdog/hang-detection-marker' +import { browserCertificateTrustController } from '../browser/browser-manager' +import { ensureActiveOrcaProfile } from '../orca-profiles/profile-index-store' +import { Store, getCanonicalUserDataPath } from '../persistence' +import { initializeBrowserClientHostId } from '../browser/browser-client-host-id' +import { scheduleSecretProtectionGapReport } from '../host/deferred-secret-protection-report' +import { initSshHostKeyStoreFile } from '../ssh/ssh-host-key-store' +import { neutralizeLegacyTerminalShimDir } from '../pty/legacy-terminal-shim-dir' +import { createWindowsShellPathHydration } from './windows-shell-path-hydration' +import { + configureWindowsHostGitEnvironmentReadiness, + setDefaultWslDistroOverride +} from '../git/runner' +import { wslHookRelayManager } from '../agent-hooks/wsl-hook-relay-manager' +import { + attachClaudeLivePtyPersistence, + onLiveClaudePtysDrained, + seedLiveClaudePtysFromPersistence +} from '../claude-accounts/live-pty-gate' +import { applyAppIcon } from '../app-icon' +import { + shouldSuppressDevEducation, + suppressDevEducationForStore +} from './dev-education-suppression' +import { + applyBrowserSessionProxies, + setBrowserNetworkProxySettingsResolver +} from '../browser/browser-session-proxy' +import { installDocPreviewProtocolHandler } from '../browser/doc-preview-protocol' +import { registerDocPreviewGrantHandlers } from '../ipc/doc-preview-grant-ipc' +import { initializeBrowserSessionsForApp } from '../browser/browser-session-startup' +import { browserSessionRegistry } from '../browser/browser-session-registry' +import { logStartupMilestone } from './startup-diagnostics' +import { mainProcessState as state } from './main-process-state' +import { recordDurableCrashBreadcrumb } from '../crash-reporting/durable-crash-breadcrumb' +import { syncMacMenuBarIcon } from './main-window-actions' +import { updateGpuAccelerationAboutPanel } from './gpu-lifecycle' +import { reconcileManagedWslCliRegistrations } from '../cli/wsl-cli-registration-reconciliation' +import { createWslCliReconciliationStartupBarrier } from './wsl-cli-reconciliation-startup-barrier' +import { isAgentStatusHooksEnabled } from '../agent-hooks/managed-agent-hook-controls' + +export async function initializeReadyFoundation(): Promise { + logStartupMilestone('app-ready') + // Why: a headless automated run must not claim a macOS Dock tile or the menu bar. + applyBackgroundActivationPolicy({ warn: console.warn }) + installElectronProxyRequestGuard(session.defaultSession) + app.on('login', (event, webContents, details, authInfo, callback) => { + handleElectronProxyLogin( + event, + webContents, + details, + authInfo, + callback, + session.defaultSession + ) + }) + const canonicalUserDataPath = getCanonicalUserDataPath() + installMainThreadHangWatchdog({ userDataPath: canonicalUserDataPath }) + state.hangDetection = consumeHangDetectionMarker(hangDetectionMarkerPath(canonicalUserDataPath)) + if (state.hangDetection) { + recordDurableCrashBreadcrumb('main_thread_hang_detected', { + unresponsiveMs: state.hangDetection.unresponsiveMs, + previousPid: state.hangDetection.parentPid, + selfRecovered: state.hangDetection.selfRecovered + }) + } + // Why: install certificate decisions before any webview or headless window issues its first TLS request. + app.on( + 'certificate-error', + (event, webContents, url, error, certificate, callback, isMainFrame) => { + browserCertificateTrustController.handleCertificateError({ + event, + webContents, + url, + error, + certificate, + callback, + isMainFrame + }) + } + ) + const identity = state.devInstanceIdentity + if (!identity) { + throw new Error('Development identity is unavailable') + } + electronApp.setAppUserModelId(identity.appUserModelId) + // Why: names the app menu/About panel. Dev already applied this pre-ready (see the + // safeStorage note above); this call stays unconditional so packaged builds keep their + // existing post-ready rename, which lands after the Keychain name is already resolved. + app.setName(identity.appName) + updateGpuAccelerationAboutPanel() + // Why: managed WSL launchers live outside the Windows app bundle, so keep their launcher/bridge contract synced across app updates. + state.managedWslCliReconciliationStatus = 'pending' + state.managedWslCliReconciliationReady = reconcileManagedWslCliRegistrations({ + isPackaged: app.isPackaged, + userDataPath: canonicalUserDataPath, + appVersion: app.getVersion() + }) + .then((results) => { + for (const result of results) { + if (result.outcome === 'failed') { + console.warn( + `[wsl-cli] ${result.distro} managed registration reconciliation failed: ${result.error}` + ) + } else if (result.outcome === 'repaired') { + console.log(`[wsl-cli] Repaired managed registration in ${result.distro}.`) + } + } + state.managedWslCliReconciliationStatus = 'settled' + }) + .catch((error) => { + state.managedWslCliReconciliationStatus = 'failed' + console.warn( + '[wsl-cli] Managed registration reconciliation discovery failed:', + error instanceof Error ? error.message : String(error) + ) + }) + state.managedWslCliStartupBarrierReady = createWslCliReconciliationStartupBarrier( + state.managedWslCliReconciliationReady + ) + const profile = ensureActiveOrcaProfile() + state.activeOrcaProfile = profile + // Why this early: the first window stamps the hosting id into its renderer's argv, so the durable + // read has to have happened by then or the renderer and the browser-host lease disagree. + initializeBrowserClientHostId(profile.profileDirectory) + const store = new Store({ + dataFile: profile.dataFile, + storageAuthority: state.isServeMode ? 'runtime' : 'desktop' + }) + state.store = store + // Why: create pending readiness before the guard can observe the default session. + const initialProxyApplication = applyElectronProxySettings(store.getSettings()) + installElectronProxyRequestGuard(session.defaultSession) + // Why armed here and not at install time: the report remembers what it last said, and + // that state lives beside the profile data file, which does not exist until now. + // Why scheduled and not called: the report probes the OS keyring, which blocks on Linux + // and must not gate the first window (STA-5765). + scheduleSecretProtectionGapReport({ + dataFile: profile.dataFile, + force: process.env.ORCA_ALWAYS_REPORT_SECRET_PROTECTION === '1', + deferUntilFirstWindow: !state.isServeMode, + skipInDevelopment: is.dev + }) + // Why here: the host key store is a sidecar of the same profile, and every SSH connect consults + // it. Left unbound it reports nothing trusted, which is safe but silently discards our own + // accept records on every launch. + initSshHostKeyStoreFile(profile.dataFile) + // Why: must precede PTY handler registration and run in headless serve too, which returns before openMainWindow. + neutralizeLegacyTerminalShimDir(app.getPath('userData')) + const windowsShellPathHydration = createWindowsShellPathHydration() + state.windowsShellPathHydration = windowsShellPathHydration + configureWindowsHostGitEnvironmentReadiness( + process.platform === 'win32' ? windowsShellPathHydration.whenReady : null + ) + if (process.platform === 'win32') { + const settings = store.getSettings() + if (app.isPackaged) { + void windowsShellPathHydration.hydrate( + settings.terminalWindowsShell, + settings.terminalWindowsPowerShellImplementation + ) + } else { + windowsShellPathHydration.configure( + settings.terminalWindowsShell, + settings.terminalWindowsPowerShellImplementation + ) + } + } + wslHookRelayManager.setManagedHookSettingsResolver(() => state.store?.getSettings() ?? null) + logStartupMilestone('store-loaded') + // Why: apply initial fallback WSL distro from store settings for global git/CLI calls. + setDefaultWslDistroOverride(store.getSettings().terminalWindowsWslDistro ?? null) + store.onSettingsChanged((updates, settings) => { + if ('terminalWindowsWslDistro' in updates) { + // Why: synchronize fallback WSL distro updates to runner. + setDefaultWslDistroOverride(settings.terminalWindowsWslDistro ?? null) + } + if ( + ('terminalWindowsShell' in updates || 'terminalWindowsPowerShellImplementation' in updates) && + process.platform === 'win32' + ) { + if (app.isPackaged) { + void windowsShellPathHydration.hydrate( + settings.terminalWindowsShell, + settings.terminalWindowsPowerShellImplementation + ) + } else { + windowsShellPathHydration.configure( + settings.terminalWindowsShell, + settings.terminalWindowsPowerShellImplementation + ) + } + } + if ('showMenuBarIcon' in updates) { + // Why: Store is the mutation authority for all settings writes, so every macOS toggle updates the native item live. + syncMacMenuBarIcon(settings.showMenuBarIcon !== false) + } + if ('agentStatusHooksEnabled' in updates) { + // Why both directions: the ensure gate only blocks NEW relays, so off must stop the running + // guest process and timers, and on must restart them — otherwise open WSL panes report no + // status until their next spawn. + if (isAgentStatusHooksEnabled(settings)) { + wslHookRelayManager.resumeStoppedRelays() + } else { + wslHookRelayManager.disposeAll({ permanent: false }) + } + } + }) + // Why: run before ClaudeRuntimeAuthService's constructor sync — a surviving daemon Claude CLI holds the single-use refresh token; early refresh rotates it out mid-session. + attachClaudeLivePtyPersistence(store) + // Why: while a live claude defers the managed OAuth refresh, usage shows + // "Waiting for Claude session"; refetch when the last live PTY exits so the + // error clears immediately instead of after the failure backoff. + onLiveClaudePtysDrained(() => { + void state.rateLimits?.refreshAfterClaudeLivePtysDrained() + }) + const persistedClaudePtyIds = store.getClaudeLivePtySessionIds() + seedLiveClaudePtysFromPersistence(persistedClaudePtyIds) + if (persistedClaudePtyIds.length > 0) { + console.log( + `[claude-live-pty] Seeded ${persistedClaudePtyIds.length} persisted Claude session id(s) into the refresh gate` + ) + } + applyAppIcon(store.getSettings().appIcon) + if (shouldSuppressDevEducation({ isDev: is.dev })) { + suppressDevEducationForStore(store) + } + try { + // Why: Dock/Launchpad launches don't inherit shell proxy env vars, so apply the persisted proxy before any app-owned network fetchers run. + const proxyApplyResult = await initialProxyApplication + if (proxyApplyResult.source === 'invalid-settings') { + // Why (STA-3442): a silent DIRECT fallback made a dead configured proxy undiagnosable. + console.warn('[proxy] persisted proxy settings are invalid; using direct networking') + } + } catch { + console.warn('[proxy] Failed to apply network proxy settings') + } + // Why: the partition installer reads the proxy through this resolver, so register it before sessions materialize. + setBrowserNetworkProxySettingsResolver(() => state.store!.getSettings()) + // Why: the preview session is protocol-scoped, so the handler must exist before any preview webview attaches. + installDocPreviewProtocolHandler() + registerDocPreviewGrantHandlers() + // Why: browser sessions serve desktop webviews and runtime profile commands, so init at app startup rather than via a renderer IPC path. + initializeBrowserSessionsForApp({ + orcaProfileId: profile.profile.id, + profileDirectory: profile.profileDirectory, + // Why: local direct-SSH partitions are scoped to targets, and the orphan + // sweep must see the live target list or it would clear their cookie jars. + listLocalSshTargetIds: () => { + const currentStore = state.store + if (!currentStore) { + // Why: an empty list would read as "every SSH jar is an orphan"; throwing skips the sweep. + throw new Error('ssh target store unavailable at partition sweep') + } + return currentStore.getSshTargets().map((target) => target.id) + } + }) + try { + // Why: awaited here so the first guest navigation cannot race the installer's fire-and-forget write. + await applyBrowserSessionProxies(browserSessionRegistry.listProfiles(), store.getSettings()) + } catch { + console.warn('[proxy] Failed to apply network proxy settings to browser sessions') + } +} diff --git a/src/main/startup/main-process-ready-runtime.ts b/src/main/startup/main-process-ready-runtime.ts new file mode 100644 index 00000000000..f89f63186a0 --- /dev/null +++ b/src/main/startup/main-process-ready-runtime.ts @@ -0,0 +1,147 @@ +import { app, nativeTheme } from 'electron' +import { randomUUID } from 'node:crypto' +import { performance } from 'node:perf_hooks' +import { is } from '@electron-toolkit/utils' +import { StarNagService } from '../star-nag/service' +import { AgentBrowserBridge } from '../browser/agent-browser-bridge' +import { EmulatorBridge } from '../emulator/emulator-bridge' +import { RpcDispatcher } from '../runtime/rpc/dispatcher' +import { browserManager } from '../browser/browser-manager' +import { configureBrowserClientPageAutomationRuntime } from '../browser/browser-client-page-automation-runtime' +import { BrowserClientPageCommandError } from '../browser/browser-client-page-command-failure' +import { startPreGoneProcessMetricsSampling } from '../crash-reporting/process-gone-diagnostics' +import { recordProcessGoneCrash } from './main-window-lifecycle-flags' +import { handleGpuChildCrash } from './gpu-lifecycle' +import { isGpuFallbackCrashCandidate } from '../crash-reporting/gpu-crash-fallback-decision' +import { ensureRealHomeCodexHookState } from '../codex/codex-real-home-hook-install' +import { + installManagedAgentHooks, + resolveStartupManagedHookAction, + shouldContinueManagedHookStartup, + shouldInstallStartupManagedAgentHook +} from '../agent-hooks/managed-agent-hook-controls' +import { shouldInstallManagedHooks } from './configure-process' +import { recordManagedHookInstallFailure } from '../agent-hooks/install-telemetry' +import { mainProcessState as state } from './main-process-state' +import { initializeMainProcessObservers } from './main-process-observers' +import { initializeMainProcessAccountServices } from './main-process-account-services' +import { + initializeMainProcessRuntime, + configureRuntimeServices +} from './main-process-runtime-service' +import { initializeMainProcessAutomations } from './main-process-automations' +import { initializeMainProcessPlugins } from './main-process-plugins' +import { collectWorktreeTrashSweepRoots, sweepStaleWorktreeTrash } from '../worktree-trash' +import { logStartupMilestone } from './startup-diagnostics' + +export async function initializeReadyRuntimeServices(): Promise { + const store = state.store + if (!store) { + throw new Error('Store must be initialized before ready services') + } + initializeMainProcessObservers() + initializeMainProcessAccountServices() + const runtime = initializeMainProcessRuntime() + initializeMainProcessAutomations() + configureRuntimeServices(runtime) + await initializeMainProcessPlugins(runtime) + state.starNag = new StarNagService(store, state.stats!) + state.starNag.start() + state.starNag.registerIpcHandlers() + state.agentBrowserBridge = new AgentBrowserBridge(browserManager, { + onTabsChanged: (worktreeId) => runtime.notifyMobileSessionTabsChanged(worktreeId) + }) + runtime.setAgentBrowserBridge(state.agentBrowserBridge) + // Why: daemons a crashed or SIGKILL'd previous run left behind answer to nobody; nothing else reclaims them. + void state.agentBrowserBridge.sweepOrphanedSessions() + const browserClientAutomationDispatcher = new RpcDispatcher({ runtime }) + configureBrowserClientPageAutomationRuntime({ + browserManager, + getAgentBrowserBridge: () => state.agentBrowserBridge, + executeRpc: async (method, params, signal) => { + const response = await browserClientAutomationDispatcher.dispatch( + { id: randomUUID(), authToken: 'local-browser-client-automation', method, params }, + { signal } + ) + if (!response.ok) { + throw new BrowserClientPageCommandError(response.error.code) + } + return response.result + } + }) + // Emulator bridge (serve-sim). macOS-only feature (gated in CLI/runtime); always ship like agent-browser. + // Why: externally started serve-sim processes must stay independent — only Orca-managed/attached helpers belong to a workspace. + state.emulatorBridge = new EmulatorBridge() + runtime.setEmulatorBridge(state.emulatorBridge) + // Why: worktree deletion renames the checkout aside and deletes it in the background, so a quit or + // crash mid-delete can leave the moved directory on disk. + void sweepStaleWorktreeTrash( + collectWorktreeTrashSweepRoots(store.getRepos(), store.getSettings()) + ).catch((error) => { + console.warn('[worktrees] Failed to sweep leftover worktree directories:', error) + }) + nativeTheme.themeSource = store.getSettings().theme ?? 'system' + // Why (#16441): the real-home grant runs a codex app-server session. It stays + // ordered before managed-hook reconciliation — an incapable host must re-arm + // and complete the legacy real-home sweep first — but awaiting it inline + // stalled app init behind that session, so chain instead of blocking. + const startupManagedHookSettings = store.getSettings() + const shouldReconcileStartupManagedHooks = + shouldInstallManagedHooks(is.dev) && + resolveStartupManagedHookAction(startupManagedHookSettings) === 'install' + const realHomeCodexHookState = + shouldReconcileStartupManagedHooks && + shouldInstallStartupManagedAgentHook(startupManagedHookSettings, 'codex') && + state.codexRuntimeHome?.isHostSystemDefaultRealHomeSelected() + ? ensureRealHomeCodexHookState({ + hooksEnabled: true, + userDataPath: app.getPath('userData') + }).catch((error: unknown) => { + console.warn('[codex-real-home-hooks] startup ensure failed:', error) + }) + : Promise.resolve() + // Why skip rather than remove when the off switch is set: the hook files are user-global but this + // decision reads only THIS profile's settings, so removing here deletes the hooks every other Orca + // instance depends on (STA-5679). Skipping already keeps removed hooks from reappearing on launch. + if (shouldReconcileStartupManagedHooks) { + const managedHookStore = store + void realHomeCodexHookState + .then(() => + installManagedAgentHooks(managedHookStore.getSettings(), { + shouldHydrateShellPath: app.isPackaged, + onInstallError: recordManagedHookInstallFailure, + shouldContinue: (agent) => + shouldContinueManagedHookStartup( + state.isQuitting, + managedHookStore.getSettings(), + agent + ) + }) + ) + .catch((error: unknown) => + console.warn('[agent-hooks] failed to reconcile managed hooks on startup:', error) + ) + } + // Why: process-gone metrics only see survivors; retain a recent whole-app + // snapshot for comparison in crash reports. + startPreGoneProcessMetricsSampling() + app.on('child-process-gone', (_event, details) => { + recordProcessGoneCrash('child', details.type, details.reason, details.exitCode ?? null, { + name: details.name, + serviceName: details.serviceName, + type: details.type + }) + if ( + isGpuFallbackCrashCandidate({ + platform: process.platform, + processType: details.type, + reason: details.reason + }) + ) { + const crashedAt = performance.now() + void state.gpuCrashDiagnostics?.record() + void handleGpuChildCrash(details.reason, details.exitCode ?? null, crashedAt) + } + }) + logStartupMilestone('services-initialized') +} diff --git a/src/main/startup/main-process-ready.ts b/src/main/startup/main-process-ready.ts new file mode 100644 index 00000000000..e6d8d782e6e --- /dev/null +++ b/src/main/startup/main-process-ready.ts @@ -0,0 +1,17 @@ +import { initializeMainProcessI18nAndMenu } from './main-process-i18n-menu' +import { initializeReadyFoundation } from './main-process-ready-foundation' +import { initializeReadyRuntimeServices } from './main-process-ready-runtime' +import { + initializeMainProcessRuntimeLaunch, + type MainProcessRuntimeLaunchOptions +} from './main-process-runtime-launch' + +/** Runs the ready-phase composition in the same dependency order as the legacy entry point. */ +export async function initializeMainProcessReady( + options: MainProcessRuntimeLaunchOptions +): Promise { + await initializeReadyFoundation() + await initializeReadyRuntimeServices() + await initializeMainProcessI18nAndMenu() + await initializeMainProcessRuntimeLaunch(options) +} diff --git a/src/main/startup/main-process-runtime-launch.ts b/src/main/startup/main-process-runtime-launch.ts new file mode 100644 index 00000000000..78061fb439c --- /dev/null +++ b/src/main/startup/main-process-runtime-launch.ts @@ -0,0 +1,311 @@ +import { app, powerMonitor, type BrowserWindow } from 'electron' +import { is } from '@electron-toolkit/utils' +import { getOrcaCloudAuthConfig } from '../orca-profiles/profile-cloud-auth-config' +import { getProfileUserDataPath } from '../orca-profiles/profile-storage-paths' +import { + getCanonicalUserDataPath, + migrateMobilePairingDataToCanonicalUserDataPath +} from '../persistence' +import { OrcaRuntimeRpcServer } from '../runtime/runtime-rpc' +import { registerMobileHandlers } from '../ipc/mobile' +import { getLocalPtyProvider, registerHeadlessPtyRuntime } from '../ipc/pty' +import { LocalPtyProvider } from '../providers/local-pty-provider' +import { HEADLESS_RUNTIME_WINDOW_ID } from '../../shared/runtime-types' +import { OffscreenBrowserBackend } from '../browser/offscreen-browser-backend' +import { browserManager } from '../browser/browser-manager' +import { DesktopRelayService } from '../runtime/relay/desktop-relay-service' +import { getServeOptions, getBundledWebClientRoot, printServeReady } from './main-process-serve' +import { + bindTerminalRuntimeStartupServices, + handleCodexHomePtySpawned, + handlePtyExit, + startTerminalRuntimeStartupServices +} from './main-process-pty-startup' +import { prepareCodexRuntimeHomeForLaunch } from './codex-launch-preparation' +import { prepareCodexSessionResumeForLaunch } from './codex-session-resume-launch' +import { startWindowsDesktopBeforeShellPathReady } from './windows-desktop-shell-path-startup' +import { registerServeSignalHandlers } from './serve-signal-handlers' +import { settleServeDesktopActivation } from './serve-desktop-activation' +import { + recordRuntimeRpcStartFailure, + showRuntimeRpcStartupFailureDialog +} from '../runtime/runtime-rpc-startup-failure' +import { CliInstaller } from '../cli/cli-installer' +import { installLinuxBareOrcaDispatcher } from '../cli/linux-bare-orca-dispatcher' +import { scheduleAllPendingHistoryTreeRemovals } from '../terminal-history-deletion' +import { triggerStartupNotificationRegistration } from '../ipc/startup-notification-registration' +import { mainProcessState as state } from './main-process-state' +import { logStartupMilestone } from './startup-diagnostics' + +type RuntimeService = NonNullable + +export type MainProcessRuntimeLaunchOptions = { + openMainWindow: (options?: { revealOnDidFinishLoad?: boolean }) => BrowserWindow + handleMacAppActivation: () => void +} + +function settleDesktopActivation(): void { + const gate = state.desktopActivationGate + if (!gate) { + return + } + settleServeDesktopActivation(gate, { + hasPersistentPtyProvider: !(getLocalPtyProvider() instanceof LocalPtyProvider) + }) +} + +function installRuntimeRpc( + runtime: RuntimeService, + serveOptions: ReturnType | null +): OrcaRuntimeRpcServer { + // Why: existing installs may have pairing creds under the late app.getPath('userData'); copy them forward before switching to the canonical path. + migrateMobilePairingDataToCanonicalUserDataPath(app.getPath('userData')) + // Why: parallel E2E Electron instances would race the fixed port (EADDRINUSE); port 0 gives each a random OS-assigned port. + const isE2E = Boolean(process.env.ORCA_E2E_USER_DATA_DIR) + const requestedE2EWsPort = process.env.ORCA_E2E_RUNTIME_WS_PORT + const e2eWsPort = requestedE2EWsPort === undefined ? 0 : Number(requestedE2EWsPort) + if (isE2E && (!Number.isInteger(e2eWsPort) || e2eWsPort < 0 || e2eWsPort > 65_535)) { + throw new Error(`Invalid ORCA_E2E_RUNTIME_WS_PORT value: ${requestedE2EWsPort}`) + } + // Why: pin dev to 6769 so `pnpm dev` doesn't race packaged Orca on 6768 and fall back to a random port, breaking deterministic mobile pairing/repro (STA-1511). + const devWsPort = is.dev && !isE2E ? 6769 : undefined + const runtimeRpc = new OrcaRuntimeRpcServer({ + runtime, + // Why: mobile pairing needs the stable pre-setName() path (getCanonicalUserDataPath), not a late app.getPath('userData') that drops paired devices across restarts. + userDataPath: getCanonicalUserDataPath(), + enableWebSocket: true, + // Why: STA-2370 — the desktop app binds the WS listener to loopback until the user pairs a device; + // `orca serve` is an explicit remote opt-in, and E2E keeps the wide bind its harness connects over. + exposeNetworkByDefault: Boolean(serveOptions) || isE2E, + ...(isE2E ? { wsPort: e2eWsPort } : {}), + ...(devWsPort !== undefined ? { wsPort: devWsPort } : {}), + ...(serveOptions?.wsPort !== undefined + ? { + wsPort: serveOptions.wsPort, + // Why: only explicit `orca serve --port` overrides a stale STA-1511 fallback (issue #8535); default/dev stay fallback-first for pairing stability. + preferPinnedWsPort: true + } + : {}), + webClientRoot: getBundledWebClientRoot() + }) + state.runtimeRpc = runtimeRpc + registerMobileHandlers(runtimeRpc, { + getRelayStatus: () => state.desktopRelayStatus, + consumePendingUnpairedDeviceAuthFailure: (webContentsId) => { + if ( + !state.mainWindow || + state.mainWindow.isDestroyed() || + state.mainWindow.webContents.id !== webContentsId || + !state.pendingUnpairedDeviceAuthFailure + ) { + return false + } + state.pendingUnpairedDeviceAuthFailure = false + return true + } + }) + // Why: repeated direct auth failures otherwise look like a client that never connects; point users to re-pairing. + runtimeRpc.setOnUnpairedDeviceAuthFailure(() => { + // Why: runtime startup races renderer mount; retain the one-shot until the listener consumes it. + state.pendingUnpairedDeviceAuthFailure = true + if (state.mainWindow && !state.mainWindow.isDestroyed()) { + state.mainWindow.webContents.send('mobile:unpairedDeviceAuthFailure') + } + }) + return runtimeRpc +} + +async function launchServeMode( + runtime: RuntimeService, + runtimeRpc: OrcaRuntimeRpcServer, + serveOptions: NonNullable> +): Promise { + // Why: give managed WSL launchers a brief chance to migrate before headless PTYs go live, without slow repairs withholding all RPC readiness. + logStartupMilestone('wsl-cli-barrier-start') + await state.managedWslCliStartupBarrierReady + logStartupMilestone('wsl-cli-barrier-resolved', { + reconciliation: state.managedWslCliReconciliationStatus + }) + // Why: headless PTYs must not start on the fallback provider, then get swept when an activated renderer registers desktop lifecycle handlers. + await state.localPtyStartupReady + await state.localPtyProviderStartupReady + await registerHeadlessPtyRuntime( + runtime, + prepareCodexRuntimeHomeForLaunch, + () => state.store!.getSettings(), + (target) => state.claudeRuntimeAuth!.prepareForClaudeLaunch(target), + state.store!, + prepareCodexSessionResumeForLaunch, + { onCodexHomePtySpawned: handleCodexHomePtySpawned, onPtyExit: handlePtyExit } + ) + await runtime.refreshRestoredOrchestrationAuthority() + await runtime.reconcileLegacyWorkerTerminals() + // Why: headless servers can't mount panes; use offscreen WebContents, gated on a real display so browser.headless.v1 stays honest. + if (state.headlessBrowserDisplayAvailable) { + runtime.setOffscreenBrowserBackend( + new OffscreenBrowserBackend(browserManager, { + getAgentBrowserBridge: () => state.agentBrowserBridge + }) + ) + } + // Why: headless servers have no renderer graph publisher; publish an explicit empty graph so status clients see a ready server. + runtime.syncWindowGraph(HEADLESS_RUNTIME_WINDOW_ID, { tabs: [], leaves: [] }) + await runtimeRpc.start().catch((error) => { + console.error('[runtime] Failed to start headless RPC transport:', error) + throw error + }) + settleDesktopActivation() + // Why: every attempt must reach app.quit(); a page beforeunload can veto an earlier signal. + registerServeSignalHandlers(process, () => app.quit()) + // Why: headless serve has no renderer to run the normal cli:install flow; do it here for macOS/Linux only (Windows-excluded: install() only mutates registry PATH, not child terminals). + if (process.platform === 'darwin' || process.platform === 'linux') { + try { + // Why: serve is headless — a fallback osascript admin prompt would hang it; skip elevation since ~/.local/bin needs none. + const cliStatus = await new CliInstaller({ + privilegedRunner: async () => { + throw new Error('serve CLI auto-install must not request administrator privileges') + } + }).install() + console.log( + `[serve] orca CLI install: ${cliStatus.state}${cliStatus.commandPath ? ` (${cliStatus.commandPath})` : ''}` + ) + } catch (error) { + console.warn( + '[serve] orca CLI install skipped:', + error instanceof Error ? error.message : String(error) + ) + } + } + // Why: Linux CLI installs as `orca-ide`, but the Claude Team launcher invokes bare `orca`; drop a ~/.local/bin dispatcher (ahead of /usr/bin) so it resolves. Best-effort. + if (process.platform === 'linux' && app.isPackaged && process.resourcesPath) { + try { + const dispatcher = await installLinuxBareOrcaDispatcher({ + resourcesPath: process.resourcesPath + }) + console.log( + `[serve] bare orca dispatcher ${dispatcher.state}: ${dispatcher.dispatcherPath}` + + `${dispatcher.target ? ` -> ${dispatcher.target}` : ''}` + ) + } catch (error) { + console.warn( + '[serve] bare orca dispatcher install skipped:', + error instanceof Error ? error.message : String(error) + ) + } + } + // Why: headless serve never opens a renderer, so arm scheduled automation dispatch here. + state.automations?.start() + // Why: serve deletes worktrees too, and the history GC that normally drains delete tombstones is + // armed from the main window — without this, a quit mid-removal leaks the tree until a desktop launch. + scheduleAllPendingHistoryTreeRemovals() + await printServeReady(serveOptions) +} + +async function launchDesktopMode( + runtimeRpc: OrcaRuntimeRpcServer, + shellPathReady: Promise, + desktopWindow: BrowserWindow | null, + openMainWindow: MainProcessRuntimeLaunchOptions['openMainWindow'] +): Promise { + // Preserve the pre-split startup failure contract if composition ever hands + // this phase an incomplete runtime graph. + if (!runtimeRpc) { + throw new Error('runtime_rpc_unavailable') + } + // Why: window and RPC startup run in parallel; registerPtyHandlers gates PTY spawns so RPC binds without racing the daemon provider swap. + const [win, runtimeRpcStartResult] = await Promise.all([ + Promise.resolve(desktopWindow ?? openMainWindow()), + shellPathReady + .then(() => runtimeRpc.start()) + .then( + () => ({ ok: true as const }), + (error: unknown) => { + recordRuntimeRpcStartFailure(error) + return { ok: false as const, error } + } + ) + ]) + if (!runtimeRpcStartResult.ok) { + void showRuntimeRpcStartupFailureDialog(win, runtimeRpcStartResult.error) + } + const cloudAuth = getOrcaCloudAuthConfig() + if (cloudAuth.configured) { + try { + const relayService = new DesktopRelayService({ + authConfig: cloudAuth.config, + userDataPath: getProfileUserDataPath(), + appVersion: app.getVersion(), + runtimeRpc, + onStatus: (status) => { + state.desktopRelayStatus = status + state.mainWindow?.webContents.send('mobile:relayStatusChanged', status) + } + }) + state.desktopRelayService = relayService + runtimeRpc.setMobileRelayPairingProvider({ + createPairingRelay: (relayDeviceId) => relayService.createPairingRelay(relayDeviceId), + onDeviceRevokeQueued: (item) => relayService.onDeviceRevokeQueued(item), + onDemandStateChanged: () => relayService.demandStateChanged(), + getEndpoints: (context, params) => relayService.getEndpoints(context, params), + provisionRelay: (context, params) => relayService.provisionRelay(context, params) + }) + relayService.start() + // Why: sleeping past relay-token expiry kills the broker with no retry + // timer; resume is the moment that state becomes recoverable. + powerMonitor.on('resume', () => state.desktopRelayService?.ensureLive()) + } catch (error) { + console.warn( + '[relay] Desktop relay startup unavailable:', + error instanceof Error ? error.message : String(error) + ) + } + } + // Why: macOS notification permission dialog must fire after the window is shown, else it's hidden behind the maximized window. + win.once('show', () => { + // Why: store can be null if init failed earlier; bail rather than throw inside an Electron event listener. + const store = state.store + if (store && store.getOnboarding().closedAt !== null) { + triggerStartupNotificationRegistration(store) + } + }) +} + +export async function initializeMainProcessRuntimeLaunch( + options: MainProcessRuntimeLaunchOptions +): Promise { + const runtime = state.runtime + const shellPathHydration = state.windowsShellPathHydration + if (!runtime || !shellPathHydration) { + throw new Error('Runtime and shell-path services must be initialized before launch') + } + let serveOptions: ReturnType | null = null + try { + serveOptions = state.isServeMode ? getServeOptions() : null + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)) + app.exit(1) + return + } + state.serveOptions = serveOptions + const runtimeRpc = installRuntimeRpc(runtime, serveOptions) + const shellPathReady = shellPathHydration.whenReady() + let desktopWindow: BrowserWindow | null = null + if (process.platform === 'win32' && app.isPackaged && !serveOptions) { + const desktopStartup = startWindowsDesktopBeforeShellPathReady({ + bindServices: bindTerminalRuntimeStartupServices, + openWindow: () => options.openMainWindow({ revealOnDidFinishLoad: true }), + shellPathReady, + startServices: startTerminalRuntimeStartupServices + }) + desktopWindow = desktopStartup.window + } else { + await shellPathReady + bindTerminalRuntimeStartupServices(Promise.resolve(startTerminalRuntimeStartupServices())) + } + app.on('activate', options.handleMacAppActivation) + if (serveOptions) { + await launchServeMode(runtime, runtimeRpc, serveOptions) + return + } + await launchDesktopMode(runtimeRpc, shellPathReady, desktopWindow, options.openMainWindow) +} diff --git a/src/main/startup/main-process-runtime-service.ts b/src/main/startup/main-process-runtime-service.ts new file mode 100644 index 00000000000..77a073614da --- /dev/null +++ b/src/main/startup/main-process-runtime-service.ts @@ -0,0 +1,149 @@ +import { app } from 'electron' +import { OrcaRuntimeService } from '../runtime/orca-runtime' +import { getLocalPtyProvider, getSshPtyProvider, clearProviderPtyState } from '../ipc/pty' +import { agentHookServer } from '../agent-hooks/server' +import { browserManager } from '../browser/browser-manager' +import { loadAgentSessionClaimSigner } from '../runtime/agent-session-claim-identity' +import { getProfileUserDataPath } from '../orca-profiles/profile-storage-paths' +import { prepareCodexAiVaultSessionResume } from '../codex/codex-ai-vault-session-resume' +import { resolveHostCodexSessionSourceHome } from '../codex/codex-session-source-home' +import { isAgentStatusHooksEnabled } from '../agent-hooks/managed-agent-hook-controls' +import { getDaemonProvider } from '../daemon/daemon-init' +import type { TerminalSideEffectBatch } from '../../shared/terminal-side-effect-facts' +import type { OrchestrationEnvironmentTransport } from '../runtime/orchestration/environment-transport' +import { resolveEnvironment } from '../../shared/runtime-environment-store' +import { getPreferredPairingOffer } from '../../shared/runtime-environments' +import { fingerprintOrchestrationPeer } from '../runtime/orchestration/environment-transport' +import { callRuntimeEnvironment } from '../ipc/runtime-environment-transport-routing' +import { mainProcessState as state } from './main-process-state' +import { prepareCodexRuntimeHomeForLaunch } from './codex-launch-preparation' +import type { RuntimeDesktopWindowStatus } from '../../shared/runtime-types' +import { ArtifactCloudService } from '../artifacts/artifact-cloud-service' +import { SkillCloudService } from '../skills/skill-cloud-service' +import { isArtifactSharingEnabled } from '../../shared/artifact-sharing-gate' + +export function getDesktopWindowStatus(): RuntimeDesktopWindowStatus { + const activation = state.desktopActivationGate + if (!activation) { + return 'available' + } + const value = activation.getState() + return value === 'ready' ? 'openable' : value +} + +export function initializeMainProcessRuntime(): OrcaRuntimeService { + const store = state.store + const stats = state.stats + if (!store || !stats) { + throw new Error('Store and stats must be initialized before runtime') + } + const orchestrationEnvironmentTransport: OrchestrationEnvironmentTransport = { + resolve: (selector) => { + const environment = resolveEnvironment(app.getPath('userData'), selector) + const pairing = getPreferredPairingOffer(environment) + return { + environmentId: environment.id, + name: environment.name, + peerFingerprint: fingerprintOrchestrationPeer(pairing.publicKeyB64) + } + }, + call: (selector, method, params, timeoutMs, envelope) => + callRuntimeEnvironment( + app.getPath('userData'), + selector, + method, + params, + timeoutMs, + undefined, + envelope + ) + } + const runtime = new OrcaRuntimeService(store, stats, { + agentSessionClaimSigner: loadAgentSessionClaimSigner( + getProfileUserDataPath(), + getProfileUserDataPath() + ), + // Why: resolve the PTY provider lazily — a daemon swap happens later, so an eager reference would freeze the pre-daemon provider (design §4.3). + getLocalProvider: () => getLocalPtyProvider(), + // Why: SSH relay providers register after construction and may reconnect, so destructive cleanup must resolve the current generation. + getSshProvider: (connectionId) => getSshPtyProvider(connectionId), + onPtyStopped: clearProviderPtyState, + onTerminalAgentStatus: (event) => agentHookServer.ingestTerminalStatus(event), + // Why: serve can be promoted in place, so wire the listener from startup; runtime enables desktop-only scanners only for a ready renderer. + onTerminalSideEffects: (batch: TerminalSideEffectBatch) => { + if (state.mainWindow && !state.mainWindow.isDestroyed()) { + state.mainWindow.webContents.send('pty:sideEffect', batch) + } + }, + getDesktopWindowStatus, + // Why: worktree.ps pulls hook-reported agent status (same source as the desktop sidebar) at query time so mobile shows the same agents. + getAgentStatusSnapshot: () => + agentHookServer.getStatusSnapshot().filter((entry) => entry.providerSessionOnly !== true), + // Why: the filter above hides resume-identity rows from the live-agent views, but + // those rows carry the provider session mobile native chat addresses transcripts + // by — Pi publishes identity that way and would otherwise be unreachable. + getAgentProviderSessionSnapshot: () => agentHookServer.getStatusSnapshot(), + getAgentProviderSessionRowsForPane: (paneKey) => + agentHookServer.getStatusSnapshotForPane(paneKey), + attestAgentHookCompatibilityAuthority: (candidate) => + agentHookServer.attestCompatibilityAuthority(candidate), + retireAgentHookCompatibilityAuthority: (paneKey) => + agentHookServer.retirePaneAuthority(paneKey), + reconcileAgentStatusForEndedProcess: (paneKeys) => + agentHookServer.reconcileEndedProcessForPaneKeys(paneKeys), + canRecoverPersistentLocalPtys: () => getDaemonProvider() !== null, + // Why: evaluated per call, not captured — the RPC server that owns the device registry is + // constructed with this runtime and does not exist yet at this point. + getPairedDeviceName: (pairedDeviceId) => + state.runtimeRpc?.getDeviceRegistry()?.getDevice(pairedDeviceId)?.name ?? null, + // Why: source codex-home here (runs in window AND serve) so aiVault.listSessions includes managed-Codex sessions; registerCoreHandlers is window-only. + getAdditionalAiVaultCodexHomePaths: () => + state.codexRuntimeHome?.getHostCodexHomePathsForSessionDiscovery() ?? [], + prepareAiVaultSessionResume: (args) => + prepareCodexAiVaultSessionResume(args, { + runtimeHome: state.codexRuntimeHome, + systemCodexHomePath: resolveHostCodexSessionSourceHome(store.getSettings()) + }), + prepareCodexStructuredLaunch: ({ workspacePath, launchEnv }) => + prepareCodexRuntimeHomeForLaunch(undefined, launchEnv, { + launchAgent: 'codex', + workspacePath + }), + buildAgentHookPtyEnv: () => + isAgentStatusHooksEnabled(state.store?.getSettings()) ? agentHookServer.buildPtyEnv() : {}, + orchestrationEnvironmentTransport, + skillTransactionRecovery: state.skillTransactionRecovery + }) + state.runtime = runtime + runtime.prepareLegacyWorkerTerminalRecovery() + // Why before anything can attach: a client host that reattaches to a restarted runtime is only + // handed its pages back if the runtime found them first. + runtime.rehydrateClientHostedBrowserPages() + state.publishProviderSessionChanges?.(agentHookServer.getProviderSessionIdentities()) + browserManager.setBrowserGuestStateChangedListener((worktreeId) => { + runtime.notifyMobileSessionTabsChanged(worktreeId) + }) + return runtime +} + +export function configureRuntimeServices(runtime: OrcaRuntimeService): void { + const store = state.store + const claudeAccounts = state.claudeAccounts + const codexAccounts = state.codexAccounts + const rateLimits = state.rateLimits + if (!store || !claudeAccounts || !codexAccounts || !rateLimits) { + throw new Error('Account services must be initialized before runtime wiring') + } + runtime.setArtifactService( + new ArtifactCloudService(app.getPath('userData'), () => + isArtifactSharingEnabled(state.store?.getSettings()) + ) + ) + runtime.setSkillCloudService(new SkillCloudService(app.getPath('userData'))) + runtime.setAccountServices({ claudeAccounts, codexAccounts, rateLimits }) + runtime.setCommitMessageAgentEnvironmentResolvers({ + // Why: Codex hooks/auth live in Orca's managed runtime home even for the default path, so every launch must resolve CODEX_HOME via runtime-home. + prepareForCodexLaunch: prepareCodexRuntimeHomeForLaunch, + prepareForClaudeLaunch: (target) => state.claudeRuntimeAuth!.prepareForClaudeLaunch(target) + }) +} diff --git a/src/main/startup/main-process-serve.ts b/src/main/startup/main-process-serve.ts new file mode 100644 index 00000000000..367bdf6d033 --- /dev/null +++ b/src/main/startup/main-process-serve.ts @@ -0,0 +1,132 @@ +import { existsSync, statSync } from 'node:fs' +import { isAbsolute, join } from 'node:path' +import { app } from 'electron' +import { resolveAdvertisedPairingEndpoint } from '../runtime/pairing-endpoint' +import { notifyServeSupervisorReady } from '../serve-update-handoff' +import { mainProcessState as state } from './main-process-state' + +export type ServeOptions = { + json: boolean + wsPort?: number + pairingAddress: string | null + noPairing: boolean + mobilePairing: boolean + recipeJson: boolean + projectRoot: string | null +} + +export function getServeOptions(argv = process.argv): ServeOptions { + const valueAfter = (flag: string): string | null => { + const index = argv.indexOf(flag) + if (index === -1) { + return null + } + const value = argv[index + 1] + return value && !value.startsWith('--') ? value : null + } + const rawPort = valueAfter('--serve-port') + let wsPort: number | undefined + if (rawPort) { + const parsedPort = Number(rawPort) + if (!Number.isInteger(parsedPort) || parsedPort < 0 || parsedPort > 65535) { + throw new Error(`Invalid --serve-port value: ${rawPort}`) + } + wsPort = parsedPort + } + return { + json: argv.includes('--serve-json'), + ...(wsPort !== undefined ? { wsPort } : {}), + pairingAddress: valueAfter('--serve-pairing-address'), + noPairing: argv.includes('--serve-no-pairing'), + mobilePairing: argv.includes('--serve-mobile-pairing'), + recipeJson: argv.includes('--serve-recipe-json'), + projectRoot: valueAfter('--serve-project-root') + } +} + +export function getBundledWebClientRoot(): string | undefined { + const appPath = app.getAppPath() + const roots = [ + join(appPath, 'out', 'web'), + // Why: unpacked electron-vite entrypoints set appPath to out/main, next to the web bundle. + join(appPath, '..', 'web') + ] + return roots.find((root) => existsSync(join(root, 'web-index.html'))) +} + +async function renderTerminalPairingQr(pairingUrl: string): Promise { + // Why dynamic: qrcode is only reachable from mobile pairing, so launch should + // not parse it for the majority who never pair a device. + const QRCode = await import('qrcode') + try { + return await QRCode.toString(pairingUrl, { type: 'terminal', small: true }) + } catch { + try { + return await QRCode.toString(pairingUrl, { type: 'utf8' }) + } catch { + return null + } + } +} + +export async function printServeReady(options: ServeOptions): Promise { + const runtime = state.runtime + const runtimeRpc = state.runtimeRpc + if (!runtime || !runtimeRpc) { + throw new Error('Runtime server must be initialized before printing serve readiness') + } + if (options.recipeJson) { + if (!options.projectRoot) { + throw new Error('--serve-recipe-json requires --serve-project-root') + } + if (!isAbsolute(options.projectRoot)) { + throw new Error(`--serve-project-root must be absolute: ${options.projectRoot}`) + } + if (!statSync(options.projectRoot).isDirectory()) { + throw new Error(`--serve-project-root must be a directory: ${options.projectRoot}`) + } + } + const boundEndpoint = runtimeRpc.getWebSocketEndpoint() + const advertised = boundEndpoint + ? resolveAdvertisedPairingEndpoint(boundEndpoint, options.pairingAddress) + : null + const pairing = options.noPairing + ? ({ + available: false, + reason: 'disabled_by_operator', + guidance: 'Restart without --no-pairing to create a client pairing offer.' + } as const) + : runtimeRpc.createPairingOffer({ + address: options.pairingAddress, + name: `${options.mobilePairing ? 'Mobile' : 'CLI'} ${new Date().toLocaleDateString()}`, + scope: options.mobilePairing ? 'mobile' : 'runtime' + }) + const pairingQr = + pairing.available && options.mobilePairing + ? await renderTerminalPairingQr(pairing.pairingUrl) + : null + await state.serveReadinessPublisher.publish( + { + runtimeId: runtime.getRuntimeId(), + boundEndpoint, + advertisedEndpoint: advertised?.ok ? advertised.endpoint : null, + // Why: the WSL reconciliation barrier fails open, so 'pending' warns a WSL PTY launch may still race a repair. + managedWslCliReconciliation: state.managedWslCliReconciliationStatus, + pairing: pairing.available + ? { + available: true, + url: pairing.pairingUrl, + endpoint: pairing.endpoint, + deviceId: pairing.deviceId, + webClientUrl: pairing.webClientUrl, + scope: options.mobilePairing ? 'mobile' : 'runtime', + qr: pairingQr + } + : pairing + }, + options.recipeJson + ? { mode: 'recipe-json', projectRoot: options.projectRoot! } + : { mode: options.json ? 'json' : 'human' } + ) + notifyServeSupervisorReady(runtime.getRuntimeId()) +} diff --git a/src/main/startup/main-process-state.ts b/src/main/startup/main-process-state.ts new file mode 100644 index 00000000000..d48219b461e --- /dev/null +++ b/src/main/startup/main-process-state.ts @@ -0,0 +1,141 @@ +import type { BrowserWindow, Tray } from 'electron' +import { app } from 'electron' +import type { Store } from '../persistence' +import type { StatsCollector } from '../stats/collector' +import type { ClaudeUsageStore } from '../claude-usage/store' +import type { CodexUsageStore } from '../codex-usage/store' +import type { OpenCodeUsageStore } from '../opencode-usage/store' +import type { CodexAccountService } from '../codex-accounts/service' +import type { CodexRuntimeHomeService } from '../codex-accounts/runtime-home-service' +import type { ClaudeAccountService } from '../claude-accounts/service' +import type { ClaudeRuntimeAuthService } from '../claude-accounts/runtime-auth-service' +import type { OrcaRuntimeService } from '../runtime/orca-runtime' +import type { RateLimitService } from '../rate-limits/service' +import type { OrcaRuntimeRpcServer } from '../runtime/runtime-rpc' +import type { DesktopRelayService } from '../runtime/relay/desktop-relay-service' +import type { StarNagService } from '../star-nag/service' +import type { AgentAwakeService } from '../agent-awake-service' +import type { CrashReportStore } from '../crash-reporting/crash-report-store' +import type { AutomationService } from '../automations/service' +import type { PluginService } from '../plugins/plugin-service' +import type { PluginKillListService } from '../plugins/plugin-kill-list-service' +import type { PluginMarketplaceService } from '../plugins/plugin-marketplace-service' +import type { PluginMarketplaceInstaller } from '../plugins/plugin-marketplace-installer' +import type { KeybindingService } from '../keybindings/keybinding-service' +import type { RelayBrokerStatus } from '../runtime/relay/relay-session-broker' +import type { AgentBrowserBridge } from '../browser/agent-browser-bridge' +import type { AgentHookProviderSessionIdentity } from '../agent-hooks/server' +import type { EmulatorBridge } from '../emulator/emulator-bridge' +import type { GpuFallbackMarker, GpuFallbackEnvironment } from './gpu-fallback-marker' +import type { createCodexSessionMigrationScheduler } from '../codex/codex-session-migration-scheduler' +import type { getDevInstanceIdentity } from './dev-instance-identity' +import type { createServeDesktopActivationGate } from './serve-desktop-activation' +import type { ensureActiveOrcaProfile } from '../orca-profiles/profile-index-store' +import type { createWindowsShellPathHydration } from './windows-shell-path-hydration' +import type { ServeOptions } from './main-process-serve' +import type { HangDetectionMarker } from '../hang-watchdog/hang-detection-marker' +import { ServeReadinessPublisher } from '../server/serve-readiness' +import { SkillShareDeepLinkState } from './skill-share-deep-link-state' +import { OsOpenedMarkdownFileState } from './os-opened-markdown-files' +import { + DEFAULT_GPU_CRASH_FALLBACK_THRESHOLD, + DEFAULT_GPU_CRASH_FALLBACK_WINDOW_MS, + GpuCrashFallbackTracker +} from '../crash-reporting/gpu-crash-fallback-decision' +import type { GpuCrashDiagnosticsRecorder } from '../crash-reporting/gpu-crash-diagnostics' +import { createWebContentsTimedFlag } from './web-contents-timed-flag' + +/** Mutable composition-root state shared by startup, window, serve, and quit phases. */ +export const mainProcessState = { + mainWindow: null as BrowserWindow | null, + /** Whether a manual app.quit() (Cmd+Q) is in progress; lets the close handler skip the running-process confirmation and go straight to close. */ + isQuitting: false, + store: null as Store | null, + stats: null as StatsCollector | null, + claudeUsage: null as ClaudeUsageStore | null, + codexUsage: null as CodexUsageStore | null, + openCodeUsage: null as OpenCodeUsageStore | null, + codexAccounts: null as CodexAccountService | null, + codexRuntimeHome: null as CodexRuntimeHomeService | null, + codexSessionMigration: null as ReturnType | null, + claudeAccounts: null as ClaudeAccountService | null, + claudeRuntimeAuth: null as ClaudeRuntimeAuthService | null, + runtime: null as OrcaRuntimeService | null, + rateLimits: null as RateLimitService | null, + runtimeRpc: null as OrcaRuntimeRpcServer | null, + serveReadinessPublisher: new ServeReadinessPublisher(), + desktopRelayService: null as DesktopRelayService | null, + desktopRelayStatus: 'offline' as RelayBrokerStatus, + pendingUnpairedDeviceAuthFailure: false, + // Why: gates whether headless serve installs the offscreen browser backend (and advertises browser pane support). + headlessBrowserDisplayAvailable: false, + starNag: null as StarNagService | null, + agentAwakeService: null as AgentAwakeService | null, + uninstallRepoMaintenanceIdleGate: null as (() => Promise) | null, + repoMaintenanceShutdown: Promise.resolve() as Promise, + crashReports: null as CrashReportStore | null, + unsubscribeAgentAwakeStatusChanges: null as (() => void) | null, + publishProviderSessionChanges: null as + | ((identities: AgentHookProviderSessionIdentity[]) => void) + | null, + unsubscribeSystemResumeBroadcast: null as (() => void) | null, + watcherShutdownPromise: null as Promise | null, + watcherShutdownDone: false, + automations: null as AutomationService | null, + pluginService: null as PluginService | null, + pluginKillListService: null as PluginKillListService | null, + pluginMarketplaceService: null as PluginMarketplaceService | null, + pluginMarketplaceInstaller: null as PluginMarketplaceInstaller | null, + keybindings: null as KeybindingService | null, + // Why: a reload intent must not leak to a later load; the recovery reload re-fires did-finish-load, so its flag spares live PTYs from the orphan sweep (#5787). + expectedRendererReload: createWebContentsTimedFlag(), + recoveryReloadInFlight: createWebContentsTimedFlag(), + // Why: a tray "Settings…" click can precede the renderer's ui:openSettings listener; it pulls this one-shot on mount. + pendingOpenSettings: createWebContentsTimedFlag(), + skillShareDeepLinks: new SkillShareDeepLinkState(), + // Why: a Finder/Explorer "Open With" can land before any window exists; the renderer pulls this buffer on mount. + osOpenedMarkdownFiles: new OsOpenedMarkdownFileState(), + // Why a latch and not just "a window exists": a window can be up while its renderer has not + // attached the ui:openMarkdownFiles listener yet, and a push into that gap is dropped by + // Electron with no error. Only the renderer's own pull proves the listener is live. + markdownFileOpenListenerReady: false, + firstWindowStartupServicesReady: Promise.resolve(), + managedWslCliReconciliationReady: Promise.resolve(), + managedWslCliStartupBarrierReady: Promise.resolve(), + // Why: the serve barrier fails open, so this state tells headless clients a WSL PTY launch may still race an un-migrated registration ('settled' = off-Windows no-op). + managedWslCliReconciliationStatus: 'settled' as 'pending' | 'settled' | 'failed', + gpuCrashFallbackTracker: new GpuCrashFallbackTracker({ + windowMs: DEFAULT_GPU_CRASH_FALLBACK_WINDOW_MS, + threshold: DEFAULT_GPU_CRASH_FALLBACK_THRESHOLD + }), + activeGpuFallbackMarker: null as GpuFallbackMarker | null, + gpuFallbackActiveThisLaunch: false, + gpuFeatureStatus: null as Electron.GPUFeatureStatus | null, + gpuCrashDiagnostics: null as GpuCrashDiagnosticsRecorder | null, + localPtyStartupReady: Promise.resolve(), + localPtyProviderStartupReady: Promise.resolve(), + isServeMode: false, + devInstanceIdentity: null as ReturnType | null, + devAgentHookEndpointNamespace: undefined as string | undefined, + startupDiagnosticsEnabled: false, + desktopActivationGate: null as ReturnType | null, + activeOrcaProfile: null as ReturnType | null, + windowsShellPathHydration: null as ReturnType | null, + shellPathReady: Promise.resolve(), + hangDetection: null as HangDetectionMarker | null, + skillTransactionRecovery: Promise.resolve() as Promise, + serveOptions: null as ServeOptions | null, + desktopWindow: null as BrowserWindow | null, + agentBrowserBridge: null as AgentBrowserBridge | null, + emulatorBridge: null as EmulatorBridge | null, + tray: null as Tray | null +} + +/** Environment passed to GPU fallback marker helpers. */ +export function gpuFallbackEnvironment(): GpuFallbackEnvironment { + return { + appVersion: app.getVersion(), + electronVersion: process.versions.electron ?? '', + platform: process.platform + } +} diff --git a/src/main/startup/main-window-actions.ts b/src/main/startup/main-window-actions.ts new file mode 100644 index 00000000000..96751d645db --- /dev/null +++ b/src/main/startup/main-window-actions.ts @@ -0,0 +1,168 @@ +import { app, clipboard, dialog, type BrowserWindow, type Tray } from 'electron' +import type { UpdateCheckOptions } from '../../shared/update-status-types' +import { recordCrashBreadcrumb } from '../crash-reporting/crash-breadcrumb-store' +import { recordDurableCrashBreadcrumb } from '../crash-reporting/durable-crash-breadcrumb' +import { checkForUpdatesFromMenu, isQuittingForUpdate } from '../updater' +import { + createSystemTray, + setMacMenuBarIconVisible, + type SystemTrayOptions +} from '../tray/system-tray' +import { ensureAutoUpdaterConfigured } from '../window/attach-main-window-services' +import { focusExistingMainWindow, safelyRevealWindow } from '../window/focus-existing-window' +import { mainProcessState as state } from './main-process-state' +import { loadMainWindow } from '../window/createMainWindow' +import { describeInstallDirAclPoison } from './windows-install-dir-acl-recovery' +import { presentRendererRecoveryPrompt } from '../window/renderer-recovery-prompt' + +// The window module injects this callback to avoid a cycle between actions and lifecycle code. +let openWindow: (options?: { revealOnDidFinishLoad?: boolean }) => BrowserWindow +export function setMainWindowOpener( + opener: (options?: { revealOnDidFinishLoad?: boolean }) => BrowserWindow +): void { + openWindow = opener +} + +export function focusExistingWindow(): void { + focusExistingMainWindow({ + app, + getWindow: () => state.mainWindow, + openWindow, + warn: console.warn + }) +} + +export function showMainWindowFromTray(): void { + if (state.mainWindow && !state.mainWindow.isDestroyed()) { + safelyRevealWindow(state.mainWindow) + return + } + if (!isQuittingForUpdate()) { + openWindow() + } +} + +export function openSettingsFromSystemMenu(): void { + showMainWindowFromTray() + const targetWindow = state.mainWindow && !state.mainWindow.isDestroyed() ? state.mainWindow : null + if (!targetWindow) { + return + } + recordCrashBreadcrumb('settings_opened') + targetWindow.webContents.send('ui:openSettings') + state.pendingOpenSettings.mark(targetWindow.webContents.id, Number.POSITIVE_INFINITY) +} + +export function quitFromSystemTray(): void { + if (state.mainWindow && !state.mainWindow.isDestroyed()) { + showMainWindowFromTray() + } + state.isQuitting = true + app.quit() +} + +export function runUserInitiatedUpdateCheck(options?: UpdateCheckOptions): void { + ensureAutoUpdaterConfigured() + checkForUpdatesFromMenu(options) +} + +export function getSystemTrayOptions(): SystemTrayOptions | null { + const store = state.store + if (!store) { + return null + } + return { + appIcon: store.getSettings().appIcon, + isDevInstance: state.devInstanceIdentity?.isDev ?? false, + devInstanceLabel: state.devInstanceIdentity?.devLabel ?? null, + onOpen: showMainWindowFromTray, + onOpenSettings: openSettingsFromSystemMenu, + onCheckForUpdates: () => { + showMainWindowFromTray() + runUserInitiatedUpdateCheck() + }, + onQuit: quitFromSystemTray + } +} + +export function syncMacMenuBarIcon(showMenuBarIcon: boolean): Tray | null { + if (process.platform !== 'darwin' || state.isServeMode) { + return null + } + const options = getSystemTrayOptions() + return options ? setMacMenuBarIconVisible(showMenuBarIcon, options) : null +} + +export function createSystemTrayDeferred( + window: BrowserWindow, + onCreated?: () => void +): () => void { + let trayCreated = false + return () => { + if (trayCreated || window.isDestroyed() || state.isQuitting || !state.store) { + return + } + trayCreated = true + if (process.platform === 'darwin') { + if (syncMacMenuBarIcon(state.store.getSettings().showMenuBarIcon !== false)) { + onCreated?.() + } + return + } + const options = getSystemTrayOptions() + if (options && createSystemTray(options)) { + onCreated?.() + } + } +} + +export function sendOpenFeatureTour(targetWindow?: BrowserWindow | null): void { + const webContents = + targetWindow && !targetWindow.isDestroyed() + ? targetWindow.webContents + : state.mainWindow?.webContents + webContents?.send('ui:openFeatureTour') +} + +export function sendOpenSetupGuide(targetWindow?: BrowserWindow | null): void { + const webContents = + targetWindow && !targetWindow.isDestroyed() + ? targetWindow.webContents + : state.mainWindow?.webContents + webContents?.send('ui:openSetupGuide') +} + +export function sendOpenCrashReport(targetWindow?: BrowserWindow | null): void { + const webContents = + targetWindow && !targetWindow.isDestroyed() + ? targetWindow.webContents + : state.mainWindow?.webContents + webContents?.send('ui:openCrashReport') +} + +// Why: on renderer crash-loop the breaker stops auto-reloading and the window goes blank, so a main-process dialog is the only retry/quit surface. +export async function showRendererRecoveryPrompt(recentRecoveryCount: number): Promise { + await presentRendererRecoveryPrompt({ + recentRecoveryCount, + isQuitting: () => state.isQuitting, + diagnose: describeInstallDirAclPoison, + showMessageBox: (options) => { + const window = + state.mainWindow && !state.mainWindow.isDestroyed() ? state.mainWindow : undefined + return window ? dialog.showMessageBox(window, options) : dialog.showMessageBox(options) + }, + copyToClipboard: (text) => clipboard.writeText(text), + reload: () => { + if (!state.mainWindow || state.mainWindow.isDestroyed()) { + return + } + recordDurableCrashBreadcrumb('renderer_recovery_manual_retry') + // Why: leave the breaker open so a re-crash re-raises this prompt instead of resuming the auto-reload loop. + loadMainWindow(state.mainWindow) + }, + quit: () => { + state.isQuitting = true + app.quit() + } + }) +} diff --git a/src/main/startup/main-window-agent-status.ts b/src/main/startup/main-window-agent-status.ts new file mode 100644 index 00000000000..2e583830a48 --- /dev/null +++ b/src/main/startup/main-window-agent-status.ts @@ -0,0 +1,142 @@ +import type { BrowserWindow } from 'electron' +import { agentHookServer } from '../agent-hooks/server' +import { setMigrationUnsupportedPtyListener } from '../agent-hooks/migration-unsupported-pty-state' +import { getDashboardPopoutWindow } from '../window/dashboard-popout-window' +import { isAskUserQuestionTool } from '../../shared/agent-question-answered-intent' +import { + getSyntheticAgentTitleProfile, + shouldDriveSyntheticAgentTitleFromHook +} from '../../shared/synthetic-agent-title' +import { + driveSyntheticTitleFromHook, + shouldSuppressCodexAutoApprovalSyntheticTitleFromHook, + stopAllSyntheticTitleSpinners +} from './synthetic-title-runtime' +import { mainProcessState as state } from './main-process-state' + +export type MainWindowAgentStatusOptions = { + window: BrowserWindow + maybeAutoRenameBranchOnFirstWork: (event: { + paneKey: string + tabId: string | undefined + worktreeId: string | undefined + payload: { state: string; prompt?: string; lastAssistantMessage?: string } + isReplay: boolean | undefined + }) => void + onRecordAgentState: (agentType: string, status: string) => void +} + +export function installMainWindowAgentStatusListeners(options: MainWindowAgentStatusOptions): void { + agentHookServer.setListener( + ({ + paneKey, + tabId, + worktreeId, + connectionId, + payload, + receivedAt, + stateStartedAt, + launchToken, + providerSession, + providerSessionOnly, + promptInteractionKey, + restoredUnconfirmed, + observation, + isReplay + }) => { + if (state.mainWindow?.isDestroyed()) { + return + } + if (providerSessionOnly) { + // Why: session_start just refreshes durable resume identity while Pi is idle; forward it without titles, telemetry, or status UI. + state.mainWindow?.webContents.send('agentStatus:set', { + ...payload, + paneKey, + ...(launchToken ? { launchToken } : {}), + tabId, + worktreeId, + connectionId, + receivedAt, + stateStartedAt, + ...(providerSession ? { providerSession } : {}), + ...(observation ? { observation } : {}), + providerSessionOnly: true + }) + return + } + if (!restoredUnconfirmed) { + options.maybeAutoRenameBranchOnFirstWork({ paneKey, tabId, worktreeId, payload, isReplay }) + } + const runtime = state.runtime + const orchestration = runtime?.getAgentStatusOrchestrationContextForPaneKey(paneKey) + const terminalHandle = runtime?.getAgentStatusTerminalHandleForPaneKey(paneKey) + const suppressSyntheticCodexAutoApprovalTitle = + payload.agentType === 'codex' && + (payload.state === 'waiting' || payload.state === 'blocked') + ? shouldSuppressCodexAutoApprovalSyntheticTitleFromHook({ + agentType: payload.agentType, + state: payload.state, + launchConfig: runtime?.getAgentStatusLaunchConfigForPaneKey(paneKey, { launchToken }) + }) + : false + const statusEvent = { + ...payload, + paneKey, + ...(launchToken ? { launchToken } : {}), + ...(terminalHandle ? { terminalHandle } : {}), + tabId, + worktreeId, + connectionId, + receivedAt, + stateStartedAt, + ...(providerSession ? { providerSession } : {}), + ...(promptInteractionKey ? { promptInteractionKey } : {}), + ...(restoredUnconfirmed ? { restoredUnconfirmed: true } : {}), + ...(observation ? { observation } : {}), + ...(orchestration ? { orchestration } : {}) + } + state.mainWindow?.webContents.send('agentStatus:set', statusEvent) + if (!suppressSyntheticCodexAutoApprovalTitle || isAskUserQuestionTool(payload.toolName)) { + getDashboardPopoutWindow()?.webContents.send('agentStatus:set', statusEvent) + } + options.onRecordAgentState(payload.agentType ?? 'unknown', payload.state) + // Why: native OSC titles miss some idle/permission frames, so inject hook-derived ones to keep the renderer title tracker in sync. + const profile = getSyntheticAgentTitleProfile(payload.agentType) + if ( + profile && + shouldDriveSyntheticAgentTitleFromHook(payload.agentType, payload.state) && + !suppressSyntheticCodexAutoApprovalTitle + ) { + driveSyntheticTitleFromHook(paneKey, payload.state, profile) + } + } + ) + agentHookServer.setPaneStatusClearListener((clear) => { + if (state.mainWindow?.isDestroyed()) { + return + } + state.mainWindow?.webContents.send('agentStatus:clear', clear) + getDashboardPopoutWindow()?.webContents.send('agentStatus:clear', clear) + }) + setMigrationUnsupportedPtyListener((event) => { + if (state.mainWindow?.isDestroyed()) { + return + } + if (event.type === 'set') { + state.mainWindow?.webContents.send('agentStatus:migrationUnsupported', event.entry) + } else { + state.mainWindow?.webContents.send('agentStatus:migrationUnsupportedClear', { + ptyId: event.ptyId + }) + } + }) +} + +export function clearMainWindowAgentStatusListeners(): void { + // Why: detach the hook listener on close so the server never fires into destroyed webContents before reopen, and replay runs only on deliberate recreations. + agentHookServer.setListener(null) + agentHookServer.setPaneStatusClearListener(null) + setMigrationUnsupportedPtyListener(null) + // Why: stop the spinner timer here — it would fire into destroyed webContents, and per-pane teardown may never run for restored-but-untorn panes. + stopAllSyntheticTitleSpinners() +} diff --git a/src/main/startup/main-window-controller.ts b/src/main/startup/main-window-controller.ts new file mode 100644 index 00000000000..0d935d5f84a --- /dev/null +++ b/src/main/startup/main-window-controller.ts @@ -0,0 +1,200 @@ +import { app, type BrowserWindow } from 'electron' +import { createMainWindow, loadMainWindow } from '../window/createMainWindow' +import { + recordCrashBreadcrumb, + recordCoalescedCrashBreadcrumb +} from '../crash-reporting/crash-breadcrumb-store' +import { recordDurableCrashBreadcrumb } from '../crash-reporting/durable-crash-breadcrumb' +import { shouldRecoverRendererAfterProcessGone } from '../crash-reporting/process-gone-classification' +import { resolveConsent } from '../telemetry/consent' +import { trackAppOpenedOnce } from '../telemetry/client' +import { ensureWindowsUserDataAclGrant } from './windows-user-data-acl' +import { probeWindowsInstallDirAcl } from './windows-install-dir-acl-probe' +import { startWindowsInstallDirAclRepairIfPoisoned } from './windows-install-dir-acl-recovery' +import { logStartupMilestone } from './startup-diagnostics' +import { notifyMainWindowBecameVisible } from '../window/main-window-visibility' +import { setTrayAttention } from '../tray/system-tray' +import { + createSystemTrayDeferred, + getSystemTrayOptions, + showMainWindowFromTray, + showRendererRecoveryPrompt, + syncMacMenuBarIcon +} from './main-window-actions' +import { attachMainWindowCoreServices } from './main-window-core-services' +import { + clearMainWindowAgentStatusListeners, + installMainWindowAgentStatusListeners +} from './main-window-agent-status' +import { mainProcessState as state } from './main-process-state' +import { + clearExpectedRendererReload, + markExpectedRendererReload, + markRecoveryReloadInFlight, + getExpectedTeardownScope, + recordProcessGoneCrash +} from './main-window-lifecycle-flags' +import { presentGpuFallbackRecoveredLaunchPrompt } from './gpu-lifecycle' +import { maybeAutoRenameBranchOnFirstWorkFromHook } from './branch-rename-hook' +import { + resumeSyntheticTitleSpinnerTimer, + stopSyntheticTitleSpinnerTimer +} from './synthetic-title-runtime' +import { requireMainWindowServices } from './main-window-service-readiness' + +const TRAY_CREATE_FALLBACK_MS = 12_000 +const AGENT_STATE_CRASH_BREADCRUMB_MIN_INTERVAL_MS = 30_000 + +export function openMainWindow(options: { revealOnDidFinishLoad?: boolean } = {}): BrowserWindow { + logStartupMilestone('open-main-window-start') + const { store, keybindings } = requireMainWindowServices({ + store: state.store, + runtime: state.runtime, + stats: state.stats, + claudeUsage: state.claudeUsage, + codexUsage: state.codexUsage, + openCodeUsage: state.openCodeUsage, + rateLimits: state.rateLimits, + automations: state.automations, + codexAccounts: state.codexAccounts, + codexRuntimeHome: state.codexRuntimeHome, + claudeAccounts: state.claudeAccounts, + claudeRuntimeAuth: state.claudeRuntimeAuth, + keybindings: state.keybindings + }) + if (process.platform === 'win32') { + logStartupMilestone('acl-grant-start') + ensureWindowsUserDataAclGrant(app.getPath('userData'), { + onDone: (result) => { + logStartupMilestone('acl-grant-done', { mode: result.mode }) + if (result.mode === 'failed') { + console.warn('[win32-acl] userData ACL grant failed:', result.reason) + } + } + }) + // Why here: read-only, and the install DACL is the one thing a 0x80000003 + // child death cannot tell us about itself. See electron/electron#51761. + probeWindowsInstallDirAcl({ + isServeMode: state.isServeMode, + onDone: (data) => + startWindowsInstallDirAclRepairIfPoisoned(data, { + isServeMode: state.isServeMode, + userDataPath: app.getPath('userData'), + appVersion: app.getVersion() + }) + }) + } + const window = createMainWindow(store, { + getIsQuitting: () => state.isQuitting, + onQuitAborted: () => { + state.isQuitting = false + clearExpectedRendererReload() + }, + onRendererProcessGone: (details, webContentsId) => + recordProcessGoneCrash( + 'renderer', + 'renderer', + details.reason, + details.exitCode ?? null, + { processType: 'renderer' }, + webContentsId + ), + shouldRecoverRenderer: (details, webContentsId) => + shouldRecoverRendererAfterProcessGone({ + reason: details.reason, + expectedTeardown: getExpectedTeardownScope(webContentsId, false) + }), + onRendererRecoveryExhausted: ({ details, recentRecoveryCount }) => { + recordDurableCrashBreadcrumb('renderer_recovery_circuit_breaker_open', { + reason: details.reason, + exitCode: details.exitCode ?? null, + recentRecoveryCount + }) + void showRendererRecoveryPrompt(recentRecoveryCount) + }, + deferLoad: true, + ...(options.revealOnDidFinishLoad === true ? { revealOnDidFinishLoad: true } : {}), + title: state.devInstanceIdentity?.name ?? app.name, + getKeybindings: () => keybindings.getOverrides(), + onBeforeReload: ({ ignoreCache, webContentsId }) => { + if (state.mainWindow?.webContents.id === webContentsId) { + markExpectedRendererReload(webContentsId) + } + recordCrashBreadcrumb('manual_reload_requested', { ignoreCache }) + }, + onBeforeRecoveryReload: (webContentsId) => { + markRecoveryReloadInFlight(webContentsId) + recordDurableCrashBreadcrumb('renderer_recovery_reload') + } + }) + recordCrashBreadcrumb('main_window_created') + logStartupMilestone('window-created') + const createTray = createSystemTrayDeferred(window, () => logStartupMilestone('tray-created')) + window.once('ready-to-show', () => { + logStartupMilestone('ready-to-show') + setImmediate(createTray) + }) + window.once('show', () => { + logStartupMilestone('window-shown') + void presentGpuFallbackRecoveredLaunchPrompt(window) + }) + const trayCreateFallback = setTimeout(createTray, TRAY_CREATE_FALLBACK_MS) + trayCreateFallback.unref?.() + const rendererWebContentsId = window.webContents.id + const onFirstWindowLoad = (): void => { + clearExpectedRendererReload(rendererWebContentsId) + recordCrashBreadcrumb('main_window_loaded') + logStartupMilestone('did-finish-load') + // Why cleared here: a reload drops the old ui:openMarkdownFiles listener, and the fresh + // renderer re-attaches by pulling. Pushing into the gap between would be silently lost. + state.markdownFileOpenListenerReady = false + const currentStore = state.store + if (currentStore && resolveConsent(currentStore.getSettings()).effective === 'enabled') { + trackAppOpenedOnce() + } + } + window.webContents.on('did-finish-load', onFirstWindowLoad) + attachMainWindowCoreServices(window, { + markExpectedRendererReload, + recordRendererReload: (ignoreCache) => + recordCrashBreadcrumb('renderer_reload_requested', { ignoreCache }) + }) + state.mainWindow = window + window.on('show', resumeSyntheticTitleSpinnerTimer) + window.on('restore', resumeSyntheticTitleSpinnerTimer) + window.on('hide', stopSyntheticTitleSpinnerTimer) + window.on('minimize', stopSyntheticTitleSpinnerTimer) + window.on('show', notifyMainWindowBecameVisible) + window.on('restore', notifyMainWindowBecameVisible) + window.on('show', () => setTrayAttention(false)) + window.on('restore', () => setTrayAttention(false)) + installMainWindowAgentStatusListeners({ + window, + maybeAutoRenameBranchOnFirstWork: maybeAutoRenameBranchOnFirstWorkFromHook, + onRecordAgentState: (agentType, status) => + recordCoalescedCrashBreadcrumb({ + name: 'agent_state_changed', + data: { agentType, state: status }, + coalesceKey: `agent:${agentType}:${status}`, + minIntervalMs: AGENT_STATE_CRASH_BREADCRUMB_MIN_INTERVAL_MS + }) + }) + window.on('closed', () => { + if (state.mainWindow === window) { + state.mainWindow = null + } + clearExpectedRendererReload(rendererWebContentsId) + state.automations?.setWebContents(null) + clearMainWindowAgentStatusListeners() + }) + logStartupMilestone('load-start') + loadMainWindow(window) + return window +} + +export function configureWindowActions(): void { + // Kept as a named seam for startup composition; action callbacks are state-backed. + void getSystemTrayOptions + void showMainWindowFromTray + void syncMacMenuBarIcon +} diff --git a/src/main/startup/main-window-core-services.ts b/src/main/startup/main-window-core-services.ts new file mode 100644 index 00000000000..759a4b2b100 --- /dev/null +++ b/src/main/startup/main-window-core-services.ts @@ -0,0 +1,133 @@ +import type { BrowserWindow } from 'electron' +import { registerCoreHandlers } from '../ipc/register-core-handlers/register-core-handlers' +import { attachMainWindowServices } from '../window/attach-main-window-services' +import { initTccPromptNotice } from '../macos-tcc-prompt-notice' +import { resolveUpdateInstallMode } from '../updater' +import { mainProcessState as state } from './main-process-state' +import { prepareCodexAiVaultSessionResume } from '../codex/codex-ai-vault-session-resume' +import { resolveHostCodexSessionSourceHome } from '../codex/codex-session-source-home' +import { preserveAgentAuthBeforeRestart } from '../agent-auth-restart-preservation' +import { + emitPluginWorktreeLifecycle, + handleCodexHomePtySpawned, + handlePtyExit +} from './main-process-pty-startup' +import { prepareCodexRuntimeHomeForLaunch } from './codex-launch-preparation' +import { prepareCodexSessionResumeForLaunch } from './codex-session-resume-launch' +import { isRecoveryReloadInFlight } from './main-window-lifecycle-flags' + +export function attachMainWindowCoreServices( + window: BrowserWindow, + deps: { + markExpectedRendererReload: (webContentsId: number) => void + recordRendererReload: (ignoreCache: boolean) => void + } +): void { + const store = state.store + const runtime = state.runtime + const stats = state.stats + const claudeUsage = state.claudeUsage + const codexUsage = state.codexUsage + const openCodeUsage = state.openCodeUsage + const codexAccounts = state.codexAccounts + const claudeAccounts = state.claudeAccounts + const rateLimits = state.rateLimits + const automations = state.automations + const keybindings = state.keybindings + const codexRuntimeHome = state.codexRuntimeHome + const claudeRuntimeAuth = state.claudeRuntimeAuth + if ( + !store || + !runtime || + !stats || + !claudeUsage || + !codexUsage || + !openCodeUsage || + !codexAccounts || + !claudeAccounts || + !rateLimits || + !automations || + !keybindings || + !codexRuntimeHome || + !claudeRuntimeAuth + ) { + throw new Error('Main window services must be initialized before attaching') + } + registerCoreHandlers( + store, + runtime, + stats, + claudeUsage, + codexUsage, + openCodeUsage, + codexAccounts, + claudeAccounts, + rateLimits, + window.webContents.id, + automations, + { + prepareForCodexLaunch: prepareCodexRuntimeHomeForLaunch, + prepareForClaudeLaunch: (target) => claudeRuntimeAuth.prepareForClaudeLaunch(target) + }, + state.agentAwakeService ?? undefined, + state.crashReports ?? undefined, + keybindings, + { + getAdditionalAiVaultCodexHomePaths: () => + codexRuntimeHome.getHostCodexHomePathsForSessionDiscovery(), + prepareAiVaultSessionResume: (args) => + prepareCodexAiVaultSessionResume(args, { + runtimeHome: codexRuntimeHome, + systemCodexHomePath: resolveHostCodexSessionSourceHome(store.getSettings()) + }), + onBeforeRelaunch: async () => { + state.isQuitting = true + state.desktopRelayService?.fenceAndCloseNow() + await preserveAgentAuthBeforeRestart({ + codexRuntimeHome, + claudeRuntimeAuth, + store + }) + }, + onOrcaProfileAuthMutation: () => state.desktopRelayService?.authMutated(), + onBeforeOrcaProfileSignOut: () => state.desktopRelayService?.fenceAndCloseNow() + }, + state.pluginService ?? undefined, + state.pluginMarketplaceService && state.pluginMarketplaceInstaller + ? { marketplace: state.pluginMarketplaceService, installer: state.pluginMarketplaceInstaller } + : undefined + ) + automations.setWebContents(window.webContents) + automations.start() + attachMainWindowServices( + window, + store, + runtime, + prepareCodexRuntimeHomeForLaunch, + (target) => claudeRuntimeAuth.prepareForClaudeLaunch(target), + { + prepareCodexSessionResume: prepareCodexSessionResumeForLaunch, + awaitLocalPtyStartup: () => state.localPtyStartupReady, + awaitLocalPtyProviderStartup: () => state.localPtyProviderStartupReady, + onBeforeRendererReload: ({ ignoreCache, webContentsId }) => { + if (window.webContents.id === webContentsId) { + deps.markExpectedRendererReload(webContentsId) + } + deps.recordRendererReload(ignoreCache) + }, + // Why: let the PTY layer skip its orphan sweep on the recovery reload that re-fires did-finish-load, so live local sessions survive (#5787). + isRecoveryReloadInFlight, + onCodexHomePtySpawned: handleCodexHomePtySpawned, + onPtyExit: handlePtyExit, + onBeforeUpdateQuit: () => + preserveAgentAuthBeforeRestart({ codexRuntimeHome, claudeRuntimeAuth, store }), + updateInstallMode: resolveUpdateInstallMode(state.isServeMode), + onWorktreeLifecycle: emitPluginWorktreeLifecycle + } + ) + // Why: attach the durable renderer pull now, but launch the diagnostic process after first paint. + initTccPromptNotice(window, { deferWatchUntilReadyToShow: true }) + rateLimits.attach(window) + // Why: quota probes spawn CLIs and hit network, so don't fetch immediately and compete with first paint; show/focus listeners refresh later. + rateLimits.start({ fetchImmediately: false }) +} diff --git a/src/main/startup/main-window-lifecycle-flags.ts b/src/main/startup/main-window-lifecycle-flags.ts new file mode 100644 index 00000000000..fcb10fcec7c --- /dev/null +++ b/src/main/startup/main-window-lifecycle-flags.ts @@ -0,0 +1,54 @@ +import { resolveExpectedTeardownScope } from '../crash-reporting/expected-teardown-state' +import type { ExpectedTeardownScope } from '../crash-reporting/process-gone-classification' +import { recordProcessGoneCrash as recordProcessGoneCrashEvent } from '../crash-reporting/process-gone-recorder' +import { isQuittingForUpdate } from '../updater' +import { mainProcessState as state } from './main-process-state' + +export function markExpectedRendererReload(webContentsId: number, durationMs = 10_000): void { + state.expectedRendererReload.mark(webContentsId, durationMs) +} + +export function clearExpectedRendererReload(webContentsId?: number): void { + state.expectedRendererReload.clear(webContentsId) +} + +export function getExpectedTeardownScope( + webContentsId?: number, + includeSystemSessionEnd = true +): ExpectedTeardownScope { + return resolveExpectedTeardownScope({ + isQuitting: state.isQuitting, + isQuittingForUpdate: isQuittingForUpdate(), + isExpectedRendererReload: + webContentsId !== undefined && state.expectedRendererReload.matches(webContentsId), + includeSystemSessionEnd + }) +} + +export function markRecoveryReloadInFlight(webContentsId: number, durationMs = 10_000): void { + state.recoveryReloadInFlight.mark(webContentsId, durationMs) +} + +export function isRecoveryReloadInFlight(webContentsId: number): boolean { + // Why: consume on read — the recovery reload fires exactly one did-finish-load, so a later genuine reload still sweeps orphaned PTYs. + return state.recoveryReloadInFlight.matches(webContentsId, { consume: true }) +} + +export function recordProcessGoneCrash( + source: 'renderer' | 'child', + processType: string, + reason: string, + exitCode: number | null, + details: Record, + webContentsId?: number +): void { + recordProcessGoneCrashEvent(state.crashReports, { + source, + processType, + reason, + exitCode, + expectedTeardown: getExpectedTeardownScope(webContentsId), + details, + ...(webContentsId !== undefined ? { webContentsId } : {}) + }) +} diff --git a/src/main/startup/main-window-service-readiness.ts b/src/main/startup/main-window-service-readiness.ts new file mode 100644 index 00000000000..2adfd612ba7 --- /dev/null +++ b/src/main/startup/main-window-service-readiness.ts @@ -0,0 +1,42 @@ +/** + * Keep startup diagnostics specific and ordered. These messages are useful + * when a partial bootstrap opens a window and are part of the existing contract. + */ +const MAIN_WINDOW_SERVICE_REQUIREMENTS = [ + ['store', 'Store must be initialized before opening the main window'], + ['runtime', 'Runtime must be initialized before opening the main window'], + ['stats', 'Stats must be initialized before opening the main window'], + ['claudeUsage', 'Claude usage store must be initialized before opening the main window'], + ['codexUsage', 'Codex usage store must be initialized before opening the main window'], + ['openCodeUsage', 'OpenCode usage store must be initialized before opening the main window'], + ['rateLimits', 'Rate limit service must be initialized before opening the main window'], + ['automations', 'Automation service must be initialized before opening the main window'], + ['codexAccounts', 'Codex account service must be initialized before opening the main window'], + [ + 'codexRuntimeHome', + 'Codex runtime home service must be initialized before opening the main window' + ], + ['claudeAccounts', 'Claude account service must be initialized before opening the main window'], + [ + 'claudeRuntimeAuth', + 'Claude runtime auth service must be initialized before opening the main window' + ], + ['keybindings', 'Keybinding service must be initialized before opening the main window'] +] as const + +type MainWindowServiceKey = (typeof MAIN_WINDOW_SERVICE_REQUIREMENTS)[number][0] + +type RequiredServices> = { + [K in keyof T]: NonNullable +} + +export function requireMainWindowServices>( + services: T +): RequiredServices { + for (const [key, message] of MAIN_WINDOW_SERVICE_REQUIREMENTS) { + if (!services[key]) { + throw new Error(message) + } + } + return services as RequiredServices +} diff --git a/src/main/startup/os-opened-markdown-delivery.test.ts b/src/main/startup/os-opened-markdown-delivery.test.ts new file mode 100644 index 00000000000..0647516e3fa --- /dev/null +++ b/src/main/startup/os-opened-markdown-delivery.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it, vi } from 'vitest' +import { OsOpenedMarkdownFileState } from './os-opened-markdown-files' + +/** + * The two ways a queued "Open With" can be lost between main and the renderer. Both are + * about ownership: main must not drop paths it has not proven the renderer received. + */ +describe('os-opened markdown delivery ownership', () => { + it('keeps the batch when resolution rejects on the pull path', async () => { + const state = new OsOpenedMarkdownFileState() + state.captureFilePaths(['/notes/a.md']) + const resolve = vi.fn().mockRejectedValue(new Error('floating root unavailable')) + + // Mirrors the ipcMain.handle('ui:consumePendingMarkdownFileOpens') body. + const pull = async (): Promise => { + const filePaths = state.consume() + try { + return await resolve(filePaths) + } catch (error) { + state.restore(filePaths) + throw error + } + } + + await expect(pull()).rejects.toThrow('floating root unavailable') + // Without the restore the file would be gone and no later mount could ever open it. + expect(state.consume()).toEqual(['/notes/a.md']) + }) + + it('holds the batch while the renderer listener is not yet attached', () => { + const state = new OsOpenedMarkdownFileState() + const send = vi.fn() + let listenerReady = false + + // Mirrors publishOsOpenedMarkdownFiles()'s guard. + const publish = (): void => { + if (!listenerReady) { + return + } + const filePaths = state.consume() + if (filePaths.length > 0) { + send(filePaths) + } + } + + // A window exists, but the renderer has not mounted its bridge yet: send() here would be + // dropped by Electron with no error, and consuming would destroy the queue. + state.captureFilePaths(['/notes/a.md'], publish) + expect(send).not.toHaveBeenCalled() + + // The renderer's pull is what proves the listener is live. + listenerReady = true + state.captureFilePaths(['/notes/b.md'], publish) + expect(send).toHaveBeenCalledExactlyOnceWith(['/notes/a.md', '/notes/b.md']) + expect(state.consume()).toEqual([]) + }) + + it('restores a batch the window could no longer receive', () => { + const state = new OsOpenedMarkdownFileState() + state.captureFilePaths(['/notes/a.md']) + const filePaths = state.consume() + + // Window died between consume and send. + state.restore(filePaths) + + expect(state.consume()).toEqual(['/notes/a.md']) + }) +}) diff --git a/src/main/startup/os-opened-markdown-files.test.ts b/src/main/startup/os-opened-markdown-files.test.ts new file mode 100644 index 00000000000..f174e10d834 --- /dev/null +++ b/src/main/startup/os-opened-markdown-files.test.ts @@ -0,0 +1,306 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join, resolve, sep } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { isMarkdownDocumentName } from '../ipc/markdown-documents' +import { + MAX_PENDING_OS_OPENED_MARKDOWN_FILES, + OsOpenedMarkdownFileState, + markdownPathsFromArguments, + resolveOpenedMarkdownDocuments +} from './os-opened-markdown-files' + +vi.mock('../ipc/filesystem-auth', () => ({ + authorizeExternalPath: vi.fn() +})) +vi.mock('../ipc/floating-workspace-directory', () => ({ + ensureDefaultFloatingWorkspacePath: vi.fn() +})) + +const { authorizeExternalPath } = await import('../ipc/filesystem-auth') +const { ensureDefaultFloatingWorkspacePath } = await import('../ipc/floating-workspace-directory') + +describe('markdownPathsFromArguments', () => { + it('keeps absolute markdown paths and drops other extensions', () => { + expect( + markdownPathsFromArguments( + [ + '/Users/dev/notes/a.md', + '/Users/dev/notes/b.markdown', + '/Users/dev/notes/c.mdx', + '/Users/dev/notes/d.txt', + '/Users/dev/src/e.tsx', + '/Users/dev/notes/README' + ], + 'darwin' + ) + ).toEqual(['/Users/dev/notes/a.md', '/Users/dev/notes/b.markdown', '/Users/dev/notes/c.mdx']) + }) + + it('drops switches, including Chromium-style ones that would otherwise look like values', () => { + expect( + markdownPathsFromArguments( + ['--serve', '-v', '--allow-file-access-from-files', '/Users/dev/notes/a.md'], + 'darwin' + ) + ).toEqual(['/Users/dev/notes/a.md']) + }) + + it('drops the executable and dev entries because none of them end in a markdown extension', () => { + const nonDocumentEntries = [ + '/Applications/Orca.app/Contents/MacOS/Orca', + '/Users/dev/orca/out/main/index.js', + '/Applications/Orca.app/Contents/Resources/app.asar' + ] + // The module documents that the extension check alone excludes these; hold it to that. + for (const entry of nonDocumentEntries) { + expect(isMarkdownDocumentName(entry), entry).toBe(false) + } + expect( + markdownPathsFromArguments([...nonDocumentEntries, '/Users/dev/notes/a.md'], 'darwin') + ).toEqual(['/Users/dev/notes/a.md']) + }) + + it('drops relative paths because a second instance has no meaningful cwd', () => { + expect( + markdownPathsFromArguments(['readme.md', './docs/a.md', '../up.md', ''], 'darwin') + ).toEqual([]) + }) + + it('accepts win32 drive-letter and UNC paths', () => { + expect( + markdownPathsFromArguments( + ['C:\\Users\\dev\\todo.md', '\\\\server\\share\\a.md', 'C:\\Users\\dev\\todo.txt'], + 'win32' + ) + ).toEqual(['C:\\Users\\dev\\todo.md', '\\\\server\\share\\a.md']) + }) + + it('dedupes case-insensitively on win32 and keeps the first spelling', () => { + expect(markdownPathsFromArguments(['C:\\notes\\A.md', 'c:\\notes\\a.md'], 'win32')).toEqual([ + 'C:\\notes\\A.md' + ]) + }) + + it('normalizes parent segments before deduping', () => { + expect( + markdownPathsFromArguments(['C:\\notes\\sub\\..\\a.md', 'C:\\notes\\a.md'], 'win32') + ).toEqual(['C:\\notes\\a.md']) + expect(markdownPathsFromArguments(['/docs/../notes/a.md', '/notes/a.md'], 'darwin')).toEqual([ + '/notes/a.md' + ]) + }) + + it('does not dedupe case-insensitively on posix, where casing is a different file', () => { + expect(markdownPathsFromArguments(['/a/A.md', '/a/a.md'], 'linux')).toEqual([ + '/a/A.md', + '/a/a.md' + ]) + }) + + it('accepts a file:// URI, which the desktop entry %U field code permits', () => { + // Why defensive rather than load-bearing: GLib decodes a local file:// URI to a plain + // path before spawning (measured on Ubuntu 24.04), so Linux hits the plain-path branch + // today. The %U spec still allows a URI, and a launcher that passes one literally would + // otherwise be dropped without a trace. + expect( + markdownPathsFromArguments( + ['file:///home/me/notes/a.md', 'file:///home/me/notes/b.txt'], + 'linux' + ) + ).toEqual(['/home/me/notes/a.md']) + }) + + it('percent-decodes a file:// URI so a path with spaces still opens', () => { + expect(markdownPathsFromArguments(['file:///home/me/design%20notes.md'], 'linux')).toEqual([ + '/home/me/design notes.md' + ]) + }) + + it('decodes win32 file:// URIs, including UNC authority form', () => { + expect( + markdownPathsFromArguments( + ['file:///C:/Users/me/todo.md', 'file://server/share/a.md'], + 'win32' + ) + ).toEqual(['C:\\Users\\me\\todo.md', '\\\\server\\share\\a.md']) + }) + + it('dedupes a path delivered as both a URI and a bare path', () => { + expect(markdownPathsFromArguments(['file:///home/me/a.md', '/home/me/a.md'], 'linux')).toEqual([ + '/home/me/a.md' + ]) + }) + + it('drops a malformed or non-file URL instead of throwing', () => { + expect(() => + markdownPathsFromArguments(['file://', 'file:///%zz.md', 'https://example.com/a.md'], 'linux') + ).not.toThrow() + expect( + markdownPathsFromArguments(['file://', 'file:///%zz.md', 'https://example.com/a.md'], 'linux') + ).toEqual([]) + }) + + it('honours the platform argument rather than the host OS', () => { + const argv = ['C:\\notes\\a.md', '/notes/b.md'] + // Same argv, two platforms: a win32 path is not absolute to posix, and posix input is + // renormalized to backslashes on win32. Neither result may depend on where the suite runs. + expect(markdownPathsFromArguments(argv, 'darwin')).toEqual(['/notes/b.md']) + expect(markdownPathsFromArguments(argv, 'win32')).toEqual(['C:\\notes\\a.md', '\\notes\\b.md']) + }) +}) + +// Why resolve(): the state uses the host platform by default, so fixture paths must already be +// spelled the way the host's path module normalizes them (`\n\a.md` and a drive on Windows). +const hostPath = (name: string): string => resolve(sep, 'notes', name) + +describe('OsOpenedMarkdownFileState', () => { + it('reports no capture and does not publish when argv carries no markdown', () => { + const state = new OsOpenedMarkdownFileState() + const publish = vi.fn() + + expect(state.capture(['/Applications/Orca.app/Contents/MacOS/Orca', '--serve'], publish)).toBe( + false + ) + expect(publish).not.toHaveBeenCalled() + expect(state.consume()).toEqual([]) + }) + + it('buffers and publishes when argv carries markdown', () => { + const state = new OsOpenedMarkdownFileState() + const publish = vi.fn() + const filePath = hostPath('a.md') + + expect(state.capture(['/Applications/Orca.app/Contents/MacOS/Orca', filePath], publish)).toBe( + true + ) + expect(publish).toHaveBeenCalledTimes(1) + expect(state.consume()).toEqual([filePath]) + }) + + it('captures a single macOS open-file path', () => { + const state = new OsOpenedMarkdownFileState() + const publish = vi.fn() + const filePath = hostPath('a.md') + + expect(state.captureFilePaths([filePath], publish)).toBe(true) + expect(state.captureFilePaths([hostPath('a.png')], publish)).toBe(false) + expect(publish).toHaveBeenCalledTimes(1) + expect(state.consume()).toEqual([filePath]) + }) + + it('does not duplicate a path captured twice', () => { + const state = new OsOpenedMarkdownFileState() + const filePath = hostPath('a.md') + + state.captureFilePaths([filePath]) + state.captureFilePaths([filePath]) + state.capture(['orca', filePath]) + + expect(state.consume()).toEqual([filePath]) + }) + + it('drains the buffer on consume', () => { + const state = new OsOpenedMarkdownFileState() + const paths = [hostPath('a.md'), hostPath('b.md')] + state.captureFilePaths(paths) + + expect(state.consume()).toEqual(paths) + expect(state.consume()).toEqual([]) + }) + + it('restores an undelivered batch at the front of the buffer', () => { + const state = new OsOpenedMarkdownFileState() + state.captureFilePaths([hostPath('later.md')]) + + state.restore([hostPath('undelivered.md')]) + + expect(state.consume()).toEqual([hostPath('undelivered.md'), hostPath('later.md')]) + }) + + it('caps the buffer when captures overflow it', () => { + const state = new OsOpenedMarkdownFileState() + const overflow = MAX_PENDING_OS_OPENED_MARKDOWN_FILES + 5 + const paths = Array.from({ length: overflow }, (_, index) => hostPath(`file-${index}.md`)) + + expect(state.captureFilePaths(paths)).toBe(true) + + expect(state.consume()).toEqual(paths.slice(0, MAX_PENDING_OS_OPENED_MARKDOWN_FILES)) + }) + + it('caps the buffer when a restore overflows it', () => { + const state = new OsOpenedMarkdownFileState() + state.captureFilePaths([hostPath('pending.md')]) + const restored = Array.from({ length: MAX_PENDING_OS_OPENED_MARKDOWN_FILES }, (_, index) => + hostPath(`restored-${index}.md`) + ) + + state.restore(restored) + + const pending = state.consume() + expect(pending).toHaveLength(MAX_PENDING_OS_OPENED_MARKDOWN_FILES) + expect(pending).toEqual(restored) + }) +}) + +describe('resolveOpenedMarkdownDocuments', () => { + let floatingRoot: string + let fileRoot: string + + beforeEach(async () => { + vi.mocked(authorizeExternalPath).mockClear() + vi.mocked(ensureDefaultFloatingWorkspacePath).mockClear() + floatingRoot = await mkdtemp(join(tmpdir(), 'orca-os-open-root-')) + fileRoot = await mkdtemp(join(tmpdir(), 'orca-os-open-files-')) + vi.mocked(ensureDefaultFloatingWorkspacePath).mockResolvedValue(floatingRoot) + }) + + afterEach(async () => { + await rm(floatingRoot, { recursive: true, force: true }) + await rm(fileRoot, { recursive: true, force: true }) + }) + + it('resolves a real file outside the floating root to a basename-relative document', async () => { + const filePath = join(fileRoot, 'design notes.md') + await writeFile(filePath, '# hi\n', 'utf8') + + const documents = await resolveOpenedMarkdownDocuments([filePath]) + + expect(documents).toEqual([ + { + filePath, + relativePath: 'design notes.md', + basename: 'design notes.md', + name: 'design notes' + } + ]) + expect(authorizeExternalPath).toHaveBeenCalledWith(filePath) + }) + + it('drops a directory that merely looks like a markdown file', async () => { + const bundlePath = join(fileRoot, 'bundle.md') + await mkdir(bundlePath) + const filePath = join(fileRoot, 'real.md') + await writeFile(filePath, '# hi\n', 'utf8') + + const documents = await resolveOpenedMarkdownDocuments([bundlePath, filePath]) + + expect(documents.map((document) => document.filePath)).toEqual([filePath]) + // Security contract: a path we never validated must never be authorized for renderer reads. + expect(authorizeExternalPath).toHaveBeenCalledTimes(1) + expect(authorizeExternalPath).toHaveBeenCalledWith(filePath) + }) + + it('drops a path that no longer exists without authorizing it', async () => { + const missingPath = join(fileRoot, 'gone.md') + + expect(await resolveOpenedMarkdownDocuments([missingPath])).toEqual([]) + expect(authorizeExternalPath).not.toHaveBeenCalled() + }) + + it('returns nothing for an empty input without touching the filesystem', async () => { + expect(await resolveOpenedMarkdownDocuments([])).toEqual([]) + expect(ensureDefaultFloatingWorkspacePath).not.toHaveBeenCalled() + expect(authorizeExternalPath).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/startup/os-opened-markdown-files.ts b/src/main/startup/os-opened-markdown-files.ts new file mode 100644 index 00000000000..dae27fb7a78 --- /dev/null +++ b/src/main/startup/os-opened-markdown-files.ts @@ -0,0 +1,149 @@ +import { stat } from 'node:fs/promises' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import type { MarkdownDocument } from '../../shared/filesystem-entry-types' +import { authorizeExternalPath } from '../ipc/filesystem-auth' +import { ensureDefaultFloatingWorkspacePath } from '../ipc/floating-workspace-directory' +import { isMarkdownDocumentName, markdownDocumentFromFilePath } from '../ipc/markdown-documents' + +// Why: a shell can only ever hand over the files the user selected; anything past this is a +// runaway argv, and buffering it unbounded would pin the paths for the whole session. +export const MAX_PENDING_OS_OPENED_MARKDOWN_FILES = 32 + +/** + * Resolves one argv entry to a local absolute path, or null if it is not one. + * + * Why file:// is accepted defensively: electron-builder appends the `%U` field code to the + * generated Linux `Exec=` line, and `%U` is specified as "URLs". GLib turns out to decode a + * local `file://` URI back to a plain path before spawning (measured on Ubuntu 24.04, via + * the same `launch_uris` call a file manager makes), so the branch below is not what fires + * there today — but the spec permits a URI, and a launcher that honours it literally would + * otherwise be silently dropped. macOS `open-file` and the Windows shell `%1` pass paths. + */ +function localPathFromArgument(argument: string, platform: NodeJS.Platform): string | null { + const pathApi = platform === 'win32' ? path.win32 : path.posix + if (argument.startsWith('file://')) { + try { + // Why the explicit windows flag: this must decode the same way on any host so the + // behaviour is testable, and it is what turns `file://server/share` back into a UNC path. + return fileURLToPath(argument, { windows: platform === 'win32' }) + } catch { + return null + } + } + return pathApi.isAbsolute(argument) ? argument : null +} + +/** + * Absolute markdown paths an OS "Open With" put on a launch or second-instance argv. + * + * Why no executable/asar/dev-entry filtering: none of those argv entries end in a markdown + * extension, so the extension check already excludes them. Relative entries are dropped + * because the shell always passes absolute paths and `cwd` is meaningless for a second instance. + */ +export function markdownPathsFromArguments( + argv: readonly string[], + platform: NodeJS.Platform = process.platform +): string[] { + const pathApi = platform === 'win32' ? path.win32 : path.posix + const seen = new Set() + const paths: string[] = [] + for (const rawArgument of argv) { + if (!rawArgument || rawArgument.startsWith('-')) { + continue + } + const argument = localPathFromArgument(rawArgument, platform) + if (!argument || !isMarkdownDocumentName(argument)) { + continue + } + const normalized = pathApi.normalize(argument) + // Why lowercased on win32: the shell round-trips drive letters and 8.3 casing + // inconsistently, and two spellings of one path must not open two tabs. + const key = platform === 'win32' ? normalized.toLowerCase() : normalized + if (seen.has(key)) { + continue + } + seen.add(key) + paths.push(normalized) + } + return paths +} + +/** + * Buffers markdown paths the OS handed us until a renderer can receive them. + * + * Mirrors SkillShareDeepLinkState: main pushes when a window is already live, and the + * renderer pulls the same buffer when its listener attaches, so a cold-start "Open With" + * that lands before mount is not dropped. + */ +export class OsOpenedMarkdownFileState { + private pending: string[] = [] + + /** Returns true when argv carried at least one markdown path. */ + capture(argv: readonly string[], publish?: () => void): boolean { + return this.add(markdownPathsFromArguments(argv), publish) + } + + /** Returns true when at least one path was a markdown document. */ + captureFilePaths(filePaths: readonly string[], publish?: () => void): boolean { + return this.add(markdownPathsFromArguments(filePaths), publish) + } + + consume(): string[] { + const pending = this.pending + this.pending = [] + return pending + } + + /** Puts an undelivered batch back at the front so the next renderer still receives it. */ + restore(filePaths: readonly string[]): void { + this.pending = [...filePaths, ...this.pending].slice(0, MAX_PENDING_OS_OPENED_MARKDOWN_FILES) + } + + private add(filePaths: readonly string[], publish?: () => void): boolean { + if (filePaths.length === 0) { + return false + } + const merged = [...this.pending] + for (const filePath of filePaths) { + if (!merged.includes(filePath)) { + merged.push(filePath) + } + } + this.pending = merged.slice(0, MAX_PENDING_OS_OPENED_MARKDOWN_FILES) + publish?.() + return true + } +} + +/** + * Turns OS-handed paths into the same `MarkdownDocument` shape the floating workspace's own + * file picker produces, authorizing each one for the renderer's later read. + */ +export async function resolveOpenedMarkdownDocuments( + filePaths: readonly string[] +): Promise { + if (filePaths.length === 0) { + return [] + } + const floatingRoot = await ensureDefaultFloatingWorkspacePath() + const documents: MarkdownDocument[] = [] + for (const filePath of filePaths) { + try { + // Why: the shell can hand over a bundle directory named `*.md`, or a path already + // deleted by the time we resolve. Authorize only something that is really a file. + if (!(await stat(filePath)).isFile()) { + continue + } + } catch { + continue + } + authorizeExternalPath(filePath) + documents.push( + markdownDocumentFromFilePath(floatingRoot, filePath, { + outsideRootRelativePath: 'basename' + }) + ) + } + return documents +} diff --git a/src/main/startup/os-opened-markdown-wiring.test.ts b/src/main/startup/os-opened-markdown-wiring.test.ts new file mode 100644 index 00000000000..179ca0820ca --- /dev/null +++ b/src/main/startup/os-opened-markdown-wiring.test.ts @@ -0,0 +1,57 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' + +const read = (relativePath: string): string => + // Why source text: this wiring is module-scope side effects in the entry point, which no + // unit test can import without booting Electron. These guards pin the call shapes instead. + readFileSync(join(process.cwd(), relativePath), 'utf8').replaceAll('"', "'") + +describe('os-opened markdown wiring', () => { + const index = read('src/main/index.ts') + const bootstrap = read('src/main/startup/main-process-ipc-bootstrap.ts') + const controller = read('src/main/startup/main-window-controller.ts') + + it('captures argv before the serve-duplicate early return', () => { + const captureIndex = index.indexOf( + 'state.osOpenedMarkdownFiles.capture(argv, publishOsOpenedMarkdownFiles)' + ) + const serveGuardIndex = index.indexOf('if (!shouldActivateDesktopForSecondInstance(argv)) {') + + expect(captureIndex).toBeGreaterThanOrEqual(0) + expect(serveGuardIndex).toBeGreaterThanOrEqual(0) + // A duplicate `orca serve` returns early; capturing after that would drop the user's files. + expect(captureIndex).toBeLessThan(serveGuardIndex) + }) + + it('claims the macOS open-file event so the default handler does not win it', () => { + const handlerIndex = index.indexOf("app.on('open-file'") + expect(handlerIndex).toBeGreaterThanOrEqual(0) + + const preventDefaultIndex = index.indexOf('event.preventDefault()', handlerIndex) + const nextRegistrationIndex = index.indexOf('app.on(', handlerIndex + 1) + expect(preventDefaultIndex).toBeGreaterThan(handlerIndex) + if (nextRegistrationIndex !== -1) { + expect(preventDefaultIndex).toBeLessThan(nextRegistrationIndex) + } + }) + + it('captures the cold-start argv and lets the renderer pull it after mount', () => { + expect(index).toContain('state.osOpenedMarkdownFiles.capture(process.argv)') + expect(bootstrap).toContain("ipcMain.handle('ui:consumePendingMarkdownFileOpens'") + }) + + // Why: `webContents.send` to a renderer that has not attached the listener is dropped with no + // error, so publishing on "a window exists" alone would consume the queue into a void. + it('only pushes once the renderer has proven its listener is attached', () => { + expect(index).toContain('!state.markdownFileOpenListenerReady') + expect(bootstrap).toContain('state.markdownFileOpenListenerReady = true') + // A reload drops the listener; the fresh renderer re-proves itself by pulling again. + expect(controller).toContain('state.markdownFileOpenListenerReady = false') + }) + + it('restores an undelivered batch on both the push and the pull path', () => { + expect(index).toContain('state.osOpenedMarkdownFiles.restore(filePaths)') + expect(bootstrap).toContain('state.osOpenedMarkdownFiles.restore(filePaths)') + }) +}) diff --git a/src/main/startup/run-electron-vite-dev.test.ts b/src/main/startup/run-electron-vite-dev.test.ts index 2146563373d..73d1bb21cdc 100644 --- a/src/main/startup/run-electron-vite-dev.test.ts +++ b/src/main/startup/run-electron-vite-dev.test.ts @@ -105,6 +105,56 @@ function devWrapperTestEnv(extra: NodeJS.ProcessEnv): NodeJS.ProcessEnv { return { ...env, ...extra } } +/** + * What the two cases below wait on: a ~280MB clone of Electron.app, two swiftc + * helper builds, and `codesign --deep` over the result. Six seconds on an idle + * machine; the swiftc builds alone pass fifteen when this file runs inside the + * full suite and every core is taken. The generous ceiling only costs time on a + * run that is already failing. + */ +const PREPARE_TIMEOUT_MS = 90_000 + +/** + * Spawns the wrapper with its output retained. + * + * Why retained: the wrapper reports its own failures on stderr, and discarding + * them turned a crash in prepare into a bare "Timed out waiting for condition" + * with nothing to act on. + */ +function spawnDevWrapper( + args: string[], + env: NodeJS.ProcessEnv +): { wrapper: ChildProcess; readOutput: () => string } { + const wrapper = spawn(process.execPath, args, { + cwd: resolve('.'), + env, + stdio: ['ignore', 'pipe', 'pipe'] + }) + let output = '' + const collect = (chunk: Buffer): void => { + output += chunk.toString() + } + wrapper.stdout?.on('data', collect) + wrapper.stderr?.on('data', collect) + return { wrapper, readOutput: () => output } +} + +async function waitForEnvFile(envFile: string, readOutput: () => string): Promise { + try { + await waitFor(() => { + try { + return readFileSync(envFile, 'utf8').trim().length > 0 + } catch { + return false + } + }, PREPARE_TIMEOUT_MS) + } catch (error) { + throw new Error( + `${(error as Error).message}: the dev wrapper never wrote ${envFile}. Wrapper output:\n${readOutput() || '(none)'}` + ) + } +} + describe('run-electron-vite-dev', () => { afterEach(async () => { for (const pid of processesToCleanUp) { @@ -351,26 +401,19 @@ describe('run-electron-vite-dev', () => { async function runWrapper(runId: string): Promise<{ electronExecPath: string }> { const pidFile = join(tempDir, `${runId}.pid`) const envFile = join(tempDir, `${runId}.json`) - const wrapper = spawn(process.execPath, [wrapperPath, '--remote-debugging-port=9448'], { - cwd: resolve('.'), - env: { + const { wrapper, readOutput } = spawnDevWrapper( + [wrapperPath, '--remote-debugging-port=9448'], + { ...baseEnv, ORCA_DEV_WRAPPER_TEST_PID_FILE: pidFile, ORCA_DEV_WRAPPER_TEST_ENV_FILE: envFile - }, - stdio: 'ignore' - }) + } + ) expect(wrapper.pid).toBeTypeOf('number') processesToCleanUp.add(wrapper.pid!) - await waitFor(() => { - try { - return readFileSync(envFile, 'utf8').trim().length > 0 - } catch { - return false - } - }, 20000) + await waitForEnvFile(envFile, readOutput) const trackedPids = trackPidFile(pidFile) @@ -409,7 +452,8 @@ describe('run-electron-vite-dev', () => { } } }, - 30000 + // Two full prepares, each budgeted at PREPARE_TIMEOUT_MS. + PREPARE_TIMEOUT_MS * 2 + 30_000 ) it.skipIf(process.platform !== 'darwin')( @@ -421,9 +465,9 @@ describe('run-electron-vite-dev', () => { const wrapperPath = resolve('config/scripts/run-electron-vite-dev.mjs') const fakeCliPath = resolve('src/main/startup/__fixtures__/fake-electron-vite-dev-cli.mjs') - const wrapper = spawn(process.execPath, [wrapperPath, '--remote-debugging-port=9448'], { - cwd: resolve('.'), - env: devWrapperTestEnv({ + const { wrapper, readOutput } = spawnDevWrapper( + [wrapperPath, '--remote-debugging-port=9448'], + devWrapperTestEnv({ ORCA_ELECTRON_VITE_CLI: fakeCliPath, ORCA_SKIP_DEV_CLI_PREPARE: '1', ORCA_SKIP_DEV_WEB_PREPARE: '1', @@ -431,20 +475,13 @@ describe('run-electron-vite-dev', () => { ORCA_DEV_WRAPPER_TEST_ENV_FILE: envFile, ORCA_DEV_BRANCH: 'feature/framework-symlinks', ORCA_DEV_WORKTREE_NAME: 'symlink-ui' - }), - stdio: 'ignore' - }) + }) + ) expect(wrapper.pid).toBeTypeOf('number') processesToCleanUp.add(wrapper.pid!) - await waitFor(() => { - try { - return readFileSync(envFile, 'utf8').trim().length > 0 - } catch { - return false - } - }, 20000) + await waitForEnvFile(envFile, readOutput) const trackedPids = trackPidFile(pidFile) @@ -464,6 +501,6 @@ describe('run-electron-vite-dev', () => { await stopWrapperAndTrackedPids(wrapper, trackedPids) }, - 30000 + PREPARE_TIMEOUT_MS + 30_000 ) }) diff --git a/src/main/startup/secret-protection-report-deferral-wiring.test.ts b/src/main/startup/secret-protection-report-deferral-wiring.test.ts index eda9721667f..be1c5f64766 100644 --- a/src/main/startup/secret-protection-report-deferral-wiring.test.ts +++ b/src/main/startup/secret-protection-report-deferral-wiring.test.ts @@ -13,18 +13,22 @@ import { describe, expect, it } from 'vitest' * as a dead host); `false` puts the blocking probe back in front of the window. Deleting the * call entirely restores the original regression. * - * Source-level because that is the property: this runs once inside `app.whenReady()` during - * startup, so there is no seam to assert against at runtime. + * Source-level because that is the property: this runs once during the ready-phase foundation, + * so there is no seam to assert against at runtime. */ describe('secret protection report deferral wiring', () => { - const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') + const source = readFileSync( + join(process.cwd(), 'src/main/startup/main-process-ready-foundation.ts'), + 'utf8' + ) + const entrySource = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') const SCHEDULE = 'scheduleSecretProtectionGapReport({' it('arms the deferred report exactly once and never calls the blocking one directly', () => { expect(source.split(SCHEDULE).length - 1, `${SCHEDULE} should appear exactly once`).toBe(1) expect(source).toContain( - "import { scheduleSecretProtectionGapReport } from './host/deferred-secret-protection-report'" + "import { scheduleSecretProtectionGapReport } from '../host/deferred-secret-protection-report'" ) // Why also assert the absence: re-importing the blocking entry point reinstates the // pre-window probe without touching the call site the next test pins. Note the scheduling @@ -48,26 +52,28 @@ describe('secret protection report deferral wiring', () => { // Why anchor the indent: `SCHEDULE` matches anywhere, including as the body of an added // `if (...) schedule(...)` guard, which leaves every assertion here true while the call // stops running unconditionally. Pinning it as a statement at whenReady's own indent is - // what makes "this runs on every desktop startup" the thing under test. + // what makes "this runs on every startup" the thing under test. expect(source).toContain(`\n ${SCHEDULE}`) - expect(call).toContain('deferUntilFirstWindow: !isServeMode') + expect(call).toContain('deferUntilFirstWindow: !state.isServeMode') + expect(call).toContain('skipInDevelopment: is.dev') // Why assert the constants are absent too: `!isServeMode` being present does not stop a // later property in the same literal from overriding it. expect(call).not.toContain('deferUntilFirstWindow: true') expect(call).not.toContain('deferUntilFirstWindow: false') }) - it('arms the report after the profile exists and inside app readiness', () => { + it('arms the report after the profile exists during app readiness', () => { // Why: the report remembers what it last said beside the profile data file, so arming it // before the profile is resolved would key the state off a path that does not exist yet. // Anchored on code, never a comment — a reworded comment silently becomes -1. - const ready = source.indexOf('app.whenReady().then(') - const profile = source.indexOf('const activeOrcaProfile = ensureActiveOrcaProfile()') + const ready = entrySource.indexOf('void app.whenReady().then(async () => {') + const profile = source.indexOf('const profile = ensureActiveOrcaProfile()') const schedule = source.indexOf(SCHEDULE) expect(ready).toBeGreaterThanOrEqual(0) - expect(profile).toBeGreaterThan(ready) + expect(profile).toBeGreaterThanOrEqual(0) expect(schedule).toBeGreaterThan(profile) + expect(entrySource.indexOf('initializeMainProcessReady({')).toBeGreaterThan(ready) }) }) diff --git a/src/main/startup/serve-desktop-activation-wiring.test.ts b/src/main/startup/serve-desktop-activation-wiring.test.ts index 0aea295c657..64d628c51ff 100644 --- a/src/main/startup/serve-desktop-activation-wiring.test.ts +++ b/src/main/startup/serve-desktop-activation-wiring.test.ts @@ -3,59 +3,84 @@ import { join } from 'node:path' import { describe, expect, it } from 'vitest' describe('serve desktop activation wiring', () => { - const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') + const entrySource = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') + const preflightSource = readFileSync( + join(process.cwd(), 'src/main/startup/main-process-preflight.ts'), + 'utf8' + ) + const runtimeSource = readFileSync( + join(process.cwd(), 'src/main/startup/main-process-runtime-launch.ts'), + 'utf8' + ) + const runtimeServiceSource = readFileSync( + join(process.cwd(), 'src/main/startup/main-process-runtime-service.ts'), + 'utf8' + ) + const windowCoreSource = readFileSync( + join(process.cwd(), 'src/main/startup/main-window-core-services.ts'), + 'utf8' + ) it('routes second-instance and windowless app activation through one safety gate', () => { - expect(source).toContain('createServeDesktopActivationGate({') - expect(source).toContain('acquireSingleInstanceLock(app, requestDesktopActivation)') - expect(source).toContain('createMacAppActivationHandler({') - expect(source).toContain("app.on('activate', handleMacAppActivation)") - expect(source).toContain('getDesktopWindowStatus: getDesktopWindowStatus') + expect(preflightSource).toContain('createServeDesktopActivationGate({') + expect(preflightSource).toContain( + 'acquireSingleInstanceLock(app, options.requestDesktopActivation)' + ) + expect(entrySource).toContain('createMacAppActivationHandler({') + expect(runtimeSource).toContain("app.on('activate', options.handleMacAppActivation)") + expect(runtimeServiceSource).toContain('getDesktopWindowStatus,') }) it('settles the persistent provider before headless PTY registration', () => { - const appReadyIndex = source.indexOf('app.whenReady().then(async () => {') - const startupIndex = source.indexOf( - 'bindTerminalRuntimeStartupServices(Promise.resolve(startTerminalRuntimeStartupServices()))', - appReadyIndex + const startupIndex = runtimeSource.indexOf( + 'bindTerminalRuntimeStartupServices(Promise.resolve(startTerminalRuntimeStartupServices()))' ) - const serveIndex = source.indexOf('if (serveOptions) {', appReadyIndex) - const ptyReadyIndex = source.indexOf('await localPtyStartupReady', serveIndex) - const providerReadyIndex = source.indexOf('await localPtyProviderStartupReady', serveIndex) - const headlessRegistrationIndex = source.indexOf( + const serveLaunchIndex = runtimeSource.indexOf('async function launchServeMode(') + const serveDispatchIndex = runtimeSource.indexOf(' if (serveOptions) {', startupIndex) + const ptyReadyIndex = runtimeSource.indexOf( + 'await state.localPtyStartupReady', + serveLaunchIndex + ) + const providerReadyIndex = runtimeSource.indexOf( + 'await state.localPtyProviderStartupReady', + serveLaunchIndex + ) + const headlessRegistrationIndex = runtimeSource.indexOf( 'await registerHeadlessPtyRuntime(', - serveIndex + serveLaunchIndex ) - const rpcIndex = source.indexOf('await runtimeRpc.start()', serveIndex) + const rpcIndex = runtimeSource.indexOf('await runtimeRpc.start()', serveLaunchIndex) expect(startupIndex).toBeGreaterThanOrEqual(0) - expect(startupIndex).toBeLessThan(serveIndex) - expect(ptyReadyIndex).toBeGreaterThan(serveIndex) + expect(serveDispatchIndex).toBeGreaterThan(startupIndex) + expect(ptyReadyIndex).toBeGreaterThan(serveLaunchIndex) expect(providerReadyIndex).toBeGreaterThan(ptyReadyIndex) expect(headlessRegistrationIndex).toBeGreaterThan(providerReadyIndex) expect(headlessRegistrationIndex).toBeLessThan(rpcIndex) - expect(source).not.toContain( + expect(runtimeSource).not.toContain( 'if (!isServeMode) {\n startDesktopFirstWindowStartupServices()' ) }) it('publishes the named headless sentinel and only enables promotion after RPC is ready', () => { - const serveIndex = source.indexOf('if (serveOptions) {') - const sentinelIndex = source.indexOf( + const serveIndex = runtimeSource.indexOf('async function launchServeMode(') + const sentinelIndex = runtimeSource.indexOf( 'runtime.syncWindowGraph(HEADLESS_RUNTIME_WINDOW_ID', serveIndex ) - const rpcIndex = source.indexOf('await runtimeRpc.start()', serveIndex) - const settleIndex = source.indexOf('settleServeDesktopActivation()', rpcIndex) + const rpcIndex = runtimeSource.indexOf('await runtimeRpc.start()', serveIndex) + const settleIndex = runtimeSource.indexOf('settleDesktopActivation()', rpcIndex) expect(serveIndex).toBeGreaterThanOrEqual(0) expect(sentinelIndex).toBeGreaterThan(serveIndex) expect(rpcIndex).toBeGreaterThan(sentinelIndex) expect(settleIndex).toBeGreaterThan(rpcIndex) - expect(source).not.toContain('runtime.syncWindowGraph(0,') + expect(runtimeSource).not.toContain('runtime.syncWindowGraph(0,') }) it('keeps the headless install policy after desktop promotion', () => { - expect(source).toContain('updateInstallMode: resolveUpdateInstallMode(isServeMode)') + expect(windowCoreSource).toContain( + 'updateInstallMode: resolveUpdateInstallMode(state.isServeMode)' + ) }) }) diff --git a/src/main/startup/serve-mode-argv-cli-redirect-order.test.ts b/src/main/startup/serve-mode-argv-cli-redirect-order.test.ts index be6bf76e625..3455c8c59ab 100644 --- a/src/main/startup/serve-mode-argv-cli-redirect-order.test.ts +++ b/src/main/startup/serve-mode-argv-cli-redirect-order.test.ts @@ -49,14 +49,17 @@ describe('serve argv rewrite vs AppImage CLI redirect ordering', () => { expect(getAppImageCliArgs(argv, MOUNTED_APPIMAGE_ENV, REDIRECT_OPTIONS)).toEqual(['status']) }) - // Why source text: the ordering only exists as statement order at index.ts module scope, and the + // Why source text: the ordering is the preflight phase's executable statement order, and the // cases above stay green if it is reversed — nothing else would catch the regression. - it('keeps index.ts running both CLI redirects before the argv rewrite', () => { - const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') + it('keeps the preflight running both CLI redirects before the argv rewrite', () => { + const source = readFileSync( + join(process.cwd(), 'src/main/startup/main-process-preflight.ts'), + 'utf8' + ) const packagedRedirect = source.indexOf('maybeRedirectPackagedCliEntryLaunch({') const appImageRedirect = source.indexOf('maybeRedirectAppImageCliLaunch({') const rewrite = source.indexOf('process.argv = normalizeServeModeArgv(process.argv)') - const serveModeCheck = source.indexOf("const isServeMode = process.argv.includes('--serve')") + const serveModeCheck = source.indexOf("state.isServeMode = process.argv.includes('--serve')") expect(packagedRedirect).toBeGreaterThanOrEqual(0) expect(appImageRedirect).toBeGreaterThanOrEqual(0) diff --git a/src/main/startup/single-instance-lock-exit.electron.test.ts b/src/main/startup/single-instance-lock-exit.electron.test.ts index 7cf61a4af0f..15623c2459f 100644 --- a/src/main/startup/single-instance-lock-exit.electron.test.ts +++ b/src/main/startup/single-instance-lock-exit.electron.test.ts @@ -34,10 +34,13 @@ afterAll(() => { /** The `app.*` call the shipped lock-loss gate executes, so a revert to `app.quit()` fails here. */ function readLockLossTermination(): string { - const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') - const start = source.indexOf('if (!hasSingleInstanceLock) {') + const source = readFileSync( + join(process.cwd(), 'src/main/startup/main-process-preflight.ts'), + 'utf8' + ) + const start = source.indexOf('if (!hasLock) {') expect(start).toBeGreaterThanOrEqual(0) - const end = source.indexOf('\n}', start) + const end = source.indexOf('\n }', start) expect(end).toBeGreaterThan(start) return source diff --git a/src/main/startup/single-instance-lock-headless-exit.test.ts b/src/main/startup/single-instance-lock-headless-exit.test.ts index 3c9468f595e..f79afa3f5ba 100644 --- a/src/main/startup/single-instance-lock-headless-exit.test.ts +++ b/src/main/startup/single-instance-lock-headless-exit.test.ts @@ -19,28 +19,32 @@ function readSystemdUnitBlocks(doc: string): Map { } describe('headless lock-loss exit contract', () => { - const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') + const preflightSource = readFileSync( + join(process.cwd(), 'src/main/startup/main-process-preflight.ts'), + 'utf8' + ) + const entrySource = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') const doc = readFileSync(join(process.cwd(), 'docs/reference/headless-linux-server.md'), 'utf8') it('exits the lock-losing launch immediately instead of scheduling a graceful quit', () => { - const gateStart = source.indexOf('if (!hasSingleInstanceLock) {') + const gateStart = preflightSource.indexOf('if (!hasLock) {') // Why: bound the anchor — an unresolved indexOf slices to EOF and passes vacuously. expect(gateStart).toBeGreaterThanOrEqual(0) - const gateEnd = source.indexOf('\n}', gateStart) + const gateEnd = preflightSource.indexOf('\n }', gateStart) expect(gateEnd).toBeGreaterThan(gateStart) - const gate = source.slice(gateStart, gateEnd) + const gate = preflightSource.slice(gateStart, gateEnd) expect(gate).toContain('app.exit(SINGLE_INSTANCE_ALREADY_RUNNING_EXIT_CODE)') expect(gate).not.toContain('app.quit()') }) it('keeps a duplicate serve launch from promoting the live server to a desktop window', () => { - const activationStart = source.indexOf('function requestDesktopActivation(') + const activationStart = entrySource.indexOf('function requestDesktopActivation(') expect(activationStart).toBeGreaterThanOrEqual(0) - const activationEnd = source.indexOf('\n}', activationStart) + const activationEnd = entrySource.indexOf('\n}', activationStart) expect(activationEnd).toBeGreaterThan(activationStart) - expect(source.slice(activationStart, activationEnd)).toContain( + expect(entrySource.slice(activationStart, activationEnd)).toContain( 'shouldActivateDesktopForSecondInstance(argv)' ) }) diff --git a/src/main/startup/synthetic-title-runtime.ts b/src/main/startup/synthetic-title-runtime.ts new file mode 100644 index 00000000000..f8b02860bef --- /dev/null +++ b/src/main/startup/synthetic-title-runtime.ts @@ -0,0 +1,201 @@ +import { registerPaneKeyTeardownListener, getPtyIdForPaneKey } from '../ipc/pty' +import { agentHookServer } from '../agent-hooks/server' +import type { AgentStatusState } from '../../shared/agent-status-types' +import { + getSyntheticAgentTitleProfile, + shouldDriveSyntheticAgentTitleFromHook, + type SyntheticAgentTitleProfile +} from '../../shared/synthetic-agent-title' +import { + advanceSyntheticTitleSpinnerEntries, + getSyntheticTitleSpinnerPaneKeyToStop, + type SyntheticTitleSpinnerEntry +} from '../synthetic-title-spinner' +import { shouldSendSyntheticTitleFrame } from '../synthetic-title-visibility' +import { shouldCopySyntheticTitleFrameToPtyData } from '../synthetic-title-frame-routing' +import { resolveTuiAgentPermissionMode } from '../../shared/tui-agent-permissions' +import { mainProcessState as state } from './main-process-state' + +// Why: cursor-agent re-emits its own OSC title on every redraw, overwriting a one-shot frame — so re-assert a working frame on an interval. +// 80ms matches Pi's cadence (smooth but under the IPC budget). opencode needs only one frame but reuses this for consistent animated UX. +const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'] +const SPINNER_INTERVAL_MS = 80 +const syntheticTitleSpinnerByPaneKey = new Map< + string, + SyntheticTitleSpinnerEntry +>() +let syntheticTitleSpinnerTimer: ReturnType | null = null + +function isSyntheticTitleWindowVisible(): boolean { + const window = state.mainWindow + return window !== null && !window.isDestroyed() && window.isVisible() && !window.isMinimized() +} + +function sendSyntheticTitle(ptyId: string, data: string, options: { force?: boolean } = {}): void { + const window = state.mainWindow + if (!window || window.isDestroyed()) { + return + } + // Why: throttle decorative spinner frames (up to 80ms/agent); final/permission frames are forced because they drive BEL. + if ( + !shouldSendSyntheticTitleFrame({ + force: options.force === true, + windowVisible: isSyntheticTitleWindowVisible() + }) + ) { + return + } + // Why: feed the per-PTY tracker directly, never onPtyData — emulator/tails/transcripts/stats must not see fabricated bytes. + state.runtime?.ingestSyntheticTitleFrame(ptyId, data) + // Why: only the kill-switch-off renderer byte-parses synthetic frames; under main authority the copy mints phantom ACKs (see synthetic-title-frame-routing.ts). + if (shouldCopySyntheticTitleFrameToPtyData(state.store?.getSettings())) { + window.webContents.send('pty:data', { id: ptyId, data }) + } +} + +function canSendDecorativeSyntheticTitle(): boolean { + return shouldSendSyntheticTitleFrame({ + force: false, + windowVisible: isSyntheticTitleWindowVisible() + }) +} + +export function stopSyntheticTitleSpinner(paneKey: string): void { + if (syntheticTitleSpinnerByPaneKey.delete(paneKey)) { + stopSyntheticTitleSpinnerTimerIfIdle() + } +} + +export function stopAllSyntheticTitleSpinners(): void { + syntheticTitleSpinnerByPaneKey.clear() + stopSyntheticTitleSpinnerTimer() +} + +export function stopSyntheticTitleSpinnerTimer(): void { + if (syntheticTitleSpinnerTimer) { + clearInterval(syntheticTitleSpinnerTimer) + syntheticTitleSpinnerTimer = null + } +} + +function stopSyntheticTitleSpinnerTimerIfIdle(): void { + if (syntheticTitleSpinnerByPaneKey.size === 0) { + stopSyntheticTitleSpinnerTimer() + } +} + +function tickSyntheticTitleSpinners(): void { + if (!canSendDecorativeSyntheticTitle()) { + stopSyntheticTitleSpinnerTimer() + return + } + const ticks = advanceSyntheticTitleSpinnerEntries({ + entries: syntheticTitleSpinnerByPaneKey, + frameCount: SPINNER_FRAMES.length, + getPtyIdForPaneKey + }) + for (const tick of ticks) { + sendSyntheticTitle( + tick.ptyId, + `\x1b]0;${SPINNER_FRAMES[tick.frame]} ${tick.profile.workingLabel}\x07` + ) + } + stopSyntheticTitleSpinnerTimerIfIdle() +} + +function ensureSyntheticTitleSpinnerTimer(): void { + if ( + syntheticTitleSpinnerTimer || + syntheticTitleSpinnerByPaneKey.size === 0 || + !canSendDecorativeSyntheticTitle() + ) { + return + } + // Why: one shared timer for all spinners — per-pane intervals multiplied idle wakeups when several agents were working. + syntheticTitleSpinnerTimer = setInterval(tickSyntheticTitleSpinners, SPINNER_INTERVAL_MS) +} + +export function resumeSyntheticTitleSpinnerTimer(): void { + ensureSyntheticTitleSpinnerTimer() +} + +export function driveSyntheticTitleFromHook( + paneKey: string, + agentState: AgentStatusState, + profile: SyntheticAgentTitleProfile +): void { + const ptyId = getPtyIdForPaneKey(paneKey) + if (!ptyId) { + return + } + if (agentState === 'working') { + // Why: emit the first frame immediately so the spinner is visible now, not up to 80ms later at the next interval tick. + const existing = syntheticTitleSpinnerByPaneKey.get(paneKey) + const frame = existing ? existing.frame : 0 + sendSyntheticTitle(ptyId, `\x1b]0;${SPINNER_FRAMES[frame]} ${profile.workingLabel}\x07`) + if (existing) { + // Why: refresh the profile so a mid-pane agent-type change lands on the right idle/permission labels at terminal state. + existing.profile = profile + return + } + syntheticTitleSpinnerByPaneKey.set(paneKey, { frame, profile }) + ensureSyntheticTitleSpinnerTimer() + return + } + // Why: stop the spinner first so the next tick can't race the state back to "working", then inject the terminal frame. + // Permission frames add a trailing BEL to light up user-input states; done frames omit it (completion notifications own that attention). + stopSyntheticTitleSpinner(paneKey) + const needsUserInput = agentState === 'blocked' || agentState === 'waiting' + const label = needsUserInput ? profile.permissionLabel : profile.idleLabel + sendSyntheticTitle(ptyId, `\x1b]0;${label}\x07${needsUserInput ? '\x07' : ''}`, { force: true }) +} + +export function shouldSuppressCodexAutoApprovalSyntheticTitleFromHook(args: { + agentType: string | null | undefined + state: AgentStatusState + launchConfig: + | { agentArgs?: string | null; agentEnv?: Record | null } + | null + | undefined +}): boolean { + if (args.agentType !== 'codex' || (args.state !== 'waiting' && args.state !== 'blocked')) { + return false + } + if (!args.launchConfig) { + return false + } + return ( + resolveTuiAgentPermissionMode({ + agent: 'codex', + agentArgs: args.launchConfig.agentArgs, + agentEnv: args.launchConfig.agentEnv + }) === 'yolo' + ) +} + +export function initializeSyntheticTitleRuntime(): void { + // Why: on PTY teardown drop the spinner entry explicitly, else the shared timer keeps ticking with sendSyntheticTitle no-oping forever. + registerPaneKeyTeardownListener((paneKey) => stopSyntheticTitleSpinner(paneKey)) + // Why: the spinner is a stand-in for a live hook status, so it must retire with the row it + // stands in for — otherwise a pane whose status was cleared or dismissed keeps rotating a + // working title long after the agent finished (#13890). Both paths are covered: the + // pane-scoped clear fan-out, and user dismissal, which never routes through it. + agentHookServer.subscribePaneStatusClear((clear) => { + const paneKey = getSyntheticTitleSpinnerPaneKeyToStop(clear) + if (paneKey) { + stopSyntheticTitleSpinner(paneKey) + } + }) + agentHookServer.subscribeStatusDrop(stopSyntheticTitleSpinner) +} + +export function driveSyntheticTitleForAgentStatus( + paneKey: string, + agentType: string | null | undefined, + agentState: AgentStatusState +): void { + const profile = getSyntheticAgentTitleProfile(agentType) + if (profile && shouldDriveSyntheticAgentTitleFromHook(agentType, agentState)) { + driveSyntheticTitleFromHook(paneKey, agentState, profile) + } +} diff --git a/src/main/startup/web-contents-timed-flag.ts b/src/main/startup/web-contents-timed-flag.ts new file mode 100644 index 00000000000..b3ac602f98e --- /dev/null +++ b/src/main/startup/web-contents-timed-flag.ts @@ -0,0 +1,31 @@ +// Why: webContents-scoped auto-expiring flag so an intent can't leak to a later renderer load; `consume` clears on match for one-shot signals. +export function createWebContentsTimedFlag(defaultDurationMs = 10_000): { + mark: (webContentsId: number, durationMs?: number) => void + clear: (webContentsId?: number) => void + matches: (webContentsId: number, options?: { consume?: boolean }) => boolean +} { + let state: { webContentsId: number; until: number } | null = null + return { + mark(webContentsId, durationMs = defaultDurationMs) { + state = { webContentsId, until: Date.now() + durationMs } + }, + clear(webContentsId) { + if (webContentsId === undefined || state?.webContentsId === webContentsId) { + state = null + } + }, + matches(webContentsId, options) { + if (!state || Date.now() > state.until) { + state = null + return false + } + if (state.webContentsId !== webContentsId) { + return false + } + if (options?.consume) { + state = null + } + return true + } + } +} diff --git a/src/main/updater.ts b/src/main/updater.ts index 912c681cfad..e22e18e84ba 100644 --- a/src/main/updater.ts +++ b/src/main/updater.ts @@ -1,11 +1,7 @@ -/* eslint-disable max-lines */ -import { app, BrowserWindow, powerMonitor } from 'electron' -import { is } from '@electron-toolkit/utils' +import type { BrowserWindow } from 'electron' import type { LinuxPackageInstallInstructions, - LinuxPackageInstallRecovery, UpdateCheckOptions, - UpdateSource, UpdateStatus } from '../shared/update-status-types' import type { @@ -13,2341 +9,86 @@ import type { RemoteServerUpdaterSnapshot, RemoteServerUpdateSupport } from '../shared/remote-server-update' -import { - isWindowsSignatureCheckUnavailableFailure, - isWindowsSignatureMismatchFailure -} from '../shared/updater-windows-signature-check' -import { killAllPty } from './ipc/pty' -import { withUpdaterSpan } from './observability/instrumentation' -import { loadElectronAutoUpdater, type ElectronAutoUpdater } from './electron-updater-loader' -import { writeMainThreadDiagnosticMarker } from './diagnostics/main-thread-churn-probe' -import { runWithLaunchPath } from './startup/hydrate-shell-path' -import { - beginMacUpdateDownload, - deferMacQuitUntilInstallerReady, - isMacInstallerReady, - markMacQuitAndInstallInFlight, - resetMacInstallState -} from './updater-mac-install' -import { - armUpdateInstallExitWatchdog, - disarmUpdateInstallExitWatchdog -} from './update-install-exit-watchdog' -import { registerAutoUpdaterHandlers } from './updater-events' -import { recordUpdaterLifecycle } from './updater-lifecycle-diagnostics' -import { getLinuxRootPackageType } from './linux-update-package-type' -import { - beginLinuxPackageInstallDiagnosticCapture, - createUpdaterDiagnosticLogger, - endLinuxPackageInstallDiagnosticCapture, - getLinuxPackageInstallDiagnostic, - parseLinuxPackageInstallExitCode, - redactLinuxPackageInstallText, - type LinuxPackageInstallDiagnostic -} from './linux-package-install-diagnostic' -import { - clearTrackedLinuxPackageArtifact, - getTrackedLinuxPackageArtifact, - resolveLinuxPackageInstallInstructions, - revalidateLinuxPackageForInstall, - revealLinuxPackage, - type LinuxPackageArtifact, - type LinuxPackageRecoveryUnavailableReason -} from './linux-package-update-recovery' -import { - compareVersions, - isBenignCheckFailure, - isMissingUpdateManifestFailure, - isPrereleaseVersion, - statusesEqual -} from './updater-fallback' -import { - fetchNewerReleaseTagsWithReadiness, - getReleaseDownloadUrl -} from './updater-prerelease-feed' -import { fetchNudge, shouldApplyNudge } from './updater-nudge' -import { - failServeUpdateHandoff, - getServeUpdateHandoffFailure, - hasServeUpdateSupervisor, - requestServeUpdateHandoff -} from './serve-update-handoff' -import type { LocalBuildFeed } from './local-builds/local-build-feed-server' -import { listReleaseBuilds, resolveTargetBuild } from './updater-release-builds' -import { - DEV_CHANNEL_PLATFORM_LABEL, - getVersionChannel, - hasDedicatedReleaseRepo, - isChannelSupportedOnPlatform, - RELEASE_CHANNEL_LABELS, - requiresManualDevChannelInstall, - type ReleaseBuild, - type ReleaseChannel -} from '../shared/release-channel' +import type { ReleaseBuild, ReleaseChannel } from '../shared/release-channel' +import { UpdaterSetup, type UpdaterSetupOptions } from './updater/updater-setup' +import type { UpdateInstallMode } from './updater/updater-state' -type CheckFailureSource = 'event' | 'promise' | 'fallback-promise' -type MissingManifestPrereleaseFallbackResult = { userInitiated: boolean } -type PrimaryEventSuppression = { failureKey: string; error: unknown } -type UpdateCheckVariant = 'default' | 'prerelease' | 'perf' -type ReleaseFeedPreflightFailure = 'manifest-unavailable' | 'release-not-ready' -// Why: expected preflight outcomes need typed context so UI routing never depends on matching error text. -class ReleaseFeedPreflightError extends Error { - constructor( - readonly reason: ReleaseFeedPreflightFailure, - readonly releaseChannel: UpdateCheckVariant, - message: string - ) { - super(message) - this.name = 'ReleaseFeedPreflightError' - } -} -type ReleaseFeedPreflightResult = 'ready' | 'not-available' -export type UpdateInstallMode = - | 'interactive' - | 'supervised-headless-serve' - | 'unsupported-headless-serve' +// Keep one service instance so all public API calls share updater state and event listeners. +const updater = new UpdaterSetup() -const AUTO_UPDATE_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000 -const AUTO_UPDATE_RETRY_INTERVAL_MS = 60 * 60 * 1000 -// Why: a persistently-failing feed used to re-arm the retry at a fixed 1h cadence forever (issue #7576); backoff doubles per failure up to this cap, any completed check resets. -const MAX_AUTO_UPDATE_RETRY_INTERVAL_MS = 6 * 60 * 60 * 1000 -const NUDGE_POLL_INTERVAL_MS = 30 * 60 * 1000 -const NUDGE_ACTIVATION_COOLDOWN_MS = 5 * 60 * 1000 -const QUIT_AND_INSTALL_DELAY_MS = 100 -const PRE_QUIT_CLEANUP_TIMEOUT_MS = 2_500 -const UPDATE_CHECK_SILENT_SETTLE_DELAY_MS = 1_000 -const UPDATE_CHECK_STALL_TIMEOUT_MS = 45_000 - -let mainWindowRef: BrowserWindow | null = null -let currentStatus: UpdateStatus = { state: 'idle' } -let userInitiatedCheck = false -let onBeforeQuitCleanup: (() => void | Promise) | null = null -let autoUpdaterInitialized = false -// Why: modifier-clicking "Check for Updates" targets prerelease manifests; the feed still pins a concrete tag so cancelled prereleases without manifests are skipped. -let includePrereleaseActive = false -let availableVersion: string | null = null -let availableReleaseUrl: string | null = null -let pendingCheckFailureKey: string | null = null -let pendingCheckFailurePromise: Promise | null = null -let autoUpdateCheckTimer: ReturnType | null = null -let nudgeCheckTimer: ReturnType | null = null -let pendingQuitAndInstallTimer: ReturnType | null = null -let quitAndInstallInProgress = false -// Why: the pre-install digest re-proof streams the whole package, so a second install request can -// arrive while it runs — after the quit timer was cleared but before the handoff owns the process. -let linuxPackageRevalidationInFlight = false -let updateInstallMode: UpdateInstallMode = 'interactive' -let lastInstallDeferralVersion = { download: null as string | null, install: null as string | null } -// Why: once install has committed, late 'error' events must not clear quittingForUpdate — that would re-enable dock activate mid-installer. -let updateInstallCommitted = false -// Why: recovery must only run after the native quitAndInstall call; pre-native errors must not clear quittingForUpdate or look like install recovery. -let quitAndInstallNativeInvoked = false -// Why: a synchronous throw out of quitAndInstall ends diagnostic capture before the catch runs, so stash the redacted text for it. -let lastInstallAttemptDiagnostic: LinuxPackageInstallDiagnostic | null = null -let persistLastUpdateCheckAt: ((timestamp: number) => void) | null = null -let _getLastUpdateCheckAt: (() => number | null) | null = null -let backgroundCheckLaunchPending = false -// Why: a promoted background check can emit an error event before its promise catch runs; keep the promotion attached to that launch. -let backgroundCheckPromotedToUserInitiated = false -let updateCheckStallTimer: ReturnType | null = null -let updateCheckSilentSettleTimer: ReturnType | null = null -let updateCheckAttemptSequence = 0 -let activeUpdateCheckAttemptId: number | null = null -let activeUpdateCheckLaunchAttemptId: number | null = null -let activeUpdateCheckEventAttemptId: number | null = null -let updateAvailableEventPendingAttemptId: number | null = null -let pendingUserInitiatedCheckAfterInFlight: UpdateCheckVariant | null = null -let activeUpdateNudgeId: string | null = null -let awaitingNudgeCheckOutcome = false -let nudgeCheckInFlight = false -let lastNudgeCheckAt = 0 -let publishingWindowLastGoodCheck: { lastGoodTag: string } | null = null -let pendingPrereleaseFallback: { - primaryTag: string - fallbackTag: string - // Why: primary promise cleanup can run after fallback starts; fallback events need this attempt-scoped state, not the mutable global. - userInitiated: boolean - suppressedPrimaryPromiseFailureKey: string | null - suppressedPrimaryEventFailure: PrimaryEventSuppression | null - suppressedFallbackPromiseFailureKey: string | null - suppressedFallbackEventFailureKey: string | null - fallbackResultHandled: boolean - fallbackCheckingForUpdateSeen: boolean - retryLaunched: boolean -} | null = null - -let _getPendingUpdateNudgeId: (() => string | null) | null = null -let _getDismissedUpdateNudgeId: (() => string | null) | null = null -let _setPendingUpdateNudgeId: ((id: string | null) => void) | null = null -let _setDismissedUpdateNudgeId: ((id: string | null) => void) | null = null -// Why: guards against duplicate download() calls while an accepted request transitions status to 'downloading'. -let downloadInFlight = false -/** Guards the macOS `activate` handler from reopening the old version while ShipIt replaces the .app bundle. */ -let quittingForUpdate = false -let autoUpdater: ElectronAutoUpdater | null = null -let activeUpdateSource: 'release' | UpdateSource = 'release' -let activeLocalBuildFeed: LocalBuildFeed | null = null -let localBuildSelectionInProgress = false -// Why: a dev channel/tag jump may target an older build, so it needs allowDowngrade -// like local builds — but off a real release feed, not a loopback server. -let pinnedBuildSelectionInProgress = false -// Why: a pinned jump to a stable/rc tag keeps the 'release' source but is still a -// deliberate downgrade, so newer-only gates must yield to it too. -let isPinnedBuildActive = false -let getReleaseChannelOverride: (() => ReleaseChannel | null) | null = null - -function getAutoUpdater(): ElectronAutoUpdater { - if (!autoUpdater) { - autoUpdater = loadElectronAutoUpdater() - } - return autoUpdater -} - -function clearAvailableUpdateContext(): void { - availableVersion = null - availableReleaseUrl = null -} - -function closeLocalBuildFeed(): void { - const feed = activeLocalBuildFeed - activeLocalBuildFeed = null - if (feed) { - void feed.close() - } -} - -function restoreReleaseUpdateSource(): void { - closeLocalBuildFeed() - activeUpdateSource = 'release' - isPinnedBuildActive = false - if (autoUpdater) { - autoUpdater.allowDowngrade = false - autoUpdater.disableDifferentialDownload = false - // Why: a pinned jump forces allowPrerelease on; leaving it set would opt - // every later background check into the RC channel behind the user's back. - autoUpdater.allowPrerelease = includePrereleaseActive - } -} - -function sendLocalBuildErrorAndRestore(message: string, userInitiated?: boolean): void { - clearAvailableUpdateContext() - if ( - currentStatus.state !== 'error' || - currentStatus.message !== message || - currentStatus.userInitiated !== userInitiated || - currentStatus.source !== 'local' - ) { - sendStatus({ state: 'error', message, userInitiated, source: 'local' }) - } - restoreReleaseUpdateSource() -} - -function clearPrereleaseFallbackContext(): void { - pendingPrereleaseFallback = null -} - -function clearPendingUpdateNudge(): void { - activeUpdateNudgeId = null - awaitingNudgeCheckOutcome = false - _setPendingUpdateNudgeId?.(null) -} - -function deferPendingUpdateNudgeUntilRetry(): void { - activeUpdateNudgeId = null - awaitingNudgeCheckOutcome = false -} - -function clearPublishingWindowLastGoodCheck(): void { - publishingWindowLastGoodCheck = null -} - -function getPublishingWindowLastGoodCheck(): { lastGoodTag: string } | null { - return publishingWindowLastGoodCheck -} - -function getPersistedPendingUpdateNudgeId(): string | null { - return _getPendingUpdateNudgeId?.() ?? null -} - -function decorateStatusWithActiveNudge(status: UpdateStatus): UpdateStatus { - // Why: only actionable/error states carry the nudge marker so the renderer knows a dismiss should ack the campaign; cycle-boundary states never need it. - if (!activeUpdateNudgeId) { - return status - } - if (status.state === 'idle' || status.state === 'checking' || status.state === 'not-available') { - return status - } - return { ...status, activeNudgeId: activeUpdateNudgeId } -} - -/** `force` re-delivers a status the renderer must not miss even when it repeats the current one. */ -function sendStatus(status: UpdateStatus, options?: { force?: boolean }): void { - const pendingUserInitiatedCheckVariant = pendingUserInitiatedCheckAfterInFlight - const shouldLaunchPendingUserInitiatedCheck = - pendingUserInitiatedCheckVariant !== null && - (status.state === 'idle' || - status.state === 'not-available' || - status.state === 'available' || - status.state === 'error') - const shouldPreserveNudgeForPublishingWindow = - publishingWindowLastGoodCheck !== null && - (status.state === 'idle' || - status.state === 'not-available' || - status.state === 'available' || - status.state === 'error') - if (awaitingNudgeCheckOutcome) { - if (status.state === 'available') { - if (shouldPreserveNudgeForPublishingWindow) { - // Why: a last-good available update is only a temporary fallback; dismissing it must not consume the newest-release nudge campaign. - deferPendingUpdateNudgeUntilRetry() - } else { - awaitingNudgeCheckOutcome = false - } - } else if ( - status.state === 'idle' || - status.state === 'not-available' || - status.state === 'error' - ) { - if (shouldPreserveNudgeForPublishingWindow) { - // Why: last-good checks can say "not available" while the campaign's newest release is still publishing. - deferPendingUpdateNudgeUntilRetry() - } else { - // Why: on no-update, mark the campaign dismissed so a nudge covering already-up-to-date users doesn't re-fire every 30-min poll. - if (activeUpdateNudgeId) { - _setDismissedUpdateNudgeId?.(activeUpdateNudgeId) - } - clearPendingUpdateNudge() - } - } - } - - const sourcedStatus: UpdateStatus = - activeUpdateSource === 'release' ? status : { ...status, source: activeUpdateSource } - const decoratedStatus = decorateStatusWithActiveNudge(sourcedStatus) - - if (isUpdateCheckResultState(status.state)) { - finishActiveUpdateCheckAttempt() - } - - if ( - status.state === 'idle' || - status.state === 'not-available' || - status.state === 'available' || - status.state === 'error' - ) { - clearPublishingWindowLastGoodCheck() - } - - // Why: reset the in-flight guard once status moves past the window where duplicate download() calls are possible. - if ( - decoratedStatus.state === 'downloading' || - decoratedStatus.state === 'error' || - decoratedStatus.state === 'idle' - ) { - downloadInFlight = false - } - if (shouldLaunchPendingUserInitiatedCheck) { - // Why: a forced status must still land before the queued check restarts the cycle. - if (options?.force) { - currentStatus = decoratedStatus - mainWindowRef?.webContents.send('updater:status', decoratedStatus) - } - launchPendingUserInitiatedCheckAfterInFlight(pendingUserInitiatedCheckVariant) - return - } - if (!options?.force && statusesEqual(currentStatus, decoratedStatus)) { - return - } - currentStatus = decoratedStatus - mainWindowRef?.webContents.send('updater:status', decoratedStatus) -} - -function getOptionsForUpdateCheckVariant(variant: UpdateCheckVariant): UpdateCheckOptions { - switch (variant) { - case 'perf': - return { includePrerelease: true, includePerfPrerelease: true } - case 'prerelease': - return { includePrerelease: true } - case 'default': - return { includePrerelease: false } - } -} - -function getUpdateCheckVariant(options?: UpdateCheckOptions): UpdateCheckVariant { - if (options?.includePerfPrerelease) { - return 'perf' - } - if (options?.includePrerelease) { - return 'prerelease' - } - // Why: a persisted 'rc' override makes every routine check follow the RC series - // without the user re-holding shift; the dev channels need an explicit tag, so - // neither is a routine-check variant. - if (getReleaseChannelOverride?.() === 'rc') { - return 'prerelease' - } - return 'default' -} - -function launchPendingUserInitiatedCheckAfterInFlight(variant: UpdateCheckVariant): void { - pendingUserInitiatedCheckAfterInFlight = null - setTimeout(() => { - // Why: defer one tick after electron-updater clears its in-flight promise so the queued modifier check starts fresh instead of deduping into the stable one. - if (currentStatus.state === 'checking') { - currentStatus = { state: 'idle' } - } - checkForUpdatesFromMenu(getOptionsForUpdateCheckVariant(variant)) - }, 0) -} - -function clearBackgroundCheckLaunchPending(): void { - backgroundCheckLaunchPending = false -} - -function clearUpdateCheckStallTimer(): void { - if (!updateCheckStallTimer) { - return - } - clearTimeout(updateCheckStallTimer) - updateCheckStallTimer = null -} - -function clearUpdateCheckSilentSettleTimer(): void { - if (!updateCheckSilentSettleTimer) { - return - } - clearTimeout(updateCheckSilentSettleTimer) - updateCheckSilentSettleTimer = null -} - -function clearUpdateCheckTimers(): void { - clearUpdateCheckStallTimer() - clearUpdateCheckSilentSettleTimer() -} - -function finishActiveUpdateCheckAttempt(): void { - activeUpdateCheckAttemptId = null - activeUpdateCheckLaunchAttemptId = null - activeUpdateCheckEventAttemptId = null - clearUpdateCheckTimers() -} - -function getActiveUpdateCheckEventAttemptId(): number | null { - if (activeUpdateCheckAttemptId === null) { - return null - } - if (activeUpdateCheckEventAttemptId !== activeUpdateCheckAttemptId) { - return null - } - return activeUpdateCheckAttemptId -} - -function isActiveUpdateCheckAttempt(attemptId: number): boolean { - return activeUpdateCheckAttemptId === attemptId -} - -function markUpdateCheckEventAttempt(): boolean { - if (activeUpdateCheckAttemptId === null) { - return false - } - if (activeUpdateCheckLaunchAttemptId !== activeUpdateCheckAttemptId) { - return false - } - activeUpdateCheckEventAttemptId = activeUpdateCheckAttemptId - return true -} - -function markUpdateCheckLaunched(attemptId: number): void { - if (!isActiveUpdateCheckAttempt(attemptId)) { - return - } - activeUpdateCheckLaunchAttemptId = attemptId -} - -function markUpdateAvailableEventPending(attemptId: number | null): void { - updateAvailableEventPendingAttemptId = attemptId -} - -function clearUpdateAvailableEventPending(attemptId: number | null): void { - if (updateAvailableEventPendingAttemptId !== attemptId) { - return - } - updateAvailableEventPendingAttemptId = null -} - -function armUpdateCheckStallTimer(attemptId: number): void { - clearUpdateCheckStallTimer() - updateCheckStallTimer = setTimeout(() => { - updateCheckStallTimer = null - if (!isActiveUpdateCheckAttempt(attemptId)) { - return - } - const wasUserInitiated = getSettledCheckUserInitiated() - if (currentStatus.state === 'checking') { - finishActiveUpdateCheckAttempt() - backgroundCheckLaunchPending = false - backgroundCheckPromotedToUserInitiated = false - userInitiatedCheck = false - void sendCheckFailureStatus( - 'Update check timed out. Try again in a few minutes.', - wasUserInitiated, - 'promise' - ) - return - } - if (backgroundCheckLaunchPending) { - finishActiveUpdateCheckAttempt() - backgroundCheckLaunchPending = false - backgroundCheckPromotedToUserInitiated = false - userInitiatedCheck = false - scheduleAutomaticUpdateCheck(AUTO_UPDATE_RETRY_INTERVAL_MS) - } - }, UPDATE_CHECK_STALL_TIMEOUT_MS) -} - -function beginUpdateCheckAttempt(): number { - finishActiveUpdateCheckAttempt() - updateAvailableEventPendingAttemptId = null - updateCheckAttemptSequence += 1 - activeUpdateCheckAttemptId = updateCheckAttemptSequence - armUpdateCheckStallTimer(activeUpdateCheckAttemptId) - // Why: issue #7576 warnings recurred at retry cadence; timestamp each attempt to confirm or rule out the updater. - writeMainThreadDiagnosticMarker('updater-check-attempt') - return activeUpdateCheckAttemptId -} - -function rearmActiveUpdateCheckStallTimer(): void { - if (activeUpdateCheckAttemptId === null) { - return - } - armUpdateCheckStallTimer(activeUpdateCheckAttemptId) -} - -function getSettledCheckUserInitiated(): boolean | undefined { - return userInitiatedCheck || backgroundCheckPromotedToUserInitiated || undefined -} - -function isUpdateCheckResultState(state: UpdateStatus['state']): boolean { - return ( - state === 'idle' || - state === 'not-available' || - state === 'available' || - state === 'error' || - state === 'downloading' || - state === 'downloaded' - ) -} - -function consumeSilentCheckShortRetryReason(): boolean { - if (publishingWindowLastGoodCheck !== null) { - return true - } - return consumeMissingManifestPrereleaseFallbackResult() !== null -} - -function completeSilentUpdateCheck(userInitiated: boolean | undefined): boolean { - const shouldRetrySoon = consumeSilentCheckShortRetryReason() - clearAvailableUpdateContext() - if (shouldRetrySoon) { - // Why: a silent result against a temporary last-good feed is still a release transition, so it must not suppress the short publish retry. - scheduleAutomaticUpdateCheck(AUTO_UPDATE_RETRY_INTERVAL_MS) - return true - } - recordCompletedUpdateCheck() - if (!userInitiated) { - scheduleAutomaticUpdateCheck(AUTO_UPDATE_CHECK_INTERVAL_MS) - } - return false -} - -function settleSilentUpdateCheck(attemptId: number, userInitiated: boolean | undefined): void { - if (!isActiveUpdateCheckAttempt(attemptId)) { - return - } - if (updateAvailableEventPendingAttemptId === attemptId) { - return - } - if (currentStatus.state !== 'checking') { - if (backgroundCheckLaunchPending) { - finishActiveUpdateCheckAttempt() - clearBackgroundCheckLaunchPending() - backgroundCheckPromotedToUserInitiated = false - userInitiatedCheck = false - const shouldRetrySoon = completeSilentUpdateCheck(userInitiated) - if (awaitingNudgeCheckOutcome) { - if (shouldRetrySoon) { - deferPendingUpdateNudgeUntilRetry() - return - } - sendStatus({ state: 'not-available', userInitiated }) - } - } - return - } - finishActiveUpdateCheckAttempt() - clearBackgroundCheckLaunchPending() - backgroundCheckPromotedToUserInitiated = false - userInitiatedCheck = false - completeSilentUpdateCheck(userInitiated) - sendStatus({ state: 'not-available', userInitiated }) -} - -function handleSettledUpdateCheckPromise(attemptId: number): void { - if (!isActiveUpdateCheckAttempt(attemptId)) { - return - } - clearUpdateCheckSilentSettleTimer() - // Why: electron-updater can resolve before the terminal event arrives; grace-period it, then unstick checks that resolved without one. - updateCheckSilentSettleTimer = setTimeout(() => { - updateCheckSilentSettleTimer = null - settleSilentUpdateCheck(attemptId, getSettledCheckUserInitiated()) - }, UPDATE_CHECK_SILENT_SETTLE_DELAY_MS) -} - -function shouldHandleUpdaterErrorEvent(): boolean { - if (getActiveUpdateCheckEventAttemptId() !== null) { - return true - } - // Why: electron-updater emits check errors globally; once a check settles, only active download/install flows should consume them. - return ( - downloadInFlight || - currentStatus.state === 'downloading' || - currentStatus.state === 'downloaded' - ) -} - -function sendErrorStatus(message: string, userInitiated?: boolean): void { - if ( - currentStatus.state === 'error' && - currentStatus.message === message && - currentStatus.userInitiated === userInitiated - ) { - return - } - // Why: count AV/EDR-blocked Windows signature checks in the field to size the affected cohort before bigger updater changes. - if (isWindowsSignatureCheckUnavailableFailure(message)) { - recordUpdaterLifecycle('windows_signature_check_blocked', undefined, { - level: 'warn', - message: 'Windows update signature check could not run' - }) - } - sendStatus({ state: 'error', message, userInitiated }) -} - -function getKnownReleaseUrl(): string | undefined { - return availableReleaseUrl ?? undefined -} - -function hasInstallableDownloadedVersion(): boolean { - return ( - availableVersion !== null && - // Why: local builds and pinned dev jumps may intentionally move backwards. - (activeUpdateSource !== 'release' || - isPinnedBuildActive || - compareVersions(availableVersion, app.getVersion()) > 0) - ) -} - -function getPendingInstallVersion(): string { - if (availableVersion) { - return availableVersion - } - if (currentStatus.state === 'downloading' || currentStatus.state === 'downloaded') { - return currentStatus.version - } - return '' -} - -function deferHeadlessServeInstall(phase: 'download' | 'install', version: string): boolean { - if (updateInstallMode !== 'unsupported-headless-serve') { - return false - } - const diagnosticVersion = version || 'unknown' - if (lastInstallDeferralVersion[phase] !== diagnosticVersion) { - lastInstallDeferralVersion[phase] = diagnosticVersion - recordUpdaterLifecycle( - 'headless_serve_install_deferred', - { phase, version: version || null }, - { - level: 'warn', - message: 'Update install deferred while hosting orca serve' - } - ) - } - sendErrorStatus( - 'This orca serve process was not started by an update-capable supervisor. Keep it running and update Orca through its service manager.', - true - ) - return true -} +export type { UpdateInstallMode, UpdaterSetupOptions } export function resolveUpdateInstallMode(isServeMode: boolean): UpdateInstallMode { - if (!isServeMode) { - return 'interactive' - } - return hasServeUpdateSupervisor() ? 'supervised-headless-serve' : 'unsupported-headless-serve' -} - -function getCheckFailureKey(message: string, userInitiated?: boolean): string { - return `${userInitiated ? 'user' : 'auto'}:${message}` -} - -function clearPrereleaseFallbackContextIfSettled(): void { - if ( - pendingPrereleaseFallback?.fallbackResultHandled && - !pendingPrereleaseFallback.suppressedPrimaryPromiseFailureKey && - !pendingPrereleaseFallback.suppressedPrimaryEventFailure && - !pendingPrereleaseFallback.suppressedFallbackPromiseFailureKey && - !pendingPrereleaseFallback.suppressedFallbackEventFailureKey - ) { - clearPrereleaseFallbackContext() - } -} - -async function performQuitAndInstall(): Promise { - if (quitAndInstallInProgress || linuxPackageRevalidationInFlight) { - recordUpdaterLifecycle('quit_and_install_ignored', { reason: 'already-in-progress' }) - return - } - - if (pendingQuitAndInstallTimer) { - clearTimeout(pendingQuitAndInstallTimer) - pendingQuitAndInstallTimer = null - } - - const pendingVersion = getPendingInstallVersion() - if (deferHeadlessServeInstall('install', pendingVersion)) { - return - } - // Why: the retained .deb/.rpm sits on a user-writable path that a root package manager is about - // to read, and nothing re-checks it after download. Re-prove it here — before any teardown — so a - // swapped or vanished package aborts instead of being installed as root. The synchronous guard - // keeps every non-Linux install on its existing timing. - if (getTrackedLinuxPackageArtifact() && !(await proveRetainedLinuxPackage(pendingVersion))) { - // Why: the renderer armed its restart before invoking, and it infers the abort from the error - // status — which a stale-cycle verdict deliberately withholds. Signal the abandon here, where - // it cannot depend on that decision, or the window keeps skipping its unsaved-work prompt. - mainWindowRef?.webContents.send('updater:quitAndInstallAborted') - return - } - quitAndInstallInProgress = true - - markMacQuitAndInstallInFlight() - - // Set BEFORE anything else so the `activate` handler doesn't reopen the old version while ShipIt replaces the .app bundle. - quittingForUpdate = true - - try { - await withUpdaterSpan({ stage: 'install' }, async (span) => { - span.setAttribute('updater.version', pendingVersion || 'unknown') - span.setAttribute('updater.platform', process.platform) - span.setAttribute( - 'updater.macosInstallerReady', - process.platform === 'darwin' ? isMacInstallerReady() : true - ) - recordUpdaterLifecycle('quit_and_install_started', { - version: pendingVersion || null, - macInstallerReady: process.platform === 'darwin' ? isMacInstallerReady() : true - }) - span.addEvent('pre_quit_cleanup_start') - await runBeforeUpdateQuitCleanup() - span.addEvent('pre_quit_cleanup_done') - - if ( - updateInstallMode === 'supervised-headless-serve' && - !requestServeUpdateHandoff(pendingVersion) - ) { - recordUpdaterLifecycle( - 'headless_serve_handoff_failed', - { version: pendingVersion || null }, - { - level: 'warn', - message: 'Could not persist supervised serve update handoff' - } - ) - sendErrorStatus( - 'Could not prepare the supervised server restart. Orca remains running.', - true - ) - resetQuitForUpdateState() - // Why: a bare return would exit this span Success and hide the aborted install from tracing. - span.fail('Could not persist the supervised serve update handoff') - return - } - - recordUpdaterLifecycle('quit_and_install_invoking_native', { - version: pendingVersion || null - }) - // Why: defensive — never call quitAndInstall if recovery/reset already cleared the handoff. - if (!quitAndInstallInProgress) { - return - } - // Why: mark before the call so a sync 'error' during quitAndInstall can recover; pre-native errors must not look like install failure. - quitAndInstallNativeInvoked = true - // Why: invoke before killAllPty/removing close listeners so a sync 'error' (the "no filepath" path) can recover while windows and PTYs are intact. - const supervisorOwnsRelaunch = updateInstallMode === 'supervised-headless-serve' - // Why: BaseUpdater logs child stderr but drops it from the 'error' event, so retain it for the span of this call. - beginLinuxPackageInstallDiagnosticCapture(getTrackedLinuxPackageArtifact()?.path ?? null) - try { - runWithLaunchPath(() => - getAutoUpdater().quitAndInstall(supervisorOwnsRelaunch, !supervisorOwnsRelaunch) - ) - } finally { - const diagnostic = endLinuxPackageInstallDiagnosticCapture() - // Why: a synchronous 'error' already consumed and reset this attempt; re-stashing would leak it into the next one. - lastInstallAttemptDiagnostic = quitAndInstallInProgress ? diagnostic : null - } - span.addEvent('native_quit_and_install_invoked') - - // Why: quitAndInstall can synchronously clear quitAndInstallInProgress via recovery (Win/Linux dispatchError); skip destructive prep if it already ran. - if (!quitAndInstallInProgress) { - // Why: recovery already wrote the reason to currentStatus; a bare return would exit this span Success. - span.fail( - currentStatus.state === 'error' - ? currentStatus.message - : 'quitAndInstall returned without invoking the installer' - ) - return - } - - // Why: DebUpdater/RpmUpdater install through spawnSync, so a normal return already means the - // package is installed. Commit here or a throw in the cleanup below is reported as an install - // failure — offering a recovery card, and stale stderr, for an update that actually succeeded. - if (getLinuxRootPackageType() !== null) { - updateInstallCommitted = true - armUpdateInstallExitWatchdog() - } - - killAllPty() - span.addEvent('local_pty_kill_all') - - for (const win of BrowserWindow.getAllWindows()) { - win.removeAllListeners('close') - } - span.addEvent('window_close_listeners_removed', { - windowCount: BrowserWindow.getAllWindows().length - }) - - // Why: committed installs keep quittingForUpdate so dock activate can't reopen the old process; macOS without Squirrel stays uncommitted so late native errors can still recover. - if (!updateInstallCommitted && (process.platform !== 'darwin' || isMacInstallerReady())) { - updateInstallCommitted = true - // Why: past commit the installer waits for this process to exit; a wedged async shutdown would strand the user with no app and no update (#4438). - armUpdateInstallExitWatchdog() - } - }) - } catch (error) { - // Why: on Linux the package is already installed once quitAndInstall returns, and the installer is - // waiting for this process to exit. Tearing down here would disarm the exit watchdog (#4438), clear - // quittingForUpdate mid-quit, and tell the user an install failed that actually succeeded. - if (updateInstallCommitted) { - recordUpdaterLifecycle( - 'post_commit_cleanup_failed', - { errorType: error instanceof Error ? error.name : typeof error }, - { - level: 'warn', - message: 'Update install cleanup failed after commit; install already applied' - } - ) - return - } - // Why: a pre-native cleanup/tracing exception is not a package install failure and must not be labelled as one. - const quitAndInstallNativeInvokedBeforeReset = quitAndInstallNativeInvoked - const recoveryStatus = - quitAndInstallNativeInvokedBeforeReset && !updateInstallCommitted - ? buildLinuxPackageInstallFailureStatus(error) - : null - failServeUpdateHandoff('Could not invoke the native updater.') - resetQuitForUpdateState() - recordUpdaterLifecycle( - 'quit_and_install_failed', - { errorType: error instanceof Error ? error.name : typeof error }, - { - level: 'warn', - message: 'Could not start update install' - } - ) - sendInstallFailureStatus( - recoveryStatus ?? { - state: 'error', - // Why: past the native invoke this is the same pre-commit failure the event path reports, so it gets the same copy; only a pre-native exception can be helped by a restart. - // A synchronous throw out of quitAndInstall carries the same installer text the 'error' event would have. - message: quitAndInstallNativeInvokedBeforeReset - ? withInstallFailureCause(getPreCommitInstallFailureMessage(), error) - : 'Could not restart to install the update. Quit and reopen Orca, then try again.' - } - ) - } -} - -function resetQuitForUpdateState(): void { - quitAndInstallInProgress = false - quittingForUpdate = false - updateInstallCommitted = false - quitAndInstallNativeInvoked = false - lastInstallAttemptDiagnostic = null - disarmUpdateInstallExitWatchdog() - resetMacInstallState() -} - -/** - * On macOS a pre-commit failure means Squirrel rejected the staged update, and quitting does re-stage - * it — so keep that advice there. Everywhere else a restart is not known to help. - */ -function getPreCommitInstallFailureMessage(): string { - return process.platform === 'darwin' - ? 'Could not restart to install the update. Quit and reopen Orca, then try again.' - : 'Could not start the update installer. Orca remains open.' -} - -/** - * Sends an install-failure status even when it repeats the current one. "Try Automatic Install - * Again" usually fails identically, and a deduped status would never reach the preload abort relay, - * leaving the renderer stuck in its restart checkpoint. - */ -function sendInstallFailureStatus(status: UpdateStatus): void { - sendStatus(status, { force: true }) -} - -const INSTALL_FAILURE_CAUSE_MAX_LENGTH = 200 - -/** - * Appends the updater's own text to the generic install-failure copy. Without it the only record of - * why the install never started is destroyed — on Linux that text carries the exact `dpkg -i ` - * command the user has to run by hand, and remote clients get nothing but "it didn't come back". - */ -function withInstallFailureCause(baseMessage: string, error: unknown): string { - const raw = error instanceof Error ? error.message : typeof error === 'string' ? error : '' - // Why: the retained-package card runs its text through this same sanitizer, so a home directory, - // user name, or terminal escape must not reach the card merely because no artifact was tracked. - const redacted = - redactLinuxPackageInstallText(raw, getTrackedLinuxPackageArtifact()?.path ?? null) ?? '' - const cause = redacted.slice(0, INSTALL_FAILURE_CAUSE_MAX_LENGTH) - if (!cause || cause === 'Unknown error') { - return baseMessage - } - // Why: UpdateCard picks the whole card off this string, so a signature verdict must not be prefixed by contradictory restart advice. - if ( - isWindowsSignatureCheckUnavailableFailure(cause) || - isWindowsSignatureMismatchFailure(cause) - ) { - return cause - } - return `${baseMessage} (${cause})` -} - -/** - * The recovery status for a failed `.deb`/`.rpm` install, or null when no retained package can - * recover it. Must run before `resetQuitForUpdateState()` clears the attempt diagnostic. - */ -function buildLinuxPackageInstallFailureStatus(error: unknown): UpdateStatus | null { - const artifact = getTrackedLinuxPackageArtifact() - if (!artifact) { - return null - } - const pendingVersion = getPendingInstallVersion() - if (pendingVersion && pendingVersion !== artifact.version) { - return null - } - const diagnostic = getLinuxPackageInstallDiagnostic() ?? lastInstallAttemptDiagnostic - // Why: the reason was classified from the original output, before redaction could rewrite a match. - const reason = diagnostic?.reason ?? 'package-install-failed' - // Durable data carries classification only — never the package path, home path, command, or stderr. - const exitCode = parseLinuxPackageInstallExitCode(error) - recordUpdaterLifecycle( - 'linux_package_install_failed', - { - packageType: artifact.packageType, - reason, - // Omitted rather than null when the child status could not be parsed. - ...(exitCode === null ? {} : { exitCode }), - version: artifact.version, - errorType: error instanceof Error ? error.name : typeof error - }, - { level: 'warn', message: 'Linux package install failed; cached package retained' } - ) - // Why: this text is shown in the card, so it gets the same redaction as retained stderr. - const message = - diagnostic?.message ?? - (error instanceof Error ? redactLinuxPackageInstallText(error.message, artifact.path) : null) ?? - 'The system package installer did not start.' - return { - state: 'error', - message, - recovery: { - kind: 'linux-package-install', - packageType: artifact.packageType, - reason, - version: artifact.version - } - } -} - -// Why: quitAndInstall failures arrive via 'error'; recover only after native invoke and before commit, else clearing quittingForUpdate lets dock activate reopen the old process mid-installer. -function handleQuitAndInstallFailure(error?: unknown): boolean { - if (!quitAndInstallInProgress || !quitAndInstallNativeInvoked || updateInstallCommitted) { - return false - } - const recoveryStatus = buildLinuxPackageInstallFailureStatus(error) - failServeUpdateHandoff('The native updater rejected the install request.') - resetQuitForUpdateState() - // Durable data carries classification only — the cause text stays on the status the user can read. - recordUpdaterLifecycle( - 'quit_and_install_failed_via_event', - { errorType: error instanceof Error ? error.name : typeof error }, - { - level: 'warn', - message: 'Update install could not start; recovered app state' - } - ) - sendInstallFailureStatus( - recoveryStatus ?? { - state: 'error', - message: withInstallFailureCause(getPreCommitInstallFailureMessage(), error) - } - ) - return true -} - -// Why: while quit-and-install owns the process, general check/download error UI must not run. -function isQuitAndInstallHandoffActive(): boolean { - return quitAndInstallInProgress -} - -async function runBeforeUpdateQuitCleanup(): Promise { - if (!onBeforeQuitCleanup) { - return - } - - let timeout: ReturnType | null = null - const cleanup = Promise.resolve() - .then(() => onBeforeQuitCleanup?.()) - .catch((error) => { - recordUpdaterLifecycle( - 'pre_quit_cleanup_failed', - { errorType: error instanceof Error ? error.name : typeof error }, - { - level: 'warn', - message: 'Pre-quit cleanup failed; continuing update install' - } - ) - }) - const timeoutResult = new Promise<'timeout'>((resolve) => { - timeout = setTimeout(() => resolve('timeout'), PRE_QUIT_CLEANUP_TIMEOUT_MS) - }) - - const result = await Promise.race([cleanup.then(() => 'done' as const), timeoutResult]) - if (result === 'timeout') { - recordUpdaterLifecycle( - 'pre_quit_cleanup_timeout', - { timeoutMs: PRE_QUIT_CLEANUP_TIMEOUT_MS }, - { - level: 'warn', - message: `Pre-quit cleanup exceeded ${PRE_QUIT_CLEANUP_TIMEOUT_MS}ms; continuing update install` - } - ) - return - } - - if (timeout) { - clearTimeout(timeout) - } -} - -async function sendCheckFailureStatus( - message: string, - userInitiated?: boolean, - source: CheckFailureSource = 'promise', - sourceError?: unknown -): Promise { - if (activeUpdateSource === 'local') { - sendLocalBuildErrorAndRestore(message, userInitiated) - return - } - if (isPinnedBuildActive) { - // Why: a failed pinned jump must hand the feed back before surfacing the - // error, or the pin blocks background checks for the process lifetime. - clearAvailableUpdateContext() - restoreReleaseUpdateSource() - sendStatus({ state: 'error', message, userInitiated }) - return - } - const failureKey = getCheckFailureKey(message, userInitiated) - if ( - source === 'promise' && - pendingPrereleaseFallback?.suppressedPrimaryPromiseFailureKey === failureKey - ) { - pendingPrereleaseFallback.suppressedPrimaryPromiseFailureKey = null - clearPrereleaseFallbackContextIfSettled() - return - } - if ( - source === 'fallback-promise' && - pendingPrereleaseFallback?.suppressedFallbackPromiseFailureKey === failureKey - ) { - pendingPrereleaseFallback.suppressedFallbackPromiseFailureKey = null - clearPrereleaseFallbackContextIfSettled() - return - } - - if ( - retryPrereleaseFallbackAfterMissingManifest( - message, - userInitiated, - source, - failureKey, - sourceError - ) - ) { - return - } - - if (pendingCheckFailureKey === failureKey && pendingCheckFailurePromise) { - return pendingCheckFailurePromise - } - - const handleFailure = async (): Promise => { - if (isBenignCheckFailure(message) || isRetryableReleaseFeedPreflightFailure(sourceError)) { - // Why: benign failures (incomplete latest.yml, network blips) are transient — retry, and skip persisting the timestamp (would suppress the next startup check). - console.warn('[updater] benign check failure:', message) - clearAvailableUpdateContext() - scheduleAutomaticUpdateCheck(AUTO_UPDATE_RETRY_INTERVAL_MS) - if (userInitiated) { - // Why: a user click needs visible feedback (idle looks broken); distinguish incomplete releases from transport failures. - sendErrorStatus( - isStableReleaseNotReadyFailure(sourceError) - ? "A newer release isn't available for this device yet. Check again later." - : "Couldn't reach the update server. Try again in a few minutes.", - true - ) - } else { - if (isRetryableReleaseFeedPreflightFailure(sourceError)) { - // Why: release probes can fail transiently; keep the campaign pending so the short retry can still show it. - deferPendingUpdateNudgeUntilRetry() - } - sendStatus({ state: 'idle' }) - } - return - } - - clearAvailableUpdateContext() - persistLastUpdateCheckAt?.(Date.now()) - if (!userInitiated) { - scheduleAutomaticUpdateCheck(AUTO_UPDATE_RETRY_INTERVAL_MS) - } - sendErrorStatus(message, userInitiated) - } - - pendingCheckFailureKey = failureKey - pendingCheckFailurePromise = handleFailure().finally(() => { - if (pendingCheckFailureKey === failureKey) { - pendingCheckFailureKey = null - pendingCheckFailurePromise = null - } - }) - return pendingCheckFailurePromise -} - -function isRetryableReleaseFeedPreflightFailure(sourceError: unknown): boolean { - return ( - sourceError instanceof ReleaseFeedPreflightError && - (sourceError.reason === 'release-not-ready' || sourceError.reason === 'manifest-unavailable') - ) -} - -function isStableReleaseNotReadyFailure(sourceError: unknown): boolean { - return ( - sourceError instanceof ReleaseFeedPreflightError && - sourceError.reason === 'release-not-ready' && - sourceError.releaseChannel === 'default' - ) + return updater.resolveUpdateInstallMode(isServeMode) } export function getUpdateStatus(): UpdateStatus { - return currentStatus + return updater.getUpdateStatus() } export function getRemoteServerUpdateSupport(): RemoteServerUpdateSupport { - if (!app.isPackaged || is.dev) { - return { - installMode: updateInstallMode, - automatic: false, - reason: 'unpackaged-build' - } - } - if (!autoUpdaterInitialized) { - return { - installMode: updateInstallMode, - automatic: false, - reason: 'updater-unavailable' - } - } - if (updateInstallMode === 'unsupported-headless-serve') { - return { - installMode: updateInstallMode, - automatic: false, - reason: 'manual-service-update-required' - } - } - return { installMode: updateInstallMode, automatic: true, reason: 'available' } + return updater.getRemoteServerUpdateSupport() } export function getRemoteServerUpdaterSnapshot(runtimeId: string): RemoteServerUpdaterSnapshot { - return { - appVersion: app.getVersion(), - runtimeId, - support: getRemoteServerUpdateSupport(), - status: getUpdateStatus() - } -} - -function assertRemoteServerUpdateAvailable(): void { - if (!getRemoteServerUpdateSupport().automatic) { - throw new Error('remote_update_manual_required') - } + return updater.getRemoteServerUpdaterSnapshot(runtimeId) } export function checkForRemoteServerUpdate( runtimeId: string, options?: UpdateCheckOptions ): RemoteServerUpdaterSnapshot { - assertRemoteServerUpdateAvailable() - checkForUpdatesFromMenu(options) - return getRemoteServerUpdaterSnapshot(runtimeId) + return updater.checkForRemoteServerUpdate(runtimeId, options) } export function downloadRemoteServerUpdate(runtimeId: string): RemoteServerUpdaterSnapshot { - assertRemoteServerUpdateAvailable() - if (currentStatus.state !== 'available') { - throw new Error('remote_update_not_available') - } - downloadUpdate() - return getRemoteServerUpdaterSnapshot(runtimeId) + return updater.downloadRemoteServerUpdate(runtimeId) } export function installRemoteServerUpdate(runtimeId: string): RemoteServerUpdateInstallResult { - assertRemoteServerUpdateAvailable() - if (currentStatus.state !== 'downloaded') { - throw new Error('remote_update_not_downloaded') - } - const targetVersion = currentStatus.version - const result: RemoteServerUpdateInstallResult = { - accepted: true, - fromVersion: app.getVersion(), - targetVersion, - runtimeId - } - quitAndInstall() - return result -} - -let consecutiveAutomaticRetrySchedules = 0 - -function scheduleAutomaticUpdateCheck(delayMs: number): void { - let effectiveDelayMs = delayMs - // All retry-cadence callers pass exactly this constant, so keying backoff on it keeps one choke point instead of threading a flag through every schedule site. - if (delayMs === AUTO_UPDATE_RETRY_INTERVAL_MS) { - effectiveDelayMs = Math.min( - AUTO_UPDATE_RETRY_INTERVAL_MS * 2 ** consecutiveAutomaticRetrySchedules, - MAX_AUTO_UPDATE_RETRY_INTERVAL_MS - ) - consecutiveAutomaticRetrySchedules += 1 - } - if (autoUpdateCheckTimer) { - clearTimeout(autoUpdateCheckTimer) - } - autoUpdateCheckTimer = setTimeout(() => { - // Why: Orca runs for days, so keep the next background check scheduled in the main process rather than tying it to relaunches or renderer lifetime. - if (!runBackgroundUpdateCheck()) { - // Why: a deferred check reaches no outcome handler, so re-arm here or one deferral ends automatic checks for the process lifetime. - scheduleAutomaticUpdateCheck(AUTO_UPDATE_CHECK_INTERVAL_MS) - } - }, effectiveDelayMs) -} - -function recordCompletedUpdateCheck(): void { - consecutiveAutomaticRetrySchedules = 0 - persistLastUpdateCheckAt?.(Date.now()) -} - -function getMissingManifestPrereleaseFallbackUserInitiated(): boolean | null { - if ( - !pendingPrereleaseFallback?.retryLaunched || - pendingPrereleaseFallback.fallbackResultHandled - ) { - return null - } - return pendingPrereleaseFallback.userInitiated -} - -function markMissingManifestPrereleaseFallbackChecking(): void { - if ( - !pendingPrereleaseFallback?.retryLaunched || - pendingPrereleaseFallback.fallbackResultHandled - ) { - return - } - pendingPrereleaseFallback.fallbackCheckingForUpdateSeen = true -} - -function consumeMissingManifestPrereleaseFallbackResult(): MissingManifestPrereleaseFallbackResult | null { - if ( - !pendingPrereleaseFallback?.retryLaunched || - pendingPrereleaseFallback.fallbackResultHandled - ) { - return null - } - const result = { userInitiated: pendingPrereleaseFallback.userInitiated } - pendingPrereleaseFallback.fallbackResultHandled = true - clearPrereleaseFallbackContextIfSettled() - return result -} - -function suppressMissingManifestPrereleaseFallbackPromiseFailure(message: string): void { - if ( - !pendingPrereleaseFallback?.retryLaunched || - pendingPrereleaseFallback.fallbackResultHandled - ) { - return - } - pendingPrereleaseFallback.suppressedFallbackPromiseFailureKey = getCheckFailureKey( - message, - pendingPrereleaseFallback.userInitiated - ) -} - -function shouldSuppressMissingManifestPrereleaseFallbackEvent( - message: string, - error: unknown -): boolean { - if (!pendingPrereleaseFallback?.retryLaunched) { - return false - } - const failureKey = getCheckFailureKey(message, pendingPrereleaseFallback.userInitiated) - const primaryEventSuppression = pendingPrereleaseFallback.suppressedPrimaryEventFailure - if (primaryEventSuppression?.failureKey === failureKey) { - const isPrimaryPromisePair = primaryEventSuppression.error === error - // Why: after fallback checking starts, same-message errors may be the fallback's, so message matching alone isn't safe. - if (isPrimaryPromisePair || !pendingPrereleaseFallback.fallbackCheckingForUpdateSeen) { - pendingPrereleaseFallback.suppressedPrimaryEventFailure = null - clearPrereleaseFallbackContextIfSettled() - return true - } - } - if (pendingPrereleaseFallback.suppressedFallbackEventFailureKey === failureKey) { - pendingPrereleaseFallback.suppressedFallbackEventFailureKey = null - clearPrereleaseFallbackContextIfSettled() - return true - } - return false -} - -function markMissingManifestPrereleaseFallbackPromiseHandled(message: string): void { - if ( - !pendingPrereleaseFallback?.retryLaunched || - pendingPrereleaseFallback.fallbackResultHandled - ) { - return - } - pendingPrereleaseFallback.suppressedFallbackEventFailureKey = getCheckFailureKey( - message, - pendingPrereleaseFallback.userInitiated - ) -} - -async function pinDefaultReleaseFeed( - variant: UpdateCheckVariant = 'default' -): Promise { - const autoUpdater = getAutoUpdater() - // Why: the latest/download redirect can move between check and download, so pin the concrete tag (prerelease users resolve any channel, stable only stable). - const currentVersion = app.getVersion() - const isPerfCheck = variant === 'perf' - const includePrerelease = - isPerfCheck || includePrereleaseActive || isPrereleaseVersion(currentVersion) - const releaseTagsResult = await fetchNewerReleaseTagsWithReadiness( - currentVersion, - includePrerelease ? 2 : 1, - { - includePrerelease, - ...(isPerfCheck ? { releaseFilter: 'perf' as const } : {}) - } - ) - const newerTag = releaseTagsResult.tags[0] ?? null - const fallbackTag = includePrerelease ? (releaseTagsResult.tags[1] ?? null) : null - pendingPrereleaseFallback = - includePrerelease && newerTag && fallbackTag - ? { - primaryTag: newerTag, - fallbackTag, - userInitiated: false, - suppressedPrimaryPromiseFailureKey: null, - suppressedPrimaryEventFailure: null, - suppressedFallbackPromiseFailureKey: null, - suppressedFallbackEventFailureKey: null, - fallbackResultHandled: false, - fallbackCheckingForUpdateSeen: false, - retryLaunched: false - } - : null - // Why: console.info is captured by Console.app/--enable-logging — our only field visibility into the updater. - if (newerTag) { - clearPublishingWindowLastGoodCheck() - const url = getReleaseDownloadUrl(newerTag) - console.info( - `[updater] release feed pinned: current=${currentVersion} includePrerelease=${includePrerelease} → ${url}` - ) - autoUpdater.setFeedURL({ provider: 'generic', url }) - return 'ready' - } else if (releaseTagsResult.state === 'not-ready') { - clearPrereleaseFallbackContext() - if (releaseTagsResult.lastGoodTag) { - // Why: during a publish window the newest tag is unsafe; a verified last-good concrete feed lets electron-updater emit a real result. - const url = getReleaseDownloadUrl(releaseTagsResult.lastGoodTag) - console.info( - `[updater] release feed pinned to last-good: current=${currentVersion} includePrerelease=${includePrerelease} → ${url}` - ) - publishingWindowLastGoodCheck = { lastGoodTag: releaseTagsResult.lastGoodTag } - autoUpdater.setFeedURL({ provider: 'generic', url }) - return 'ready' - } - clearPublishingWindowLastGoodCheck() - console.info( - `[updater] release feed deferred: current=${currentVersion} includePrerelease=${includePrerelease}; newest release assets are not ready` - ) - throw new ReleaseFeedPreflightError( - 'release-not-ready', - isPerfCheck ? 'perf' : includePrerelease ? 'prerelease' : 'default', - 'Latest release artifacts are not ready' - ) - } else if ( - releaseTagsResult.state === 'unavailable' && - releaseTagsResult.unavailableReason === 'manifest' && - !includePrerelease - ) { - clearPrereleaseFallbackContext() - clearPublishingWindowLastGoodCheck() - throw new ReleaseFeedPreflightError( - 'manifest-unavailable', - 'default', - 'Unable to find latest version on GitHub' - ) - } else if (isPerfCheck) { - clearPrereleaseFallbackContext() - clearPublishingWindowLastGoodCheck() - if (releaseTagsResult.state === 'no-newer') { - console.info( - `[updater] perf release not found: current=${currentVersion} includePrerelease=${includePrerelease}` - ) - return 'not-available' - } - throw new Error('Could not resolve perf update feed') - } else { - clearPrereleaseFallbackContext() - clearPublishingWindowLastGoodCheck() - const url = 'https://github.com/stablyai/orca/releases/latest/download' - console.info( - `[updater] release feed fallback: current=${currentVersion} includePrerelease=${includePrerelease} → ${url}` - ) - autoUpdater.setFeedURL({ provider: 'generic', url }) - return 'ready' - } -} - -function retryPrereleaseFallbackAfterMissingManifest( - message: string, - userInitiated: boolean | undefined, - source: CheckFailureSource, - failureKey: string, - sourceError?: unknown -): boolean { - if ( - !pendingPrereleaseFallback || - pendingPrereleaseFallback.retryLaunched || - !isMissingUpdateManifestFailure(message) - ) { - return false - } - const attemptId = activeUpdateCheckAttemptId - if (attemptId === null) { - return false - } - - // Why: a published tag can briefly lack its platform manifest mid-release; walk back once to the previous feed for a normal not-available result. - pendingPrereleaseFallback.retryLaunched = true - pendingPrereleaseFallback.userInitiated = Boolean(userInitiated) - pendingPrereleaseFallback.suppressedPrimaryPromiseFailureKey = - source === 'event' ? failureKey : null - pendingPrereleaseFallback.suppressedPrimaryEventFailure = - source === 'promise' ? { failureKey, error: sourceError } : null - pendingPrereleaseFallback.fallbackCheckingForUpdateSeen = false - const { primaryTag, fallbackTag } = pendingPrereleaseFallback - const url = getReleaseDownloadUrl(fallbackTag) - console.info( - `[updater] prerelease manifest missing for ${primaryTag}; retrying once against ${url}` - ) - const autoUpdater = getAutoUpdater() - autoUpdater.setFeedURL({ provider: 'generic', url }) - userInitiatedCheck = Boolean(userInitiated) - backgroundCheckLaunchPending = !userInitiated - armUpdateCheckStallTimer(attemptId) - markUpdateCheckLaunched(attemptId) - void autoUpdater - .checkForUpdates() - .then(() => handleSettledUpdateCheckPromise(attemptId)) - .catch((err) => { - if (!isActiveUpdateCheckAttempt(attemptId)) { - return - } - const message = String(err?.message ?? err) - if (userInitiated) { - userInitiatedCheck = false - } else { - backgroundCheckLaunchPending = false - } - markMissingManifestPrereleaseFallbackPromiseHandled(message) - consumeMissingManifestPrereleaseFallbackResult() - void sendCheckFailureStatus(message, userInitiated, 'fallback-promise', err) - }) - return true -} - -/** Returns false when the check was deferred instead of launched, so timer-driven callers can re-arm. */ -function runBackgroundUpdateCheck( - nudgeId: string | null = getPersistedPendingUpdateNudgeId() -): boolean { - // Why: a pinned dev jump owns the feed until it settles; a background check - // would repoint it mid-flight and download the wrong build. - if ( - activeUpdateSource !== 'release' || - isPinnedBuildActive || - localBuildSelectionInProgress || - pinnedBuildSelectionInProgress - ) { - return false - } - if (backgroundCheckLaunchPending || currentStatus.state === 'checking') { - return false - } - if (!app.isPackaged || is.dev) { - sendStatus({ state: 'not-available' }) - return false - } - // Why: set the nudge marker before any events arrive so later checks can't inherit a stale campaign id; persisted id keeps a nudge card dismissable after relaunch. - activeUpdateNudgeId = nudgeId - // Why: 'checking-for-update' arrives a tick later, so a second focus/resume can slip in before status flips; track launch in memory to dedupe that gap. - backgroundCheckLaunchPending = true - backgroundCheckPromotedToUserInitiated = false - const attemptId = beginUpdateCheckAttempt() - // Don't send 'checking' here — the 'checking-for-update' handler does; sending from both dupes notifications (issue #35). - const autoUpdater = getAutoUpdater() - const launch = (): Promise | undefined => { - if (!isActiveUpdateCheckAttempt(attemptId)) { - return undefined - } - markUpdateCheckLaunched(attemptId) - return autoUpdater.checkForUpdates() - } - const run = pinDefaultReleaseFeed().then(launch) - void Promise.resolve(run) - .then(() => handleSettledUpdateCheckPromise(attemptId)) - .catch((err) => { - if (!isActiveUpdateCheckAttempt(attemptId)) { - return - } - const wasUserInitiated = getSettledCheckUserInitiated() - backgroundCheckLaunchPending = false - backgroundCheckPromotedToUserInitiated = false - if (wasUserInitiated) { - userInitiatedCheck = false - } - void sendCheckFailureStatus(String(err?.message ?? err), wasUserInitiated, 'promise', err) - }) - return true + return updater.installRemoteServerUpdate(runtimeId) } export function checkForUpdates(): void { - // Why: span records only check launch (always Success), not outcome; dashboards must filter `updater.outcome === 'launched'`, not this span's success rate. - void withUpdaterSpan({ stage: 'check' }, async (span) => { - span.setAttribute('updater.outcome', 'launched') - runBackgroundUpdateCheck() - }) + updater.checkForUpdates() } -function enablePrereleaseManifestChecks(): void { - getAutoUpdater().allowPrerelease = true -} - -function enableIncludePrerelease(): void { - if (includePrereleaseActive) { - return - } - // Why: this flag makes electron-updater accept prerelease manifests; we keep the manifest-probed generic feed over the native GitHub provider because cancelled RCs can appear without assets. - enablePrereleaseManifestChecks() - includePrereleaseActive = true -} - -/** Menu-triggered check — delegates feedback to renderer toasts via userInitiated flag */ export function checkForUpdatesFromMenu(options?: UpdateCheckOptions): void { - if (!app.isPackaged || is.dev) { - sendStatus({ state: 'not-available', userInitiated: true }) - return - } - if (options?.localBuild) { - void checkForLocalBuildFromMenu() - return - } - if (options?.targetTag && options.channel) { - void checkForPinnedBuild(options.channel, options.targetTag) - return - } - if (localBuildSelectionInProgress || pinnedBuildSelectionInProgress) { - return - } - if ( - activeUpdateSource !== 'release' && - (currentStatus.state === 'checking' || currentStatus.state === 'downloading') - ) { - return - } - restoreReleaseUpdateSource() - - const checkVariant = getUpdateCheckVariant(options) - if (checkVariant === 'prerelease') { - clearPrereleaseFallbackContext() - enableIncludePrerelease() - } else if (checkVariant === 'perf') { - clearPrereleaseFallbackContext() - // Why: perf checks need prerelease manifests now, but must not opt future default/background checks into the RC channel. - enablePrereleaseManifestChecks() - } - - const checkAlreadyInFlight = backgroundCheckLaunchPending || currentStatus.state === 'checking' - userInitiatedCheck = true - // Why: manual checks are nudge-independent; clear the marker so a later dismiss can't consume the campaign by accident. - activeUpdateNudgeId = null - // Why: respond visibly before feed pinning/updater events; duplicate broadcasts are suppressed by status equality below. - sendStatus({ state: 'checking', userInitiated: true }) - if (checkAlreadyInFlight) { - backgroundCheckPromotedToUserInitiated = true - rearmActiveUpdateCheckStallTimer() - if (checkVariant !== 'default') { - // Why: in-flight check may have pinned the stable feed; queue a fresh modifier check to avoid a stale-channel result. - pendingUserInitiatedCheckAfterInFlight = checkVariant - } - return - } - - const attemptId = beginUpdateCheckAttempt() - const autoUpdater = getAutoUpdater() - const launch = (): Promise | undefined => { - if (!isActiveUpdateCheckAttempt(attemptId)) { - return undefined - } - markUpdateCheckLaunched(attemptId) - return autoUpdater.checkForUpdates() - } - const run = pinDefaultReleaseFeed(checkVariant).then((preflightResult) => { - if (preflightResult === 'not-available') { - if (!isActiveUpdateCheckAttempt(attemptId)) { - return false - } - userInitiatedCheck = false - finishActiveUpdateCheckAttempt() - recordCompletedUpdateCheck() - sendStatus({ state: 'not-available', userInitiated: true }) - return false - } - return launch() - }) - void Promise.resolve(run) - .then((launchResult) => { - if (launchResult === false) { - return - } - handleSettledUpdateCheckPromise(attemptId) - }) - .catch((err) => { - if (!isActiveUpdateCheckAttempt(attemptId)) { - return - } - userInitiatedCheck = false - void sendCheckFailureStatus(String(err?.message ?? err), true, 'promise', err) - }) -} - -async function checkForLocalBuildFromMenu(): Promise { - if (process.platform !== 'darwin') { - sendLocalBuildErrorAndRestore( - 'Local build switching is currently available only on macOS.', - true - ) - return - } - if (currentStatus.state === 'checking' || currentStatus.state === 'downloading') { - return - } - if (localBuildSelectionInProgress) { - return - } - localBuildSelectionInProgress = true - try { - const [{ chooseLocalBuild }, { startLocalBuildFeed }] = await Promise.all([ - import('./local-builds/local-build-switch'), - import('./local-builds/local-build-feed-server') - ]) - const candidate = await chooseLocalBuild(mainWindowRef) - if (!candidate) { - return - } - closeLocalBuildFeed() - const feed = await startLocalBuildFeed(candidate) - activeLocalBuildFeed = feed - activeUpdateSource = 'local' - clearPrereleaseFallbackContext() - clearPublishingWindowLastGoodCheck() - clearAvailableUpdateContext() - activeUpdateNudgeId = null - userInitiatedCheck = true - sendStatus({ state: 'checking', userInitiated: true }) - - const updater = getAutoUpdater() - updater.allowDowngrade = true - updater.disableDifferentialDownload = true - updater.setFeedURL({ provider: 'generic', url: feed.url }) - const attemptId = beginUpdateCheckAttempt() - markUpdateCheckLaunched(attemptId) - await updater.checkForUpdates() - handleSettledUpdateCheckPromise(attemptId) - } catch (error) { - userInitiatedCheck = false - sendLocalBuildErrorAndRestore(String((error as Error)?.message ?? error), true) - } finally { - localBuildSelectionInProgress = false - } -} - -export async function listAvailableReleaseBuilds(channel: ReleaseChannel): Promise { - return listReleaseBuilds(channel) -} - -/** - * Pins the updater at one exact release tag and checks it, so a dev can move to - * any published build on any channel — including an older one. - * - * Unlike a routine check this sets `allowDowngrade`, because "jump to yesterday's - * hourly" is a downgrade by semver. The pin is torn down as soon as the attempt - * settles so ordinary background checks never inherit it. - */ -async function checkForPinnedBuild(channel: ReleaseChannel, tag: string): Promise { - if (!app.isPackaged || is.dev) { - sendStatus({ state: 'not-available', userInitiated: true }) - return - } - // Why here as well as in the picker: the renderer disables the option, but IPC - // is reachable regardless, and there is no artifact to install on a platform - // the dev workflows do not build for. - if (!isChannelSupportedOnPlatform(channel, process.platform)) { - sendStatus({ - state: 'error', - message: `${RELEASE_CHANNEL_LABELS[channel]} builds are produced only for ${DEV_CHANNEL_PLATFORM_LABEL}.`, - userInitiated: true - }) - return - } - // Why: electron-updater would otherwise take this all the way to a download - // and fail it with a raw ERR_UPDATER_INVALID_SIGNATURE. Say what to do instead - // — the installer is run by hand once, and in-app updates work from there on. - if ( - requiresManualDevChannelInstall({ - platform: process.platform, - runningChannel: getVersionChannel(app.getVersion()), - targetChannel: channel - }) - ) { - sendStatus({ - state: 'error', - message: `${RELEASE_CHANNEL_LABELS[channel]} builds are unsigned, and this signed build only installs updates signed by Orca's publisher. Download the installer from the release page and run it once — updates work normally from there, including back to Stable.`, - userInitiated: true - }) - return - } - if (currentStatus.state === 'checking' || currentStatus.state === 'downloading') { - return - } - if (localBuildSelectionInProgress || pinnedBuildSelectionInProgress) { - return - } - pinnedBuildSelectionInProgress = true - try { - const target = resolveTargetBuild(channel, tag) - if (compareVersions(target.version, app.getVersion()) === 0) { - sendStatus({ state: 'not-available', userInitiated: true }) - return - } - closeLocalBuildFeed() - activeUpdateSource = hasDedicatedReleaseRepo(channel) ? channel : 'release' - isPinnedBuildActive = true - clearPrereleaseFallbackContext() - clearPublishingWindowLastGoodCheck() - clearAvailableUpdateContext() - activeUpdateNudgeId = null - userInitiatedCheck = true - sendStatus({ state: 'checking', userInitiated: true }) - - const updater = getAutoUpdater() - // Why: an intentional jump to an older tag must not be filtered out as "not newer". - updater.allowDowngrade = true - updater.disableDifferentialDownload = true - updater.allowPrerelease = true - console.info(`[updater] pinned to ${channel} build ${target.tag} → ${target.feedUrl}`) - updater.setFeedURL({ provider: 'generic', url: target.feedUrl }) - availableReleaseUrl = target.feedUrl - const attemptId = beginUpdateCheckAttempt() - markUpdateCheckLaunched(attemptId) - await updater.checkForUpdates() - handleSettledUpdateCheckPromise(attemptId) - } catch (error) { - userInitiatedCheck = false - clearAvailableUpdateContext() - restoreReleaseUpdateSource() - sendStatus({ - state: 'error', - message: String((error as Error)?.message ?? error), - userInitiated: true - }) - } finally { - pinnedBuildSelectionInProgress = false - } -} - -export function isQuittingForUpdate(): boolean { - return quittingForUpdate -} - -function getActiveLinuxPackageRecovery(): LinuxPackageInstallRecovery | null { - if (currentStatus.state !== 'error') { - return null - } - return currentStatus.recovery?.kind === 'linux-package-install' ? currentStatus.recovery : null -} - -const LINUX_PACKAGE_RECOVERY_MESSAGES: Record = { - missing: - 'The downloaded package is no longer in the update cache. Download the update again, or get it from the official release page.', - // Why: this reason also covers a path that left the cache (traversal or symlinked parent), so the copy must not promise the file merely changed type. - 'not-regular': - 'The downloaded package is no longer a valid file in the update cache. Download the update again, or get it from the official release page.', - 'hash-mismatch': - 'The downloaded package no longer matches the verified release, so Orca will not hand it to a package manager. Download the update again, or get it from the official release page.', - 'read-failed': - 'Orca could not read the downloaded package. Download the update again, or get it from the official release page.', - 'no-sudo': - 'No sudo command was found in the system directories, so Orca cannot build a safe install command. Show the package and install it with your package manager.', - 'no-package-manager': - 'No supported package manager was found in the system directories, so Orca cannot build a safe install command. Show the package and install it with your package manager.', - // Defensive: capture only ever tracks absolute cache paths, so this reports a bug rather than a machine state. - 'invalid-package-path': - 'The downloaded package is not at a usable path, so Orca cannot build a safe install command. Show the package and install it with your package manager.' -} - -// Why: clearing the artifact alone would leave the renderer's actions enabled; the status must lose its recovery too. -const RECOVERY_CLEARING_REASONS: LinuxPackageRecoveryUnavailableReason[] = [ - 'missing', - 'not-regular', - 'hash-mismatch' -] - -function recordLinuxPackageRecoveryUnavailable( - recovery: LinuxPackageInstallRecovery, - reason: LinuxPackageRecoveryUnavailableReason -): void { - recordUpdaterLifecycle( - 'linux_package_recovery_unavailable', - { reason, packageType: recovery.packageType, version: recovery.version }, - { level: 'warn', message: 'Linux package recovery action unavailable' } - ) -} - -function failLinuxPackageRecovery( - recovery: LinuxPackageInstallRecovery, - reason: LinuxPackageRecoveryUnavailableReason -): never { - recordLinuxPackageRecoveryUnavailable(recovery, reason) - const message = LINUX_PACKAGE_RECOVERY_MESSAGES[reason] - // Why: hashing 160 MB takes long enough for a new cycle to land. Acting on a stale verdict would - // destroy the newer artifact and clobber whatever card replaced this one. - const active = getActiveLinuxPackageRecovery() - const stillCurrent = - active?.version === recovery.version && active?.packageType === recovery.packageType - if (stillCurrent && RECOVERY_CLEARING_REASONS.includes(reason)) { - clearTrackedLinuxPackageArtifact() - sendStatus({ state: 'error', message }) - } - throw new Error(message) -} - -/** - * Identifies the update cycle an install belongs to, so a verdict produced by a multi-second hash - * can be dropped when a newer cycle already replaced the card it would otherwise overwrite. - */ -function getInstallCycleSignature(): string { - const recovery = getActiveLinuxPackageRecovery() - if (recovery) { - return `recovery:${recovery.packageType}:${recovery.version}` - } - return currentStatus.state === 'downloaded' - ? `downloaded:${currentStatus.version}` - : `state:${currentStatus.state}` -} - -/** - * Re-proves the retained package before the install starts. Returns false when the install must be - * abandoned; the artifact is only re-read here, so callers still own every teardown decision. - */ -async function proveRetainedLinuxPackage(pendingVersion: string): Promise { - const artifact = getTrackedLinuxPackageArtifact() - if (!artifact) { - return true - } - // Why: an artifact retained from another cycle says nothing about the file electron-updater is - // about to install, so proving it would block a legitimate install on an unrelated digest. - if (pendingVersion && pendingVersion !== artifact.version) { - return true - } - const recovery = getActiveLinuxPackageRecovery() - const cycle = getInstallCycleSignature() - const reason = await revalidateRetainedLinuxPackage(artifact) - if (!reason) { - return true - } - reportLinuxPackageRevalidationFailure({ artifact, recovery, reason, cycle }) - return false -} - -/** The failing reason, or null when the retained package still matches its release digest. */ -async function revalidateRetainedLinuxPackage( - artifact: LinuxPackageArtifact -): Promise { - linuxPackageRevalidationInFlight = true - try { - const verdict = await revalidateLinuxPackageForInstall(artifact) - return verdict.ok ? null : verdict.reason - } catch (error) { - recordUpdaterLifecycle( - 'linux_package_revalidation_errored', - { errorType: error instanceof Error ? error.name : typeof error }, - { level: 'warn', message: 'Could not re-verify the retained update package' } - ) - // Why: fail closed — bytes we could not read are bytes we cannot hand to a root installer. - return 'read-failed' - } finally { - // Why: the invariant every install path depends on — a wedged flag would make quitAndInstall - // early-return for the rest of the session. - linuxPackageRevalidationInFlight = false - } -} - -function reportLinuxPackageRevalidationFailure({ - artifact, - recovery, - reason, - cycle -}: { - artifact: LinuxPackageArtifact - recovery: LinuxPackageInstallRecovery | null - reason: LinuxPackageRecoveryUnavailableReason - cycle: string -}): void { - recordUpdaterLifecycle( - 'linux_package_revalidation_failed', - { - action: recovery ? 'retry-automatic' : 'restart-to-install', - packageType: artifact.packageType, - version: artifact.version, - reason - }, - { level: 'warn', message: 'Retained update package failed its pre-install digest check' } - ) - // Why: a package proven bad must not stay tracked, but a download that landed during the hash - // owns the slot now and destroying it would force a needless 160 MB redownload. - const clearsArtifact = RECOVERY_CLEARING_REASONS.includes(reason) - if (clearsArtifact && getTrackedLinuxPackageArtifact() === artifact) { - clearTrackedLinuxPackageArtifact() - } - // Why: same reasoning as failLinuxPackageRecovery — a verdict from a cycle that has since been - // replaced must not clobber whatever card the user is looking at now. - if (getInstallCycleSignature() !== cycle) { - return - } - sendInstallFailureStatus({ - state: 'error', - message: LINUX_PACKAGE_RECOVERY_MESSAGES[reason], - // Why: an unreadable file is not evidence the bytes changed, so the recovery card and its - // Copy/Show actions survive a transient I/O failure exactly as they do elsewhere. - ...(recovery && !clearsArtifact ? { recovery } : {}) - }) -} - -export async function getLinuxPackageInstallInstructions(): Promise { - const recovery = getActiveLinuxPackageRecovery() - if (!recovery) { - throw new Error('No package install recovery is available.') - } - recordUpdaterLifecycle('linux_package_recovery_requested', { - action: 'copy-command', - packageType: recovery.packageType, - version: recovery.version - }) - const result = await resolveLinuxPackageInstallInstructions(recovery) - if (!result.ok) { - // Why: the renderer must distinguish "this machine has no package manager" (keep the card, promote - // Show Package) from "the artifact is gone" (recovery is cleared and the card unmounts). - if (result.reason === 'no-sudo' || result.reason === 'no-package-manager') { - recordLinuxPackageRecoveryUnavailable(recovery, result.reason) - return { - ok: false, - reason: result.reason, - message: LINUX_PACKAGE_RECOVERY_MESSAGES[result.reason] - } - } - failLinuxPackageRecovery(recovery, result.reason) - } - return { ok: true, command: result.command, packageFileName: result.packageFileName } -} - -export async function showLinuxPackage(): Promise { - const recovery = getActiveLinuxPackageRecovery() - if (!recovery) { - throw new Error('No package install recovery is available.') - } - recordUpdaterLifecycle('linux_package_recovery_requested', { - action: 'show-package', - packageType: recovery.packageType, - version: recovery.version - }) - const result = await revealLinuxPackage(recovery) - if (!result.ok) { - failLinuxPackageRecovery(recovery, result.reason) - } -} - -export function quitAndInstall(): void { - if ( - localBuildSelectionInProgress || - pinnedBuildSelectionInProgress || - pendingQuitAndInstallTimer || - quitAndInstallInProgress || - // Why: the quit timer is already cleared while the pre-install digest re-proof streams, so - // without this a second click would schedule a parallel install of the same package. - linuxPackageRevalidationInFlight - ) { - return - } - - const retriedRecovery = getActiveLinuxPackageRecovery() - if (retriedRecovery) { - recordUpdaterLifecycle('linux_package_recovery_requested', { - action: 'retry-automatic', - packageType: retriedRecovery.packageType, - version: retriedRecovery.version - }) - } - - if (deferHeadlessServeInstall('install', getPendingInstallVersion())) { - return - } - - if ( - deferMacQuitUntilInstallerReady( - currentStatus, - hasInstallableDownloadedVersion(), - getPendingInstallVersion, - sendStatus - ) - ) { - return - } - - // Why: defer the quit a tick so the renderer can flush dismissals/state before windows start closing. - pendingQuitAndInstallTimer = setTimeout(() => { - void performQuitAndInstall() - }, QUIT_AND_INSTALL_DELAY_MS) -} - -async function checkForUpdateNudge(): Promise { - if (!app.isPackaged || is.dev) { - return - } - if (nudgeCheckInFlight) { - return - } - - const now = Date.now() - if (now - lastNudgeCheckAt < NUDGE_ACTIVATION_COOLDOWN_MS) { - return - } - lastNudgeCheckAt = now - - nudgeCheckInFlight = true - try { - const nudge = await fetchNudge() - if (!nudge) { - return - } - - if (currentStatus.state === 'checking' || currentStatus.state === 'downloading') { - return - } - - const appVersion = app.getVersion() - const pendingUpdateNudgeId = _getPendingUpdateNudgeId?.() ?? null - const dismissedUpdateNudgeId = _getDismissedUpdateNudgeId?.() ?? null - - if ( - shouldApplyNudge({ - nudge, - appVersion, - pendingUpdateNudgeId, - dismissedUpdateNudgeId - }) - ) { - awaitingNudgeCheckOutcome = true - _setPendingUpdateNudgeId?.(nudge.id) - mainWindowRef?.webContents.send('updater:clearDismissal') - runBackgroundUpdateCheck(nudge.id) - } - } finally { - nudgeCheckInFlight = false - } -} - -function scheduleUpdateNudgeCheck(): void { - if (nudgeCheckTimer) { - clearTimeout(nudgeCheckTimer) - } - nudgeCheckTimer = setTimeout(() => { - void checkForUpdateNudge() - scheduleUpdateNudgeCheck() - }, NUDGE_POLL_INTERVAL_MS) -} - -export function dismissNudge(): void { - const pendingId = activeUpdateNudgeId ?? _getPendingUpdateNudgeId?.() ?? null - if (pendingId) { - _setDismissedUpdateNudgeId?.(pendingId) - clearPendingUpdateNudge() - } -} - -/** - * The user closed an offered update without taking it. For a local build or a - * pinned dev jump that ends the session: nothing will consume that feed now, so - * release checks must stop being deferred. - */ -export function dismissAvailableUpdate(): void { - if (activeUpdateSource === 'release' && !isPinnedBuildActive) { - return - } - if (localBuildSelectionInProgress || pinnedBuildSelectionInProgress) { - return - } - // Why: only an un-acted 'available' card is abandoned — 'downloading'/'downloaded' still need the pinned feed and allowDowngrade. - if (currentStatus.state !== 'available') { - return - } - clearAvailableUpdateContext() - restoreReleaseUpdateSource() - // Why: leaving the card's 'available' status behind would let a retry download the local version off the restored release feed. - sendStatus({ state: 'idle' }) -} - -export function setupAutoUpdater( - mainWindow: BrowserWindow, - opts?: { - getLastUpdateCheckAt?: () => number | null - onBeforeQuit?: () => void | Promise - setLastUpdateCheckAt?: (timestamp: number) => void - getPendingUpdateNudgeId?: () => string | null - getDismissedUpdateNudgeId?: () => string | null - setPendingUpdateNudgeId?: (id: string | null) => void - setDismissedUpdateNudgeId?: (id: string | null) => void - getReleaseChannelOverride?: () => ReleaseChannel | null - installMode?: UpdateInstallMode - } -): void { - mainWindowRef = mainWindow - onBeforeQuitCleanup = opts?.onBeforeQuit ?? null - persistLastUpdateCheckAt = opts?.setLastUpdateCheckAt ?? null - _getLastUpdateCheckAt = opts?.getLastUpdateCheckAt ?? null - _getPendingUpdateNudgeId = opts?.getPendingUpdateNudgeId ?? null - _getDismissedUpdateNudgeId = opts?.getDismissedUpdateNudgeId ?? null - _setPendingUpdateNudgeId = opts?.setPendingUpdateNudgeId ?? null - _setDismissedUpdateNudgeId = opts?.setDismissedUpdateNudgeId ?? null - getReleaseChannelOverride = opts?.getReleaseChannelOverride ?? null - updateInstallMode = opts?.installMode ?? 'interactive' - lastInstallDeferralVersion = { download: null, install: null } - - const serveHandoffFailure = getServeUpdateHandoffFailure() - if (serveHandoffFailure) { - recordUpdaterLifecycle( - 'headless_serve_handoff_failed', - { reason: serveHandoffFailure }, - { level: 'warn', message: 'Supervised serve update did not complete' } - ) - sendErrorStatus(`The server update did not complete: ${serveHandoffFailure}`, true) - } - - if (!app.isPackaged && !is.dev) { - return - } - if (is.dev) { - return - } - - const autoUpdater = getAutoUpdater() - autoUpdater.autoDownload = false - if (activeUpdateSource === 'release') { - autoUpdater.allowDowngrade = false - autoUpdater.disableDifferentialDownload = false - } - // Why: supervised serve installs require an explicit handoff; ordinary service quits must never install implicitly. - // Root Linux packages also opt out: an implicit quit-time escalation would fail after the UI is gone, leaving no recovery surface. - autoUpdater.autoInstallOnAppQuit = - updateInstallMode === 'interactive' && getLinuxRootPackageType() === null - // Why: MacUpdater ignores quitAndInstall arguments; the surviving CLI supervisor must be the only serve relaunch owner. - autoUpdater.autoRunAppAfterInstall = updateInstallMode === 'interactive' - - // Why: our only on-machine window into electron-updater; otherwise an unexpected update-not-available or failed fetch is invisible. - // The adapter also retains the redacted child stderr that BaseUpdater logs but drops from the 'error' event. - autoUpdater.logger = createUpdaterDiagnosticLogger() as never - - // Security: never re-add a verifyUpdateCodeSignature override — a no-op disables electron-updater's built-in Authenticode check and accepts any installer. - - // Why: generic provider avoids the native GitHub provider's RC-channel filtering; per-check repinning to a concrete /releases/download// URL avoids /latest redirect drift between check and download. - if (activeUpdateSource === 'release') { - autoUpdater.setFeedURL({ - provider: 'generic', - url: 'https://github.com/stablyai/orca/releases/latest/download' - }) - } - - if (autoUpdaterInitialized) { - return - } - autoUpdaterInitialized = true - - registerAutoUpdaterHandlers({ - autoUpdater, - clearAvailableUpdateContext, - consumeMissingManifestPrereleaseFallbackResult, - getMissingManifestPrereleaseFallbackUserInitiated, - getPublishingWindowLastGoodCheck, - getActiveUpdateCheckEventAttemptId, - getCurrentStatus: () => currentStatus, - getKnownReleaseUrl, - getPendingInstallVersion, - getUserInitiatedCheck: () => userInitiatedCheck, - handleQuitAndInstallFailure, - isQuitAndInstallHandoffActive, - hasInstallableDownloadedVersion, - isLocalBuildCheck: () => activeUpdateSource === 'local', - // Why: pinned jumps are deliberate, so update-available/-downloaded must not - // reject them for being older than the running version. - isPinnedBuildCheck: () => isPinnedBuildActive, - shouldHandleUpdaterErrorEvent, - performQuitAndInstall, - clearUpdateAvailableEventPending, - isActiveUpdateCheckAttempt, - markUpdateCheckEventAttempt, - markUpdateAvailableEventPending, - sendCheckFailureStatus, - sendErrorStatus, - markMissingManifestPrereleaseFallbackChecking, - shouldDeferMacQuitForInstall: () => updateInstallMode === 'interactive', - shouldSuppressMissingManifestPrereleaseFallbackEvent, - suppressMissingManifestPrereleaseFallbackPromiseFailure, - recordCompletedUpdateCheck, - restoreReleaseUpdateSource, - sendStatus, - scheduleAutomaticUpdateCheck, - clearBackgroundCheckLaunchPending, - setAvailableReleaseUrl: (releaseUrl) => { - availableReleaseUrl = releaseUrl - }, - setAvailableVersion: (version) => { - availableVersion = version - }, - setUserInitiatedCheck: (value) => { - userInitiatedCheck = value - } - }) - - void checkForUpdateNudge() - scheduleUpdateNudgeCheck() - - const checkDailyOnWake = () => { - void checkForUpdateNudge() - if ( - backgroundCheckLaunchPending || - currentStatus.state === 'checking' || - currentStatus.state === 'downloading' - ) { - return - } - const lastCheck = _getLastUpdateCheckAt?.() ?? null - const msSince = lastCheck === null ? Number.POSITIVE_INFINITY : Date.now() - lastCheck - if (msSince >= AUTO_UPDATE_CHECK_INTERVAL_MS) { - runBackgroundUpdateCheck() - scheduleAutomaticUpdateCheck(AUTO_UPDATE_CHECK_INTERVAL_MS) - } - } - - powerMonitor.on('resume', checkDailyOnWake) - app.on('browser-window-focus', checkDailyOnWake) - - const lastUpdateCheckAt = opts?.getLastUpdateCheckAt?.() ?? null - const msSinceLastCheck = - lastUpdateCheckAt === null ? Number.POSITIVE_INFINITY : Date.now() - lastUpdateCheckAt - - if (msSinceLastCheck >= AUTO_UPDATE_CHECK_INTERVAL_MS) { - runBackgroundUpdateCheck() - scheduleAutomaticUpdateCheck(AUTO_UPDATE_CHECK_INTERVAL_MS) - } else { - scheduleAutomaticUpdateCheck(AUTO_UPDATE_CHECK_INTERVAL_MS - msSinceLastCheck) - } + updater.checkForUpdatesFromMenu(options) } export function downloadUpdate(): void { - if (localBuildSelectionInProgress || pinnedBuildSelectionInProgress || downloadInFlight) { - return - } - // Why: allow retry from 'error' (availableVersion stays cached) so the error card's Retry Download button works. - const canStart = - currentStatus.state === 'available' || - (currentStatus.state === 'error' && hasInstallableDownloadedVersion()) - if (!canStart) { - return - } - const version = currentStatus.state === 'available' ? currentStatus.version : availableVersion - if (!version) { - return - } - if (deferHeadlessServeInstall('download', version)) { - return - } - downloadInFlight = true - const localBuildDownload = activeUpdateSource === 'local' - beginMacUpdateDownload() - // Why: setup can take seconds before progress emits; surface acceptance now so the action never looks inert. - sendStatus({ state: 'downloading', percent: 0, version }) - getAutoUpdater() - .downloadUpdate() - .catch((err) => { - downloadInFlight = false - const message = String(err?.message ?? err) - if (localBuildDownload) { - sendLocalBuildErrorAndRestore(message) - } else { - sendErrorStatus(message) - } - }) + updater.downloadUpdate() +} + +export function quitAndInstall(): void { + updater.quitAndInstall() +} + +export function isQuittingForUpdate(): boolean { + return updater.isQuittingForUpdate() +} + +export async function getLinuxPackageInstallInstructions(): Promise { + return updater.getLinuxPackageInstallInstructions() +} + +export async function showLinuxPackage(): Promise { + return updater.showLinuxPackage() +} + +export async function listAvailableReleaseBuilds(channel: ReleaseChannel): Promise { + return updater.listAvailableReleaseBuilds(channel) +} + +export function dismissNudge(): void { + updater.dismissNudge() +} + +export function dismissAvailableUpdate(): void { + updater.dismissAvailableUpdate() +} + +export function setupAutoUpdater(mainWindow: BrowserWindow, opts?: UpdaterSetupOptions): void { + updater.setupAutoUpdater(mainWindow, opts) } diff --git a/src/main/updater/updater-build-selection.ts b/src/main/updater/updater-build-selection.ts new file mode 100644 index 00000000000..63222fb3101 --- /dev/null +++ b/src/main/updater/updater-build-selection.ts @@ -0,0 +1,152 @@ +import { app } from 'electron' +import { is } from '@electron-toolkit/utils' +import { + DEV_CHANNEL_PLATFORM_LABEL, + getVersionChannel, + hasDedicatedReleaseRepo, + isChannelSupportedOnPlatform, + RELEASE_CHANNEL_LABELS, + requiresManualDevChannelInstall, + type ReleaseBuild, + type ReleaseChannel +} from '../../shared/release-channel' +import { compareVersions } from '../updater-fallback' +import { listReleaseBuilds, resolveTargetBuild } from '../updater-release-builds' +import { UpdaterMenuChecks } from './updater-menu-checks' + +/** Handles local-build selection and exact release-channel/tag jumps. */ +export abstract class UpdaterBuildSelection extends UpdaterMenuChecks { + protected async checkForLocalBuildFromMenu(): Promise { + if (process.platform !== 'darwin') { + this.sendLocalBuildErrorAndRestore( + 'Local build switching is currently available only on macOS.', + true + ) + return + } + if (this.currentStatus.state === 'checking' || this.currentStatus.state === 'downloading') { + return + } + if (this.localBuildSelectionInProgress) { + return + } + this.localBuildSelectionInProgress = true + try { + const [{ chooseLocalBuild }, { startLocalBuildFeed }] = await Promise.all([ + import('../local-builds/local-build-switch'), + import('../local-builds/local-build-feed-server') + ]) + const candidate = await chooseLocalBuild(this.mainWindowRef) + if (!candidate) { + return + } + this.closeLocalBuildFeed() + const feed = await startLocalBuildFeed(candidate) + this.activeLocalBuildFeed = feed + this.activeUpdateSource = 'local' + this.clearPrereleaseFallbackContext() + this.clearPublishingWindowLastGoodCheck() + this.clearAvailableUpdateContext() + this.activeUpdateNudgeId = null + this.userInitiatedCheck = true + this.sendStatus({ state: 'checking', userInitiated: true }) + + const updater = this.getAutoUpdater() + updater.allowDowngrade = true + updater.disableDifferentialDownload = true + updater.setFeedURL({ provider: 'generic', url: feed.url }) + const attemptId = this.beginUpdateCheckAttempt() + this.markUpdateCheckLaunched(attemptId) + await updater.checkForUpdates() + this.handleSettledUpdateCheckPromise(attemptId) + } catch (error) { + this.userInitiatedCheck = false + this.sendLocalBuildErrorAndRestore(String((error as Error)?.message ?? error), true) + } finally { + this.localBuildSelectionInProgress = false + } + } + + protected async listAvailableReleaseBuilds(channel: ReleaseChannel): Promise { + return listReleaseBuilds(channel) + } + + /** Pins the updater at one exact release tag and checks it, so a dev can move to any published build on any channel — including an older one. */ + protected async checkForPinnedBuild(channel: ReleaseChannel, tag: string): Promise { + if (!app.isPackaged || is.dev) { + this.sendStatus({ state: 'not-available', userInitiated: true }) + return + } + // Why here as well as in the picker: the renderer disables the option, but IPC is reachable regardless, and there is no artifact to install on a platform the dev workflows do not build for. + if (!isChannelSupportedOnPlatform(channel, process.platform)) { + this.sendStatus({ + state: 'error', + message: `${RELEASE_CHANNEL_LABELS[channel]} builds are produced only for ${DEV_CHANNEL_PLATFORM_LABEL}.`, + userInitiated: true + }) + return + } + // Why: electron-updater would otherwise take this all the way to a download and fail it with a raw ERR_UPDATER_INVALID_SIGNATURE. Say what to do instead — the installer is run by hand once, and in-app updates work from there on. + if ( + requiresManualDevChannelInstall({ + platform: process.platform, + runningChannel: getVersionChannel(app.getVersion()), + targetChannel: channel + }) + ) { + this.sendStatus({ + state: 'error', + message: `${RELEASE_CHANNEL_LABELS[channel]} builds are unsigned, and this signed build only installs updates signed by Orca's publisher. Download the installer from the release page and run it once — updates work normally from there, including back to Stable.`, + userInitiated: true + }) + return + } + if (this.currentStatus.state === 'checking' || this.currentStatus.state === 'downloading') { + return + } + if (this.localBuildSelectionInProgress || this.pinnedBuildSelectionInProgress) { + return + } + this.pinnedBuildSelectionInProgress = true + try { + const target = resolveTargetBuild(channel, tag) + if (compareVersions(target.version, app.getVersion()) === 0) { + this.sendStatus({ state: 'not-available', userInitiated: true }) + return + } + this.closeLocalBuildFeed() + this.activeUpdateSource = hasDedicatedReleaseRepo(channel) ? channel : 'release' + this.isPinnedBuildActive = true + this.clearPrereleaseFallbackContext() + this.clearPublishingWindowLastGoodCheck() + this.clearAvailableUpdateContext() + this.activeUpdateNudgeId = null + this.userInitiatedCheck = true + this.sendStatus({ state: 'checking', userInitiated: true }) + + const updater = this.getAutoUpdater() + // Why: an intentional jump to an older tag must not be filtered out as "not newer". + updater.allowDowngrade = true + updater.disableDifferentialDownload = true + updater.allowPrerelease = true + console.info(`[updater] pinned to ${channel} build ${target.tag} → ${target.feedUrl}`) + updater.setFeedURL({ provider: 'generic', url: target.feedUrl }) + this.availableReleaseUrl = target.feedUrl + const attemptId = this.beginUpdateCheckAttempt() + this.markUpdateCheckLaunched(attemptId) + await updater.checkForUpdates() + this.handleSettledUpdateCheckPromise(attemptId) + } catch (error) { + this.userInitiatedCheck = false + this.clearAvailableUpdateContext() + this.restoreReleaseUpdateSource() + this.sendStatus({ + state: 'error', + message: String((error as Error)?.message ?? error), + userInitiated: true + }) + } finally { + this.pinnedBuildSelectionInProgress = false + } + } +} diff --git a/src/main/updater/updater-check-failure.ts b/src/main/updater/updater-check-failure.ts new file mode 100644 index 00000000000..8ee2500c6a9 --- /dev/null +++ b/src/main/updater/updater-check-failure.ts @@ -0,0 +1,118 @@ +import { isBenignCheckFailure } from '../updater-fallback' +import { ReleaseFeedPreflightError } from './updater-state' +import type { CheckFailureSource } from './updater-state' +import { UpdaterReleaseFeed } from './updater-release-feed' + +/** Normalizes check failures, retry policy, and release-feed preflight diagnostics. */ +export abstract class UpdaterCheckFailure extends UpdaterReleaseFeed { + protected isRetryableReleaseFeedPreflightFailure(sourceError: unknown): boolean { + return ( + sourceError instanceof ReleaseFeedPreflightError && + (sourceError.reason === 'release-not-ready' || sourceError.reason === 'manifest-unavailable') + ) + } + + protected isStableReleaseNotReadyFailure(sourceError: unknown): boolean { + return ( + sourceError instanceof ReleaseFeedPreflightError && + sourceError.reason === 'release-not-ready' && + sourceError.releaseChannel === 'default' + ) + } + + protected async sendCheckFailureStatus( + message: string, + userInitiated?: boolean, + source: CheckFailureSource = 'promise', + sourceError?: unknown + ): Promise { + if (this.activeUpdateSource === 'local') { + this.sendLocalBuildErrorAndRestore(message, userInitiated) + return + } + if (this.isPinnedBuildActive) { + // Why: a failed pinned jump must hand the feed back before surfacing the error, or the pin blocks background checks for the process lifetime. + this.clearAvailableUpdateContext() + this.restoreReleaseUpdateSource() + this.sendStatus({ state: 'error', message, userInitiated }) + return + } + const failureKey = this.getCheckFailureKey(message, userInitiated) + if ( + source === 'promise' && + this.pendingPrereleaseFallback?.suppressedPrimaryPromiseFailureKey === failureKey + ) { + this.pendingPrereleaseFallback.suppressedPrimaryPromiseFailureKey = null + this.clearPrereleaseFallbackContextIfSettled() + return + } + if ( + source === 'fallback-promise' && + this.pendingPrereleaseFallback?.suppressedFallbackPromiseFailureKey === failureKey + ) { + this.pendingPrereleaseFallback.suppressedFallbackPromiseFailureKey = null + this.clearPrereleaseFallbackContextIfSettled() + return + } + if ( + this.retryPrereleaseFallbackAfterMissingManifest( + message, + userInitiated, + source, + failureKey, + sourceError + ) + ) { + return + } + if (this.pendingCheckFailureKey === failureKey && this.pendingCheckFailurePromise) { + return this.pendingCheckFailurePromise + } + + const handleFailure = async (): Promise => { + if ( + isBenignCheckFailure(message) || + this.isRetryableReleaseFeedPreflightFailure(sourceError) + ) { + // Why: benign failures (incomplete latest.yml, network blips) are transient — retry, and skip persisting the timestamp (would suppress the next startup check). + console.warn('[updater] benign check failure:', message) + this.clearAvailableUpdateContext() + this.scheduleAutomaticUpdateCheck(this.getAutomaticRetryInterval()) + if (userInitiated) { + // Why: a user click needs visible feedback (idle looks broken); distinguish incomplete releases from transport failures. + this.sendErrorStatus( + this.isStableReleaseNotReadyFailure(sourceError) + ? "A newer release isn't available for this device yet. Check again later." + : "Couldn't reach the update server. Try again in a few minutes.", + true + ) + } else { + if (this.isRetryableReleaseFeedPreflightFailure(sourceError)) { + // Why: release probes can fail transiently; keep the campaign pending so the short retry can still show it. + this.deferPendingUpdateNudgeUntilRetry() + } + this.sendStatus({ state: 'idle' }) + } + return + } + this.clearAvailableUpdateContext() + this.persistLastUpdateCheckAt?.(Date.now()) + if (!userInitiated) { + this.scheduleAutomaticUpdateCheck(this.getAutomaticRetryInterval()) + } + this.sendErrorStatus(message, userInitiated) + } + + this.pendingCheckFailureKey = failureKey + this.pendingCheckFailurePromise = handleFailure().finally(() => { + if (this.pendingCheckFailureKey === failureKey) { + this.pendingCheckFailureKey = null + this.pendingCheckFailurePromise = null + } + }) + return this.pendingCheckFailurePromise + } + + /** Keeps retry interval access in one place for the scheduling layer. */ + protected abstract getAutomaticRetryInterval(): number +} diff --git a/src/main/updater/updater-check-state.ts b/src/main/updater/updater-check-state.ts new file mode 100644 index 00000000000..8905029c75d --- /dev/null +++ b/src/main/updater/updater-check-state.ts @@ -0,0 +1,298 @@ +import { writeMainThreadDiagnosticMarker } from '../diagnostics/main-thread-churn-probe' +import { isWindowsSignatureCheckUnavailableFailure } from '../../shared/updater-windows-signature-check' +import { recordUpdaterLifecycle } from '../updater-lifecycle-diagnostics' +import type { UpdateCheckOptions, UpdateStatus } from '../../shared/update-status-types' +import type { UpdateCheckVariant } from './updater-types' +import { UpdaterStatus } from './updater-status' +import { + AUTO_UPDATE_CHECK_INTERVAL_MS, + AUTO_UPDATE_RETRY_INTERVAL_MS, + UPDATE_CHECK_SILENT_SETTLE_DELAY_MS, + UPDATE_CHECK_STALL_TIMEOUT_MS +} from './updater-state' + +export abstract class UpdaterCheckState extends UpdaterStatus { + protected getOptionsForUpdateCheckVariant(variant: UpdateCheckVariant): UpdateCheckOptions { + switch (variant) { + case 'perf': + return { includePrerelease: true, includePerfPrerelease: true } + case 'prerelease': + return { includePrerelease: true } + case 'default': + return { includePrerelease: false } + } + } + + protected getUpdateCheckVariant(options?: UpdateCheckOptions): UpdateCheckVariant { + if (options?.includePerfPrerelease) { + return 'perf' + } + if (options?.includePrerelease) { + return 'prerelease' + } + // Why: a persisted 'rc' override makes every routine check follow the RC series + // without the user re-holding shift; the dev channels need an explicit tag, so + // neither is a routine-check variant. + if (this.getReleaseChannelOverride?.() === 'rc') { + return 'prerelease' + } + return 'default' + } + + protected launchPendingUserInitiatedCheckAfterInFlight(variant: UpdateCheckVariant): void { + this.pendingUserInitiatedCheckAfterInFlight = null + setTimeout(() => { + // Why: defer one tick after electron-updater clears its in-flight promise so the queued modifier check starts fresh instead of deduping into the stable one. + if (this.currentStatus.state === 'checking') { + this.currentStatus = { state: 'idle' } + } + this.checkForUpdatesFromMenu(this.getOptionsForUpdateCheckVariant(variant)) + }, 0) + } + + protected clearBackgroundCheckLaunchPending(): void { + this.backgroundCheckLaunchPending = false + } + + protected clearUpdateCheckStallTimer(): void { + if (!this.updateCheckStallTimer) { + return + } + clearTimeout(this.updateCheckStallTimer) + this.updateCheckStallTimer = null + } + + protected clearUpdateCheckSilentSettleTimer(): void { + if (!this.updateCheckSilentSettleTimer) { + return + } + clearTimeout(this.updateCheckSilentSettleTimer) + this.updateCheckSilentSettleTimer = null + } + + protected clearUpdateCheckTimers(): void { + this.clearUpdateCheckStallTimer() + this.clearUpdateCheckSilentSettleTimer() + } + + protected finishActiveUpdateCheckAttempt(): void { + this.activeUpdateCheckAttemptId = null + this.activeUpdateCheckLaunchAttemptId = null + this.activeUpdateCheckEventAttemptId = null + this.clearUpdateCheckTimers() + } + + protected getActiveUpdateCheckEventAttemptId(): number | null { + if (this.activeUpdateCheckAttemptId === null) { + return null + } + if (this.activeUpdateCheckEventAttemptId !== this.activeUpdateCheckAttemptId) { + return null + } + return this.activeUpdateCheckAttemptId + } + + protected isActiveUpdateCheckAttempt(attemptId: number): boolean { + return this.activeUpdateCheckAttemptId === attemptId + } + + protected markUpdateCheckEventAttempt(): boolean { + if (this.activeUpdateCheckAttemptId === null) { + return false + } + if (this.activeUpdateCheckLaunchAttemptId !== this.activeUpdateCheckAttemptId) { + return false + } + this.activeUpdateCheckEventAttemptId = this.activeUpdateCheckAttemptId + return true + } + + protected markUpdateCheckLaunched(attemptId: number): void { + if (!this.isActiveUpdateCheckAttempt(attemptId)) { + return + } + this.activeUpdateCheckLaunchAttemptId = attemptId + } + + protected markUpdateAvailableEventPending(attemptId: number | null): void { + this.updateAvailableEventPendingAttemptId = attemptId + } + + protected clearUpdateAvailableEventPending(attemptId: number | null): void { + if (this.updateAvailableEventPendingAttemptId !== attemptId) { + return + } + this.updateAvailableEventPendingAttemptId = null + } + + protected armUpdateCheckStallTimer(attemptId: number): void { + this.clearUpdateCheckStallTimer() + this.updateCheckStallTimer = setTimeout(() => { + this.updateCheckStallTimer = null + if (!this.isActiveUpdateCheckAttempt(attemptId)) { + return + } + const wasUserInitiated = this.getSettledCheckUserInitiated() + if (this.currentStatus.state === 'checking') { + this.finishActiveUpdateCheckAttempt() + this.backgroundCheckLaunchPending = false + this.backgroundCheckPromotedToUserInitiated = false + this.userInitiatedCheck = false + void this.sendCheckFailureStatus( + 'Update check timed out. Try again in a few minutes.', + wasUserInitiated, + 'promise' + ) + return + } + if (this.backgroundCheckLaunchPending) { + this.finishActiveUpdateCheckAttempt() + this.backgroundCheckLaunchPending = false + this.backgroundCheckPromotedToUserInitiated = false + this.userInitiatedCheck = false + this.scheduleAutomaticUpdateCheck(AUTO_UPDATE_RETRY_INTERVAL_MS) + } + }, UPDATE_CHECK_STALL_TIMEOUT_MS) + } + + protected beginUpdateCheckAttempt(): number { + this.finishActiveUpdateCheckAttempt() + this.updateAvailableEventPendingAttemptId = null + this.updateCheckAttemptSequence += 1 + this.activeUpdateCheckAttemptId = this.updateCheckAttemptSequence + this.armUpdateCheckStallTimer(this.activeUpdateCheckAttemptId) + // Why: issue #7576 warnings recurred at retry cadence; timestamp each attempt to confirm or rule out the updater. + writeMainThreadDiagnosticMarker('updater-check-attempt') + return this.activeUpdateCheckAttemptId + } + + protected rearmActiveUpdateCheckStallTimer(): void { + if (this.activeUpdateCheckAttemptId === null) { + return + } + this.armUpdateCheckStallTimer(this.activeUpdateCheckAttemptId) + } + + protected getSettledCheckUserInitiated(): boolean | undefined { + return this.userInitiatedCheck || this.backgroundCheckPromotedToUserInitiated || undefined + } + + protected isUpdateCheckResultState(state: UpdateStatus['state']): boolean { + return ( + state === 'idle' || + state === 'not-available' || + state === 'available' || + state === 'error' || + state === 'downloading' || + state === 'downloaded' + ) + } + + protected consumeSilentCheckShortRetryReason(): boolean { + if (this.publishingWindowLastGoodCheck !== null) { + return true + } + return this.consumeMissingManifestPrereleaseFallbackResult() !== null + } + + protected completeSilentUpdateCheck(userInitiated: boolean | undefined): boolean { + const shouldRetrySoon = this.consumeSilentCheckShortRetryReason() + this.clearAvailableUpdateContext() + if (shouldRetrySoon) { + // Why: a silent result against a temporary last-good feed is still a release transition, so it must not suppress the short publish retry. + this.scheduleAutomaticUpdateCheck(AUTO_UPDATE_RETRY_INTERVAL_MS) + return true + } + this.recordCompletedUpdateCheck() + if (!userInitiated) { + this.scheduleAutomaticUpdateCheck(AUTO_UPDATE_CHECK_INTERVAL_MS) + } + return false + } + + protected settleSilentUpdateCheck(attemptId: number, userInitiated: boolean | undefined): void { + if (!this.isActiveUpdateCheckAttempt(attemptId)) { + return + } + if (this.updateAvailableEventPendingAttemptId === attemptId) { + return + } + if (this.currentStatus.state !== 'checking') { + if (this.backgroundCheckLaunchPending) { + this.finishActiveUpdateCheckAttempt() + this.clearBackgroundCheckLaunchPending() + this.backgroundCheckPromotedToUserInitiated = false + this.userInitiatedCheck = false + const shouldRetrySoon = this.completeSilentUpdateCheck(userInitiated) + if (this.awaitingNudgeCheckOutcome) { + if (shouldRetrySoon) { + this.deferPendingUpdateNudgeUntilRetry() + return + } + this.sendStatus({ state: 'not-available', userInitiated }) + } + } + return + } + this.finishActiveUpdateCheckAttempt() + this.clearBackgroundCheckLaunchPending() + this.backgroundCheckPromotedToUserInitiated = false + this.userInitiatedCheck = false + this.completeSilentUpdateCheck(userInitiated) + this.sendStatus({ state: 'not-available', userInitiated }) + } + + protected handleSettledUpdateCheckPromise(attemptId: number): void { + if (!this.isActiveUpdateCheckAttempt(attemptId)) { + return + } + this.clearUpdateCheckSilentSettleTimer() + // Why: electron-updater can resolve before the terminal event arrives; grace-period it, then unstick checks that resolved without one. + this.updateCheckSilentSettleTimer = setTimeout(() => { + this.updateCheckSilentSettleTimer = null + this.settleSilentUpdateCheck(attemptId, this.getSettledCheckUserInitiated()) + }, UPDATE_CHECK_SILENT_SETTLE_DELAY_MS) + } + + protected shouldHandleUpdaterErrorEvent(): boolean { + if (this.getActiveUpdateCheckEventAttemptId() !== null) { + return true + } + // Why: electron-updater emits check errors globally; once a check settles, only active download/install flows should consume them. + return ( + this.downloadInFlight || + this.currentStatus.state === 'downloading' || + this.currentStatus.state === 'downloaded' + ) + } + + protected sendErrorStatus(message: string, userInitiated?: boolean): void { + if ( + this.currentStatus.state === 'error' && + this.currentStatus.message === message && + this.currentStatus.userInitiated === userInitiated + ) { + return + } + // Why: count AV/EDR-blocked Windows signature checks in the field to size the affected cohort before bigger updater changes. + if (isWindowsSignatureCheckUnavailableFailure(message)) { + recordUpdaterLifecycle('windows_signature_check_blocked', undefined, { + level: 'warn', + message: 'Windows update signature check could not run' + }) + } + this.sendStatus({ state: 'error', message, userInitiated }) + } + + protected abstract consumeMissingManifestPrereleaseFallbackResult(): { + userInitiated: boolean + } | null + protected abstract recordCompletedUpdateCheck(): void + protected abstract sendCheckFailureStatus( + message: string, + userInitiated?: boolean, + source?: 'event' | 'promise' | 'fallback-promise', + sourceError?: unknown + ): Promise + protected abstract scheduleAutomaticUpdateCheck(delayMs: number): void +} diff --git a/src/main/updater/updater-download-install.ts b/src/main/updater/updater-download-install.ts new file mode 100644 index 00000000000..a1627d3895c --- /dev/null +++ b/src/main/updater/updater-download-install.ts @@ -0,0 +1,93 @@ +import { beginMacUpdateDownload, deferMacQuitUntilInstallerReady } from '../updater-mac-install' +import { recordUpdaterLifecycle } from '../updater-lifecycle-diagnostics' +import { QUIT_AND_INSTALL_DELAY_MS } from './updater-state' +import { UpdaterRemoteStatus } from './updater-remote-status' + +/** Coordinates renderer-facing download/install actions and their duplicate guards. */ +export abstract class UpdaterDownloadInstall extends UpdaterRemoteStatus { + protected quitAndInstall(): void { + if ( + this.localBuildSelectionInProgress || + this.pinnedBuildSelectionInProgress || + this.pendingQuitAndInstallTimer || + this.quitAndInstallInProgress || + // Why: the quit timer is already cleared while the pre-install digest re-proof streams, so without this a second click would schedule a parallel install of the same package. + this.linuxPackageRevalidationInFlight + ) { + return + } + + const retriedRecovery = this.getActiveLinuxPackageRecovery() + if (retriedRecovery) { + recordUpdaterLifecycle('linux_package_recovery_requested', { + action: 'retry-automatic', + packageType: retriedRecovery.packageType, + version: retriedRecovery.version + }) + } + + if (this.deferHeadlessServeInstall('install', this.getPendingInstallVersion())) { + return + } + if ( + deferMacQuitUntilInstallerReady( + this.currentStatus, + this.hasInstallableDownloadedVersion(), + () => this.getPendingInstallVersion(), + (status) => this.sendStatus(status) + ) + ) { + return + } + + // Why: defer the quit a tick so the renderer can flush dismissals/state before windows start closing. + this.pendingQuitAndInstallTimer = setTimeout(() => { + void this.performQuitAndInstall() + }, QUIT_AND_INSTALL_DELAY_MS) + } + + protected downloadUpdate(): void { + if ( + this.localBuildSelectionInProgress || + this.pinnedBuildSelectionInProgress || + this.downloadInFlight + ) { + return + } + // Why: allow retry from 'error' (availableVersion stays cached) so the error card's Retry Download button works. + const canStart = + this.currentStatus.state === 'available' || + (this.currentStatus.state === 'error' && this.hasInstallableDownloadedVersion()) + if (!canStart) { + return + } + const version = + this.currentStatus.state === 'available' ? this.currentStatus.version : this.availableVersion + if (!version) { + return + } + if (this.deferHeadlessServeInstall('download', version)) { + return + } + this.downloadInFlight = true + const localBuildDownload = this.activeUpdateSource === 'local' + beginMacUpdateDownload() + // Why: setup can take seconds before progress emits; surface acceptance now so the action never looks inert. + this.sendStatus({ state: 'downloading', percent: 0, version }) + this.getAutoUpdater() + .downloadUpdate() + .catch((err) => { + this.downloadInFlight = false + const message = String(err?.message ?? err) + if (localBuildDownload) { + this.sendLocalBuildErrorAndRestore(message) + } else { + this.sendErrorStatus(message) + } + }) + } + + protected isQuittingForUpdate(): boolean { + return this.quittingForUpdate + } +} diff --git a/src/main/updater/updater-install-execution.ts b/src/main/updater/updater-install-execution.ts new file mode 100644 index 00000000000..4522e155865 --- /dev/null +++ b/src/main/updater/updater-install-execution.ts @@ -0,0 +1,228 @@ +import { BrowserWindow } from 'electron' +import { killAllPty } from '../ipc/pty' +import { withUpdaterSpan } from '../observability/instrumentation' +import { runWithLaunchPath } from '../startup/hydrate-shell-path' +import { markMacQuitAndInstallInFlight, isMacInstallerReady } from '../updater-mac-install' +import { armUpdateInstallExitWatchdog } from '../update-install-exit-watchdog' +import { getLinuxRootPackageType } from '../linux-update-package-type' +import { + beginLinuxPackageInstallDiagnosticCapture, + endLinuxPackageInstallDiagnosticCapture +} from '../linux-package-install-diagnostic' +import { getTrackedLinuxPackageArtifact } from '../linux-package-update-recovery' +import { recordUpdaterLifecycle } from '../updater-lifecycle-diagnostics' +import { requestServeUpdateHandoff, failServeUpdateHandoff } from '../serve-update-handoff' +import { UpdaterPackageRecovery } from './updater-package-recovery' + +export abstract class UpdaterInstallExecution extends UpdaterPackageRecovery { + protected async performQuitAndInstall(): Promise { + if (this.quitAndInstallInProgress || this.linuxPackageRevalidationInFlight) { + recordUpdaterLifecycle('quit_and_install_ignored', { reason: 'already-in-progress' }) + return + } + + if (this.pendingQuitAndInstallTimer) { + clearTimeout(this.pendingQuitAndInstallTimer) + this.pendingQuitAndInstallTimer = null + } + + const pendingVersion = this.getPendingInstallVersion() + if (this.deferHeadlessServeInstall('install', pendingVersion)) { + return + } + // Why: the retained .deb/.rpm sits on a user-writable path that a root package manager is about + // to read, and nothing re-checks it after download. Re-prove it here — before any teardown — so a + // swapped or vanished package aborts instead of being installed as root. The synchronous guard + // keeps every non-Linux install on its existing timing. + if ( + getTrackedLinuxPackageArtifact() && + !(await this.proveRetainedLinuxPackage(pendingVersion)) + ) { + // Why: the renderer armed its restart before invoking, and it infers the abort from the error + // status — which a stale-cycle verdict deliberately withholds. Signal the abandon here, where + // it cannot depend on that decision, or the window keeps skipping its unsaved-work prompt. + this.mainWindowRef?.webContents.send('updater:quitAndInstallAborted') + return + } + this.quitAndInstallInProgress = true + + markMacQuitAndInstallInFlight() + + // Set BEFORE anything else so the `activate` handler doesn't reopen the old version while ShipIt replaces the .app bundle. + this.quittingForUpdate = true + + try { + await withUpdaterSpan({ stage: 'install' }, async (span) => { + span.setAttribute('updater.version', pendingVersion || 'unknown') + span.setAttribute('updater.platform', process.platform) + span.setAttribute( + 'updater.macosInstallerReady', + process.platform === 'darwin' ? isMacInstallerReady() : true + ) + recordUpdaterLifecycle('quit_and_install_started', { + version: pendingVersion || null, + macInstallerReady: process.platform === 'darwin' ? isMacInstallerReady() : true + }) + span.addEvent('pre_quit_cleanup_start') + await this.runBeforeUpdateQuitCleanup() + span.addEvent('pre_quit_cleanup_done') + + if ( + this.updateInstallMode === 'supervised-headless-serve' && + !requestServeUpdateHandoff(pendingVersion) + ) { + recordUpdaterLifecycle( + 'headless_serve_handoff_failed', + { version: pendingVersion || null }, + { + level: 'warn', + message: 'Could not persist supervised serve update handoff' + } + ) + this.sendErrorStatus( + 'Could not prepare the supervised server restart. Orca remains running.', + true + ) + this.resetQuitForUpdateState() + // Why: a bare return would exit this span Success and hide the aborted install from tracing. + span.fail('Could not persist the supervised serve update handoff') + return + } + + recordUpdaterLifecycle('quit_and_install_invoking_native', { + version: pendingVersion || null + }) + // Why: defensive — never call quitAndInstall if recovery/reset already cleared the handoff. + if (!this.quitAndInstallInProgress) { + return + } + // Why: mark before the call so a sync 'error' during quitAndInstall can recover; pre-native errors must not look like install failure. + this.quitAndInstallNativeInvoked = true + // Why: invoke before killAllPty/removing close listeners so a sync 'error' (the "no filepath" path) can recover while windows and PTYs are intact. + const supervisorOwnsRelaunch = this.updateInstallMode === 'supervised-headless-serve' + // Why: BaseUpdater logs child stderr but drops it from the 'error' event, so retain it for the span of this call. + beginLinuxPackageInstallDiagnosticCapture(getTrackedLinuxPackageArtifact()?.path ?? null) + try { + runWithLaunchPath(() => + this.getAutoUpdater().quitAndInstall(supervisorOwnsRelaunch, !supervisorOwnsRelaunch) + ) + } finally { + const diagnostic = endLinuxPackageInstallDiagnosticCapture() + // Why: a synchronous 'error' already consumed and reset this attempt; re-stashing would leak it into the next one. + this.lastInstallAttemptDiagnostic = this.quitAndInstallInProgress ? diagnostic : null + } + span.addEvent('native_quit_and_install_invoked') + + // Why: quitAndInstall can synchronously clear quitAndInstallInProgress via recovery (Win/Linux dispatchError); skip destructive prep if it already ran. + if (!this.quitAndInstallInProgress) { + // Why: recovery already wrote the reason to currentStatus; a bare return would exit this span Success. + span.fail( + this.currentStatus.state === 'error' + ? this.currentStatus.message + : 'quitAndInstall returned without invoking the installer' + ) + return + } + + // Why: DebUpdater/RpmUpdater install through spawnSync, so a normal return already means the + // package is installed. Commit here or a throw in the cleanup below is reported as an install + // failure — offering a recovery card, and stale stderr, for an update that actually succeeded. + if (getLinuxRootPackageType() !== null) { + this.updateInstallCommitted = true + armUpdateInstallExitWatchdog() + } + + killAllPty() + span.addEvent('local_pty_kill_all') + + for (const win of BrowserWindow.getAllWindows()) { + win.removeAllListeners('close') + } + span.addEvent('window_close_listeners_removed', { + windowCount: BrowserWindow.getAllWindows().length + }) + + // Why: committed installs keep quittingForUpdate so dock activate can't reopen the old process; macOS without Squirrel stays uncommitted so late native errors can still recover. + if ( + !this.updateInstallCommitted && + (process.platform !== 'darwin' || isMacInstallerReady()) + ) { + this.updateInstallCommitted = true + // Why: past commit the installer waits for this process to exit; a wedged async shutdown would strand the user with no app and no update (#4438). + armUpdateInstallExitWatchdog() + } + }) + } catch (error) { + // Why: on Linux the package is already installed once quitAndInstall returns, and the installer is + // waiting for this process to exit. Tearing down here would disarm the exit watchdog (#4438), clear + // quittingForUpdate mid-quit, and tell the user an install failed that actually succeeded. + if (this.updateInstallCommitted) { + recordUpdaterLifecycle( + 'post_commit_cleanup_failed', + { errorType: error instanceof Error ? error.name : typeof error }, + { + level: 'warn', + message: 'Update install cleanup failed after commit; install already applied' + } + ) + return + } + // Why: a pre-native cleanup/tracing exception is not a package install failure and must not be labelled as one. + const quitAndInstallNativeInvokedBeforeReset = this.quitAndInstallNativeInvoked + const recoveryStatus = + quitAndInstallNativeInvokedBeforeReset && !this.updateInstallCommitted + ? this.buildLinuxPackageInstallFailureStatus(error) + : null + failServeUpdateHandoff('Could not invoke the native updater.') + this.resetQuitForUpdateState() + recordUpdaterLifecycle( + 'quit_and_install_failed', + { errorType: error instanceof Error ? error.name : typeof error }, + { + level: 'warn', + message: 'Could not start update install' + } + ) + this.sendInstallFailureStatus( + recoveryStatus ?? { + state: 'error', + // Why: past the native invoke this is the same pre-commit failure the event path reports, so it gets the same copy; only a pre-native exception can be helped by a restart. + // A synchronous throw out of quitAndInstall carries the same installer text the 'error' event would have. + message: quitAndInstallNativeInvokedBeforeReset + ? this.withInstallFailureCause(this.getPreCommitInstallFailureMessage(), error) + : 'Could not restart to install the update. Quit and reopen Orca, then try again.' + } + ) + } + } + + // Why: quitAndInstall failures arrive via 'error'; recover only after native invoke and before commit, else clearing quittingForUpdate lets dock activate reopen the old process mid-installer. + protected handleQuitAndInstallFailure(error?: unknown): boolean { + if ( + !this.quitAndInstallInProgress || + !this.quitAndInstallNativeInvoked || + this.updateInstallCommitted + ) { + return false + } + const recoveryStatus = this.buildLinuxPackageInstallFailureStatus(error) + failServeUpdateHandoff('The native updater rejected the install request.') + this.resetQuitForUpdateState() + // Durable data carries classification only — the cause text stays on the status the user can read. + recordUpdaterLifecycle( + 'quit_and_install_failed_via_event', + { errorType: error instanceof Error ? error.name : typeof error }, + { + level: 'warn', + message: 'Update install could not start; recovered app state' + } + ) + this.sendInstallFailureStatus( + recoveryStatus ?? { + state: 'error', + message: this.withInstallFailureCause(this.getPreCommitInstallFailureMessage(), error) + } + ) + return true + } +} diff --git a/src/main/updater/updater-install-support.ts b/src/main/updater/updater-install-support.ts new file mode 100644 index 00000000000..048f293f01e --- /dev/null +++ b/src/main/updater/updater-install-support.ts @@ -0,0 +1,171 @@ +import { app } from 'electron' +import { + isWindowsSignatureCheckUnavailableFailure, + isWindowsSignatureMismatchFailure +} from '../../shared/updater-windows-signature-check' +import { redactLinuxPackageInstallText } from '../linux-package-install-diagnostic' +import { getTrackedLinuxPackageArtifact } from '../linux-package-update-recovery' +import { recordUpdaterLifecycle } from '../updater-lifecycle-diagnostics' +import { disarmUpdateInstallExitWatchdog } from '../update-install-exit-watchdog' +import { resetMacInstallState } from '../updater-mac-install' +import type { LinuxPackageInstallRecovery, UpdateStatus } from '../../shared/update-status-types' +import { compareVersions } from '../updater-fallback' +import { PRE_QUIT_CLEANUP_TIMEOUT_MS } from './updater-state' +import { UpdaterCheckState } from './updater-check-state' + +export abstract class UpdaterInstallSupport extends UpdaterCheckState { + protected getKnownReleaseUrl(): string | undefined { + return this.availableReleaseUrl ?? undefined + } + + protected hasInstallableDownloadedVersion(): boolean { + return ( + this.availableVersion !== null && + // Why: local builds and pinned dev jumps may intentionally move backwards. + (this.activeUpdateSource !== 'release' || + this.isPinnedBuildActive || + compareVersions(this.availableVersion, app.getVersion()) > 0) + ) + } + + protected getPendingInstallVersion(): string { + if (this.availableVersion) { + return this.availableVersion + } + if (this.currentStatus.state === 'downloading' || this.currentStatus.state === 'downloaded') { + return this.currentStatus.version + } + return '' + } + + protected deferHeadlessServeInstall(phase: 'download' | 'install', version: string): boolean { + if (this.updateInstallMode !== 'unsupported-headless-serve') { + return false + } + const diagnosticVersion = version || 'unknown' + if (this.lastInstallDeferralVersion[phase] !== diagnosticVersion) { + this.lastInstallDeferralVersion[phase] = diagnosticVersion + recordUpdaterLifecycle( + 'headless_serve_install_deferred', + { phase, version: version || null }, + { + level: 'warn', + message: 'Update install deferred while hosting orca serve' + } + ) + } + this.sendErrorStatus( + 'This orca serve process was not started by an update-capable supervisor. Keep it running and update Orca through its service manager.', + true + ) + return true + } + + protected getCheckFailureKey(message: string, userInitiated?: boolean): string { + return `${userInitiated ? 'user' : 'auto'}:${message}` + } + + protected resetQuitForUpdateState(): void { + this.quitAndInstallInProgress = false + this.quittingForUpdate = false + this.updateInstallCommitted = false + this.quitAndInstallNativeInvoked = false + this.lastInstallAttemptDiagnostic = null + disarmUpdateInstallExitWatchdog() + resetMacInstallState() + } + + /** + * On macOS a pre-commit failure means Squirrel rejected the staged update, and quitting does re-stage + * it — so keep that advice there. Everywhere else a restart is not known to help. + */ + protected getPreCommitInstallFailureMessage(): string { + return process.platform === 'darwin' + ? 'Could not restart to install the update. Quit and reopen Orca, then try again.' + : 'Could not start the update installer. Orca remains open.' + } + + /** + * Sends an install-failure status even when it repeats the current one. "Try Automatic Install + * Again" usually fails identically, and a deduped status would never reach the preload abort relay, + * leaving the renderer stuck in its restart checkpoint. + */ + protected sendInstallFailureStatus(status: UpdateStatus): void { + this.sendStatus(status, { force: true }) + } + + /** + * Appends the updater's own text to the generic install-failure copy. Without it the only record of + * why the install never started is destroyed — on Linux that text carries the exact `dpkg -i ` + * command the user has to run by hand, and remote clients get nothing but "it didn't come back". + */ + protected withInstallFailureCause(baseMessage: string, error: unknown): string { + const raw = error instanceof Error ? error.message : typeof error === 'string' ? error : '' + // Why: the retained-package card runs its text through this same sanitizer, so a home directory, + // user name, or terminal escape must not reach the card merely because no artifact was tracked. + const redacted = + redactLinuxPackageInstallText(raw, getTrackedLinuxPackageArtifact()?.path ?? null) ?? '' + const cause = redacted.slice(0, this.installFailureCauseMaxLength) + if (!cause || cause === 'Unknown error') { + return baseMessage + } + // Why: UpdateCard picks the whole card off this string, so a signature verdict must not be prefixed by contradictory restart advice. + if ( + isWindowsSignatureCheckUnavailableFailure(cause) || + isWindowsSignatureMismatchFailure(cause) + ) { + return cause + } + return `${baseMessage} (${cause})` + } + + /** + * The recovery status for a failed `.deb`/`.rpm` install, or null when no retained package can + * recover it. Must run before `resetQuitForUpdateState()` clears the attempt diagnostic. + */ + protected isQuitAndInstallHandoffActive(): boolean { + return this.quitAndInstallInProgress + } + + protected async runBeforeUpdateQuitCleanup(): Promise { + if (!this.onBeforeQuitCleanup) { + return + } + + let timeout: ReturnType | null = null + const cleanup = Promise.resolve() + .then(() => this.onBeforeQuitCleanup?.()) + .catch((error) => { + recordUpdaterLifecycle( + 'pre_quit_cleanup_failed', + { errorType: error instanceof Error ? error.name : typeof error }, + { + level: 'warn', + message: 'Pre-quit cleanup failed; continuing update install' + } + ) + }) + const timeoutResult = new Promise<'timeout'>((resolve) => { + timeout = setTimeout(() => resolve('timeout'), PRE_QUIT_CLEANUP_TIMEOUT_MS) + }) + + const result = await Promise.race([cleanup.then(() => 'done' as const), timeoutResult]) + if (result === 'timeout') { + recordUpdaterLifecycle( + 'pre_quit_cleanup_timeout', + { timeoutMs: PRE_QUIT_CLEANUP_TIMEOUT_MS }, + { + level: 'warn', + message: `Pre-quit cleanup exceeded ${PRE_QUIT_CLEANUP_TIMEOUT_MS}ms; continuing update install` + } + ) + return + } + + if (timeout) { + clearTimeout(timeout) + } + } + + protected abstract getActiveLinuxPackageRecovery(): LinuxPackageInstallRecovery | null +} diff --git a/src/main/updater/updater-menu-checks.ts b/src/main/updater/updater-menu-checks.ts new file mode 100644 index 00000000000..b76d5c19710 --- /dev/null +++ b/src/main/updater/updater-menu-checks.ts @@ -0,0 +1,100 @@ +import { app } from 'electron' +import { is } from '@electron-toolkit/utils' +import type { UpdateCheckOptions } from '../../shared/update-status-types' +import type { ReleaseChannel } from '../../shared/release-channel' +import { UpdaterScheduling } from './updater-scheduling' + +/** Handles checks initiated from the desktop menu and modifier-key variants. */ +export abstract class UpdaterMenuChecks extends UpdaterScheduling { + protected checkForUpdatesFromMenu(options?: UpdateCheckOptions): void { + if (!app.isPackaged || is.dev) { + this.sendStatus({ state: 'not-available', userInitiated: true }) + return + } + if (options?.localBuild) { + void this.checkForLocalBuildFromMenu() + return + } + if (options?.targetTag && options.channel) { + void this.checkForPinnedBuild(options.channel, options.targetTag) + return + } + if (this.localBuildSelectionInProgress || this.pinnedBuildSelectionInProgress) { + return + } + if ( + this.activeUpdateSource !== 'release' && + (this.currentStatus.state === 'checking' || this.currentStatus.state === 'downloading') + ) { + return + } + this.restoreReleaseUpdateSource() + + const checkVariant = this.getUpdateCheckVariant(options) + if (checkVariant === 'prerelease') { + this.clearPrereleaseFallbackContext() + this.enableIncludePrerelease() + } else if (checkVariant === 'perf') { + this.clearPrereleaseFallbackContext() + // Why: perf checks need prerelease manifests now, but must not opt future default/background checks into the RC channel. + this.enablePrereleaseManifestChecks() + } + + const checkAlreadyInFlight = + this.backgroundCheckLaunchPending || this.currentStatus.state === 'checking' + this.userInitiatedCheck = true + // Why: manual checks are nudge-independent; clear the marker so a later dismiss can't consume the campaign by accident. + this.activeUpdateNudgeId = null + // Why: respond visibly before feed pinning/updater events; duplicate broadcasts are suppressed by status equality below. + this.sendStatus({ state: 'checking', userInitiated: true }) + if (checkAlreadyInFlight) { + this.backgroundCheckPromotedToUserInitiated = true + this.rearmActiveUpdateCheckStallTimer() + if (checkVariant !== 'default') { + // Why: in-flight check may have pinned the stable feed; queue a fresh modifier check to avoid a stale-channel result. + this.pendingUserInitiatedCheckAfterInFlight = checkVariant + } + return + } + + const attemptId = this.beginUpdateCheckAttempt() + const autoUpdater = this.getAutoUpdater() + const launch = (): Promise | undefined => { + if (!this.isActiveUpdateCheckAttempt(attemptId)) { + return undefined + } + this.markUpdateCheckLaunched(attemptId) + return autoUpdater.checkForUpdates() + } + const run = this.pinDefaultReleaseFeed(checkVariant).then((preflightResult) => { + if (preflightResult === 'not-available') { + if (!this.isActiveUpdateCheckAttempt(attemptId)) { + return false + } + this.userInitiatedCheck = false + this.finishActiveUpdateCheckAttempt() + this.recordCompletedUpdateCheck() + this.sendStatus({ state: 'not-available', userInitiated: true }) + return false + } + return launch() + }) + void Promise.resolve(run) + .then((launchResult) => { + if (launchResult === false) { + return + } + this.handleSettledUpdateCheckPromise(attemptId) + }) + .catch((err) => { + if (!this.isActiveUpdateCheckAttempt(attemptId)) { + return + } + this.userInitiatedCheck = false + void this.sendCheckFailureStatus(String(err?.message ?? err), true, 'promise', err) + }) + } + + protected abstract checkForLocalBuildFromMenu(): Promise + protected abstract checkForPinnedBuild(channel: ReleaseChannel, tag: string): Promise +} diff --git a/src/main/updater/updater-nudge.ts b/src/main/updater/updater-nudge.ts new file mode 100644 index 00000000000..765af67cd53 --- /dev/null +++ b/src/main/updater/updater-nudge.ts @@ -0,0 +1,86 @@ +import { app } from 'electron' +import { is } from '@electron-toolkit/utils' +import { fetchNudge, shouldApplyNudge } from '../updater-nudge' +import { NUDGE_ACTIVATION_COOLDOWN_MS, NUDGE_POLL_INTERVAL_MS } from './updater-state' +import { UpdaterBuildSelection } from './updater-build-selection' + +/** Polls update campaigns and exposes their dismissal actions. */ +export abstract class UpdaterNudge extends UpdaterBuildSelection { + protected async checkForUpdateNudge(): Promise { + if (!app.isPackaged || is.dev) { + return + } + if (this.nudgeCheckInFlight) { + return + } + const now = Date.now() + if (now - this.lastNudgeCheckAt < NUDGE_ACTIVATION_COOLDOWN_MS) { + return + } + this.lastNudgeCheckAt = now + this.nudgeCheckInFlight = true + try { + const nudge = await fetchNudge() + if (!nudge) { + return + } + if (this.currentStatus.state === 'checking' || this.currentStatus.state === 'downloading') { + return + } + const appVersion = app.getVersion() + const pendingUpdateNudgeId = this._getPendingUpdateNudgeId?.() ?? null + const dismissedUpdateNudgeId = this._getDismissedUpdateNudgeId?.() ?? null + if ( + shouldApplyNudge({ + nudge, + appVersion, + pendingUpdateNudgeId, + dismissedUpdateNudgeId + }) + ) { + this.awaitingNudgeCheckOutcome = true + this._setPendingUpdateNudgeId?.(nudge.id) + this.mainWindowRef?.webContents.send('updater:clearDismissal') + this.runBackgroundUpdateCheck(nudge.id) + } + } finally { + this.nudgeCheckInFlight = false + } + } + + protected scheduleUpdateNudgeCheck(): void { + if (this.nudgeCheckTimer) { + clearTimeout(this.nudgeCheckTimer) + } + this.nudgeCheckTimer = setTimeout(() => { + void this.checkForUpdateNudge() + this.scheduleUpdateNudgeCheck() + }, NUDGE_POLL_INTERVAL_MS) + } + + protected dismissNudge(): void { + const pendingId = this.activeUpdateNudgeId ?? this._getPendingUpdateNudgeId?.() ?? null + if (pendingId) { + this._setDismissedUpdateNudgeId?.(pendingId) + this.clearPendingUpdateNudge() + } + } + + /** Abandons an un-acted local or pinned update and restores the release feed. */ + protected dismissAvailableUpdate(): void { + if (this.activeUpdateSource === 'release' && !this.isPinnedBuildActive) { + return + } + if (this.localBuildSelectionInProgress || this.pinnedBuildSelectionInProgress) { + return + } + // Why: only an un-acted 'available' card is abandoned — 'downloading'/'downloaded' still need the pinned feed and allowDowngrade. + if (this.currentStatus.state !== 'available') { + return + } + this.clearAvailableUpdateContext() + this.restoreReleaseUpdateSource() + // Why: leaving the card's 'available' status behind would let a retry download the local version off the restored release feed. + this.sendStatus({ state: 'idle' }) + } +} diff --git a/src/main/updater/updater-package-recovery.ts b/src/main/updater/updater-package-recovery.ts new file mode 100644 index 00000000000..870d601a5f3 --- /dev/null +++ b/src/main/updater/updater-package-recovery.ts @@ -0,0 +1,274 @@ +import { recordUpdaterLifecycle } from '../updater-lifecycle-diagnostics' +import { + getTrackedLinuxPackageArtifact, + clearTrackedLinuxPackageArtifact, + revalidateLinuxPackageForInstall, + resolveLinuxPackageInstallInstructions, + revealLinuxPackage, + type LinuxPackageArtifact, + type LinuxPackageRecoveryUnavailableReason +} from '../linux-package-update-recovery' +import { + getLinuxPackageInstallDiagnostic, + parseLinuxPackageInstallExitCode, + redactLinuxPackageInstallText +} from '../linux-package-install-diagnostic' +import type { + LinuxPackageInstallInstructions, + LinuxPackageInstallRecovery, + UpdateStatus +} from '../../shared/update-status-types' +import { UpdaterInstallSupport } from './updater-install-support' + +const LINUX_PACKAGE_RECOVERY_MESSAGES: Record = { + missing: + 'The downloaded package is no longer in the update cache. Download the update again, or get it from the official release page.', + // Why: this reason also covers a path that left the cache (traversal or symlinked parent), so the copy must not promise the file merely changed type. + 'not-regular': + 'The downloaded package is no longer a valid file in the update cache. Download the update again, or get it from the official release page.', + 'hash-mismatch': + 'The downloaded package no longer matches the verified release, so Orca will not hand it to a package manager. Download the update again, or get it from the official release page.', + 'read-failed': + 'Orca could not read the downloaded package. Download the update again, or get it from the official release page.', + 'no-sudo': + 'No sudo command was found in the system directories, so Orca cannot build a safe install command. Show the package and install it with your package manager.', + 'no-package-manager': + 'No supported package manager was found in the system directories, so Orca cannot build a safe install command. Show the package and install it with your package manager.', + // Defensive: capture only ever tracks absolute cache paths, so this reports a bug rather than a machine state. + 'invalid-package-path': + 'The downloaded package is not at a usable path, so Orca cannot build a safe install command. Show the package and install it with your package manager.' +} + +// Why: clearing the artifact alone would leave the renderer's actions enabled; the status must lose its recovery too. +const RECOVERY_CLEARING_REASONS: LinuxPackageRecoveryUnavailableReason[] = [ + 'missing', + 'not-regular', + 'hash-mismatch' +] + +export abstract class UpdaterPackageRecovery extends UpdaterInstallSupport { + protected getActiveLinuxPackageRecovery(): LinuxPackageInstallRecovery | null { + if (this.currentStatus.state !== 'error') { + return null + } + return this.currentStatus.recovery?.kind === 'linux-package-install' + ? this.currentStatus.recovery + : null + } + + protected recordLinuxPackageRecoveryUnavailable( + recovery: LinuxPackageInstallRecovery, + reason: LinuxPackageRecoveryUnavailableReason + ): void { + recordUpdaterLifecycle( + 'linux_package_recovery_unavailable', + { reason, packageType: recovery.packageType, version: recovery.version }, + { level: 'warn', message: 'Linux package recovery action unavailable' } + ) + } + + protected failLinuxPackageRecovery( + recovery: LinuxPackageInstallRecovery, + reason: LinuxPackageRecoveryUnavailableReason + ): never { + this.recordLinuxPackageRecoveryUnavailable(recovery, reason) + const message = LINUX_PACKAGE_RECOVERY_MESSAGES[reason] + // Why: hashing 160 MB takes long enough for a new cycle to land. Acting on a stale verdict would + // destroy the newer artifact and clobber whatever card replaced this one. + const active = this.getActiveLinuxPackageRecovery() + const stillCurrent = + active?.version === recovery.version && active?.packageType === recovery.packageType + if (stillCurrent && RECOVERY_CLEARING_REASONS.includes(reason)) { + clearTrackedLinuxPackageArtifact() + this.sendStatus({ state: 'error', message }) + } + throw new Error(message) + } + + /** + * Identifies the update cycle an install belongs to, so a verdict produced by a multi-second hash + * can be dropped when a newer cycle already replaced the card it would otherwise overwrite. + */ + protected getInstallCycleSignature(): string { + const recovery = this.getActiveLinuxPackageRecovery() + if (recovery) { + return `recovery:${recovery.packageType}:${recovery.version}` + } + return this.currentStatus.state === 'downloaded' + ? `downloaded:${this.currentStatus.version}` + : `state:${this.currentStatus.state}` + } + + /** + * Re-proves the retained package before the install starts. Returns false when the install must be + * abandoned; the artifact is only re-read here, so callers still own every teardown decision. + */ + protected async proveRetainedLinuxPackage(pendingVersion: string): Promise { + const artifact = getTrackedLinuxPackageArtifact() + if (!artifact) { + return true + } + // Why: an artifact retained from another cycle says nothing about the file electron-updater is + // about to install, so proving it would block a legitimate install on an unrelated digest. + if (pendingVersion && pendingVersion !== artifact.version) { + return true + } + const recovery = this.getActiveLinuxPackageRecovery() + const cycle = this.getInstallCycleSignature() + const reason = await this.revalidateRetainedLinuxPackage(artifact) + if (!reason) { + return true + } + this.reportLinuxPackageRevalidationFailure({ artifact, recovery, reason, cycle }) + return false + } + + /** The failing reason, or null when the retained package still matches its release digest. */ + protected async revalidateRetainedLinuxPackage( + artifact: LinuxPackageArtifact + ): Promise { + this.linuxPackageRevalidationInFlight = true + try { + const verdict = await revalidateLinuxPackageForInstall(artifact) + return verdict.ok ? null : verdict.reason + } catch (error) { + recordUpdaterLifecycle( + 'linux_package_revalidation_errored', + { errorType: error instanceof Error ? error.name : typeof error }, + { level: 'warn', message: 'Could not re-verify the retained update package' } + ) + // Why: fail closed — bytes we could not read are bytes we cannot hand to a root installer. + return 'read-failed' + } finally { + // Why: the invariant every install path depends on — a wedged flag would make quitAndInstall + // early-return for the rest of the session. + this.linuxPackageRevalidationInFlight = false + } + } + + protected reportLinuxPackageRevalidationFailure({ + artifact, + recovery, + reason, + cycle + }: { + artifact: LinuxPackageArtifact + recovery: LinuxPackageInstallRecovery | null + reason: LinuxPackageRecoveryUnavailableReason + cycle: string + }): void { + recordUpdaterLifecycle( + 'linux_package_revalidation_failed', + { + action: recovery ? 'retry-automatic' : 'restart-to-install', + packageType: artifact.packageType, + version: artifact.version, + reason + }, + { level: 'warn', message: 'Retained update package failed its pre-install digest check' } + ) + // Why: a package proven bad must not stay tracked, but a download that landed during the hash + // owns the slot now and destroying it would force a needless 160 MB redownload. + const clearsArtifact = RECOVERY_CLEARING_REASONS.includes(reason) + if (clearsArtifact && getTrackedLinuxPackageArtifact() === artifact) { + clearTrackedLinuxPackageArtifact() + } + // Why: same reasoning as failLinuxPackageRecovery — a verdict from a cycle that has since been + // replaced must not clobber whatever card the user is looking at now. + if (this.getInstallCycleSignature() !== cycle) { + return + } + this.sendInstallFailureStatus({ + state: 'error', + message: LINUX_PACKAGE_RECOVERY_MESSAGES[reason], + // Why: an unreadable file is not evidence the bytes changed, so the recovery card and its + // Copy/Show actions survive a transient I/O failure exactly as they do elsewhere. + ...(recovery && !clearsArtifact ? { recovery } : {}) + }) + } + + protected async getLinuxPackageInstallInstructions(): Promise { + const recovery = this.getActiveLinuxPackageRecovery() + if (!recovery) { + throw new Error('No package install recovery is available.') + } + recordUpdaterLifecycle('linux_package_recovery_requested', { + action: 'copy-command', + packageType: recovery.packageType, + version: recovery.version + }) + const result = await resolveLinuxPackageInstallInstructions(recovery) + if (!result.ok) { + // Why: the renderer must distinguish "this machine has no package manager" (keep the card, promote + // Show Package) from "the artifact is gone" (recovery is cleared and the card unmounts). + if (result.reason === 'no-sudo' || result.reason === 'no-package-manager') { + this.recordLinuxPackageRecoveryUnavailable(recovery, result.reason) + return { + ok: false, + reason: result.reason, + message: LINUX_PACKAGE_RECOVERY_MESSAGES[result.reason] + } + } + this.failLinuxPackageRecovery(recovery, result.reason) + } + return { ok: true, command: result.command, packageFileName: result.packageFileName } + } + + protected async showLinuxPackage(): Promise { + const recovery = this.getActiveLinuxPackageRecovery() + if (!recovery) { + throw new Error('No package install recovery is available.') + } + recordUpdaterLifecycle('linux_package_recovery_requested', { + action: 'show-package', + packageType: recovery.packageType, + version: recovery.version + }) + const result = await revealLinuxPackage(recovery) + if (!result.ok) { + this.failLinuxPackageRecovery(recovery, result.reason) + } + } + + /** Builds a recoverable status when the native Linux package installer rejects a retained artifact. */ + protected buildLinuxPackageInstallFailureStatus(error: unknown): UpdateStatus | null { + const artifact = getTrackedLinuxPackageArtifact() + if (!artifact) { + return null + } + const pendingVersion = this.getPendingInstallVersion() + if (pendingVersion && pendingVersion !== artifact.version) { + return null + } + const diagnostic = getLinuxPackageInstallDiagnostic() ?? this.lastInstallAttemptDiagnostic + const reason = diagnostic?.reason ?? 'package-install-failed' + const exitCode = parseLinuxPackageInstallExitCode(error) + recordUpdaterLifecycle( + 'linux_package_install_failed', + { + packageType: artifact.packageType, + reason, + ...(exitCode === null ? {} : { exitCode }), + version: artifact.version, + errorType: error instanceof Error ? error.name : typeof error + }, + { level: 'warn', message: 'Linux package install failed; cached package retained' } + ) + const message = + diagnostic?.message ?? + (error instanceof Error + ? redactLinuxPackageInstallText(error.message, artifact.path) + : null) ?? + 'The system package installer did not start.' + return { + state: 'error', + message, + recovery: { + kind: 'linux-package-install', + packageType: artifact.packageType, + reason, + version: artifact.version + } + } + } +} diff --git a/src/main/updater/updater-release-feed.ts b/src/main/updater/updater-release-feed.ts new file mode 100644 index 00000000000..46a34eb49ed --- /dev/null +++ b/src/main/updater/updater-release-feed.ts @@ -0,0 +1,270 @@ +import { app } from 'electron' +import { + fetchNewerReleaseTagsWithReadiness, + getReleaseDownloadUrl +} from '../updater-prerelease-feed' +import { isMissingUpdateManifestFailure, isPrereleaseVersion } from '../updater-fallback' +import type { CheckFailureSource } from './updater-state' +import type { UpdateCheckVariant } from './updater-types' +import { ReleaseFeedPreflightError } from './updater-state' +import { UpdaterInstallExecution } from './updater-install-execution' + +/** Owns concrete release-feed pinning and the one-shot prerelease fallback. */ +export abstract class UpdaterReleaseFeed extends UpdaterInstallExecution { + protected clearPrereleaseFallbackContextIfSettled(): void { + if ( + this.pendingPrereleaseFallback?.fallbackResultHandled && + !this.pendingPrereleaseFallback.suppressedPrimaryPromiseFailureKey && + !this.pendingPrereleaseFallback.suppressedPrimaryEventFailure && + !this.pendingPrereleaseFallback.suppressedFallbackPromiseFailureKey && + !this.pendingPrereleaseFallback.suppressedFallbackEventFailureKey + ) { + this.clearPrereleaseFallbackContext() + } + } + + protected getMissingManifestPrereleaseFallbackUserInitiated(): boolean | null { + if ( + !this.pendingPrereleaseFallback?.retryLaunched || + this.pendingPrereleaseFallback.fallbackResultHandled + ) { + return null + } + return this.pendingPrereleaseFallback.userInitiated + } + + protected markMissingManifestPrereleaseFallbackChecking(): void { + if ( + !this.pendingPrereleaseFallback?.retryLaunched || + this.pendingPrereleaseFallback.fallbackResultHandled + ) { + return + } + this.pendingPrereleaseFallback.fallbackCheckingForUpdateSeen = true + } + + protected consumeMissingManifestPrereleaseFallbackResult(): { userInitiated: boolean } | null { + if ( + !this.pendingPrereleaseFallback?.retryLaunched || + this.pendingPrereleaseFallback.fallbackResultHandled + ) { + return null + } + const result = { userInitiated: this.pendingPrereleaseFallback.userInitiated } + this.pendingPrereleaseFallback.fallbackResultHandled = true + this.clearPrereleaseFallbackContextIfSettled() + return result + } + + protected suppressMissingManifestPrereleaseFallbackPromiseFailure(message: string): void { + if ( + !this.pendingPrereleaseFallback?.retryLaunched || + this.pendingPrereleaseFallback.fallbackResultHandled + ) { + return + } + this.pendingPrereleaseFallback.suppressedFallbackPromiseFailureKey = this.getCheckFailureKey( + message, + this.pendingPrereleaseFallback.userInitiated + ) + } + + protected shouldSuppressMissingManifestPrereleaseFallbackEvent( + message: string, + error: unknown + ): boolean { + if (!this.pendingPrereleaseFallback?.retryLaunched) { + return false + } + const failureKey = this.getCheckFailureKey( + message, + this.pendingPrereleaseFallback.userInitiated + ) + const primaryEventSuppression = this.pendingPrereleaseFallback.suppressedPrimaryEventFailure + if (primaryEventSuppression?.failureKey === failureKey) { + const isPrimaryPromisePair = primaryEventSuppression.error === error + // Why: after fallback checking starts, same-message errors may be the fallback's, so message matching alone isn't safe. + if (isPrimaryPromisePair || !this.pendingPrereleaseFallback.fallbackCheckingForUpdateSeen) { + this.pendingPrereleaseFallback.suppressedPrimaryEventFailure = null + this.clearPrereleaseFallbackContextIfSettled() + return true + } + } + if (this.pendingPrereleaseFallback.suppressedFallbackEventFailureKey === failureKey) { + this.pendingPrereleaseFallback.suppressedFallbackEventFailureKey = null + this.clearPrereleaseFallbackContextIfSettled() + return true + } + return false + } + + protected markMissingManifestPrereleaseFallbackPromiseHandled(message: string): void { + if ( + !this.pendingPrereleaseFallback?.retryLaunched || + this.pendingPrereleaseFallback.fallbackResultHandled + ) { + return + } + this.pendingPrereleaseFallback.suppressedFallbackEventFailureKey = this.getCheckFailureKey( + message, + this.pendingPrereleaseFallback.userInitiated + ) + } + + protected async pinDefaultReleaseFeed( + variant: UpdateCheckVariant = 'default' + ): Promise<'ready' | 'not-available'> { + const autoUpdater = this.getAutoUpdater() + // Why: the latest/download redirect can move between check and download, so pin the concrete tag (prerelease users resolve any channel, stable only stable). + const currentVersion = app.getVersion() + const isPerfCheck = variant === 'perf' + const includePrerelease = + isPerfCheck || this.includePrereleaseActive || isPrereleaseVersion(currentVersion) + const releaseTagsResult = await fetchNewerReleaseTagsWithReadiness( + currentVersion, + includePrerelease ? 2 : 1, + { + includePrerelease, + ...(isPerfCheck ? { releaseFilter: 'perf' as const } : {}) + } + ) + const newerTag = releaseTagsResult.tags[0] ?? null + const fallbackTag = includePrerelease ? (releaseTagsResult.tags[1] ?? null) : null + this.pendingPrereleaseFallback = + includePrerelease && newerTag && fallbackTag + ? { + primaryTag: newerTag, + fallbackTag, + userInitiated: false, + suppressedPrimaryPromiseFailureKey: null, + suppressedPrimaryEventFailure: null, + suppressedFallbackPromiseFailureKey: null, + suppressedFallbackEventFailureKey: null, + fallbackResultHandled: false, + fallbackCheckingForUpdateSeen: false, + retryLaunched: false + } + : null + // Why: console.info is captured by Console.app/--enable-logging — our only field visibility into the updater. + if (newerTag) { + this.clearPublishingWindowLastGoodCheck() + const url = getReleaseDownloadUrl(newerTag) + console.info( + `[updater] release feed pinned: current=${currentVersion} includePrerelease=${includePrerelease} → ${url}` + ) + autoUpdater.setFeedURL({ provider: 'generic', url }) + return 'ready' + } + if (releaseTagsResult.state === 'not-ready') { + this.clearPrereleaseFallbackContext() + if (releaseTagsResult.lastGoodTag) { + // Why: during a publish window the newest tag is unsafe; a verified last-good concrete feed lets electron-updater emit a real result. + const url = getReleaseDownloadUrl(releaseTagsResult.lastGoodTag) + console.info( + `[updater] release feed pinned to last-good: current=${currentVersion} includePrerelease=${includePrerelease} → ${url}` + ) + this.publishingWindowLastGoodCheck = { lastGoodTag: releaseTagsResult.lastGoodTag } + autoUpdater.setFeedURL({ provider: 'generic', url }) + return 'ready' + } + this.clearPublishingWindowLastGoodCheck() + console.info( + `[updater] release feed deferred: current=${currentVersion} includePrerelease=${includePrerelease}; newest release assets are not ready` + ) + throw new ReleaseFeedPreflightError( + 'release-not-ready', + isPerfCheck ? 'perf' : includePrerelease ? 'prerelease' : 'default', + 'Latest release artifacts are not ready' + ) + } + if ( + releaseTagsResult.state === 'unavailable' && + releaseTagsResult.unavailableReason === 'manifest' && + !includePrerelease + ) { + this.clearPrereleaseFallbackContext() + this.clearPublishingWindowLastGoodCheck() + throw new ReleaseFeedPreflightError( + 'manifest-unavailable', + 'default', + 'Unable to find latest version on GitHub' + ) + } + if (isPerfCheck) { + this.clearPrereleaseFallbackContext() + this.clearPublishingWindowLastGoodCheck() + if (releaseTagsResult.state === 'no-newer') { + console.info( + `[updater] perf release not found: current=${currentVersion} includePrerelease=${includePrerelease}` + ) + return 'not-available' + } + throw new Error('Could not resolve perf update feed') + } + this.clearPrereleaseFallbackContext() + this.clearPublishingWindowLastGoodCheck() + const url = 'https://github.com/stablyai/orca/releases/latest/download' + console.info( + `[updater] release feed fallback: current=${currentVersion} includePrerelease=${includePrerelease} → ${url}` + ) + autoUpdater.setFeedURL({ provider: 'generic', url }) + return 'ready' + } + + protected retryPrereleaseFallbackAfterMissingManifest( + message: string, + userInitiated: boolean | undefined, + source: CheckFailureSource, + failureKey: string, + sourceError?: unknown + ): boolean { + if ( + !this.pendingPrereleaseFallback || + this.pendingPrereleaseFallback.retryLaunched || + !isMissingUpdateManifestFailure(message) + ) { + return false + } + const attemptId = this.activeUpdateCheckAttemptId + if (attemptId === null) { + return false + } + // Why: a published tag can briefly lack its platform manifest mid-release; walk back once to the previous feed for a normal not-available result. + this.pendingPrereleaseFallback.retryLaunched = true + this.pendingPrereleaseFallback.userInitiated = Boolean(userInitiated) + this.pendingPrereleaseFallback.suppressedPrimaryPromiseFailureKey = + source === 'event' ? failureKey : null + this.pendingPrereleaseFallback.suppressedPrimaryEventFailure = + source === 'promise' ? { failureKey, error: sourceError } : null + this.pendingPrereleaseFallback.fallbackCheckingForUpdateSeen = false + const { primaryTag, fallbackTag } = this.pendingPrereleaseFallback + const url = getReleaseDownloadUrl(fallbackTag) + console.info( + `[updater] prerelease manifest missing for ${primaryTag}; retrying once against ${url}` + ) + const autoUpdater = this.getAutoUpdater() + autoUpdater.setFeedURL({ provider: 'generic', url }) + this.userInitiatedCheck = Boolean(userInitiated) + this.backgroundCheckLaunchPending = !userInitiated + this.armUpdateCheckStallTimer(attemptId) + this.markUpdateCheckLaunched(attemptId) + void autoUpdater + .checkForUpdates() + .then(() => this.handleSettledUpdateCheckPromise(attemptId)) + .catch((err) => { + if (!this.isActiveUpdateCheckAttempt(attemptId)) { + return + } + const fallbackMessage = String(err?.message ?? err) + if (userInitiated) { + this.userInitiatedCheck = false + } else { + this.backgroundCheckLaunchPending = false + } + this.markMissingManifestPrereleaseFallbackPromiseHandled(fallbackMessage) + this.consumeMissingManifestPrereleaseFallbackResult() + void this.sendCheckFailureStatus(fallbackMessage, userInitiated, 'fallback-promise', err) + }) + return true + } +} diff --git a/src/main/updater/updater-remote-status.ts b/src/main/updater/updater-remote-status.ts new file mode 100644 index 00000000000..6e3db76b0c8 --- /dev/null +++ b/src/main/updater/updater-remote-status.ts @@ -0,0 +1,102 @@ +import { app } from 'electron' +import { is } from '@electron-toolkit/utils' +import type { UpdateCheckOptions, UpdateStatus } from '../../shared/update-status-types' +import type { + RemoteServerUpdateInstallResult, + RemoteServerUpdaterSnapshot, + RemoteServerUpdateSupport +} from '../../shared/remote-server-update' +import { hasServeUpdateSupervisor } from '../serve-update-handoff' +import { UpdaterNudge } from './updater-nudge' +import type { UpdateInstallMode } from './updater-state' + +/** Exposes updater state to runtime RPC callers without leaking internal mutators. */ +export abstract class UpdaterRemoteStatus extends UpdaterNudge { + protected getUpdateStatus(): UpdateStatus { + return this.currentStatus + } + + protected getRemoteServerUpdateSupport(): RemoteServerUpdateSupport { + if (!app.isPackaged || is.dev) { + return { + installMode: this.updateInstallMode, + automatic: false, + reason: 'unpackaged-build' + } + } + if (!this.autoUpdaterInitialized) { + return { + installMode: this.updateInstallMode, + automatic: false, + reason: 'updater-unavailable' + } + } + if (this.updateInstallMode === 'unsupported-headless-serve') { + return { + installMode: this.updateInstallMode, + automatic: false, + reason: 'manual-service-update-required' + } + } + return { installMode: this.updateInstallMode, automatic: true, reason: 'available' } + } + + protected getRemoteServerUpdaterSnapshot(runtimeId: string): RemoteServerUpdaterSnapshot { + return { + appVersion: app.getVersion(), + runtimeId, + support: this.getRemoteServerUpdateSupport(), + status: this.getUpdateStatus() + } + } + + protected assertRemoteServerUpdateAvailable(): void { + if (!this.getRemoteServerUpdateSupport().automatic) { + throw new Error('remote_update_manual_required') + } + } + + protected checkForRemoteServerUpdate( + runtimeId: string, + options?: UpdateCheckOptions + ): RemoteServerUpdaterSnapshot { + this.assertRemoteServerUpdateAvailable() + this.checkForUpdatesFromMenu(options) + return this.getRemoteServerUpdaterSnapshot(runtimeId) + } + + protected downloadRemoteServerUpdate(runtimeId: string): RemoteServerUpdaterSnapshot { + this.assertRemoteServerUpdateAvailable() + if (this.currentStatus.state !== 'available') { + throw new Error('remote_update_not_available') + } + this.downloadUpdate() + return this.getRemoteServerUpdaterSnapshot(runtimeId) + } + + protected installRemoteServerUpdate(runtimeId: string): RemoteServerUpdateInstallResult { + this.assertRemoteServerUpdateAvailable() + if (this.currentStatus.state !== 'downloaded') { + throw new Error('remote_update_not_downloaded') + } + const targetVersion = this.currentStatus.version + const result: RemoteServerUpdateInstallResult = { + accepted: true, + fromVersion: app.getVersion(), + targetVersion, + runtimeId + } + this.quitAndInstall() + return result + } + + protected resolveUpdateInstallMode(isServeMode: boolean): UpdateInstallMode { + if (!isServeMode) { + return 'interactive' + } + return hasServeUpdateSupervisor() ? 'supervised-headless-serve' : 'unsupported-headless-serve' + } + + protected abstract downloadUpdate(): void + protected abstract quitAndInstall(): void +} diff --git a/src/main/updater/updater-scheduling.ts b/src/main/updater/updater-scheduling.ts new file mode 100644 index 00000000000..954431385d0 --- /dev/null +++ b/src/main/updater/updater-scheduling.ts @@ -0,0 +1,121 @@ +import { app } from 'electron' +import { is } from '@electron-toolkit/utils' +import { withUpdaterSpan } from '../observability/instrumentation' +import { + AUTO_UPDATE_CHECK_INTERVAL_MS, + AUTO_UPDATE_RETRY_INTERVAL_MS, + MAX_AUTO_UPDATE_RETRY_INTERVAL_MS +} from './updater-state' +import { UpdaterCheckFailure } from './updater-check-failure' + +/** Owns timer-driven checks and the shared check-launch bookkeeping. */ +export abstract class UpdaterScheduling extends UpdaterCheckFailure { + protected getAutomaticRetryInterval(): number { + return AUTO_UPDATE_RETRY_INTERVAL_MS + } + + protected scheduleAutomaticUpdateCheck(delayMs: number): void { + let effectiveDelayMs = delayMs + // All retry-cadence callers pass exactly this constant, so keying backoff on it keeps one choke point instead of threading a flag through every schedule site. + if (delayMs === AUTO_UPDATE_RETRY_INTERVAL_MS) { + effectiveDelayMs = Math.min( + AUTO_UPDATE_RETRY_INTERVAL_MS * 2 ** this.consecutiveAutomaticRetrySchedules, + MAX_AUTO_UPDATE_RETRY_INTERVAL_MS + ) + this.consecutiveAutomaticRetrySchedules += 1 + } + if (this.autoUpdateCheckTimer) { + clearTimeout(this.autoUpdateCheckTimer) + } + this.autoUpdateCheckTimer = setTimeout(() => { + // Why: Orca runs for days, so keep the next background check scheduled in the main process rather than tying it to relaunches or renderer lifetime. + if (!this.runBackgroundUpdateCheck()) { + // Why: a deferred check reaches no outcome handler, so re-arm here or one deferral ends automatic checks for the process lifetime. + this.scheduleAutomaticUpdateCheck(AUTO_UPDATE_CHECK_INTERVAL_MS) + } + }, effectiveDelayMs) + } + + protected recordCompletedUpdateCheck(): void { + this.consecutiveAutomaticRetrySchedules = 0 + this.persistLastUpdateCheckAt?.(Date.now()) + } + + /** Returns false when the check was deferred instead of launched, so timer-driven callers can re-arm. */ + protected runBackgroundUpdateCheck( + nudgeId: string | null = this.getPersistedPendingUpdateNudgeId() + ): boolean { + // Why: a pinned dev jump owns the feed until it settles; a background check would repoint it mid-flight and download the wrong build. + if ( + this.activeUpdateSource !== 'release' || + this.isPinnedBuildActive || + this.localBuildSelectionInProgress || + this.pinnedBuildSelectionInProgress + ) { + return false + } + if (this.backgroundCheckLaunchPending || this.currentStatus.state === 'checking') { + return false + } + if (!app.isPackaged || is.dev) { + this.sendStatus({ state: 'not-available' }) + return false + } + // Why: set the nudge marker before any events arrive so later checks can't inherit a stale campaign id; persisted id keeps a nudge card dismissable after relaunch. + this.activeUpdateNudgeId = nudgeId + // Why: 'checking-for-update' arrives a tick later, so a second focus/resume can slip in before status flips; track launch in memory to dedupe that gap. + this.backgroundCheckLaunchPending = true + this.backgroundCheckPromotedToUserInitiated = false + const attemptId = this.beginUpdateCheckAttempt() + const autoUpdater = this.getAutoUpdater() + const launch = (): Promise | undefined => { + if (!this.isActiveUpdateCheckAttempt(attemptId)) { + return undefined + } + this.markUpdateCheckLaunched(attemptId) + return autoUpdater.checkForUpdates() + } + const run = this.pinDefaultReleaseFeed().then(launch) + void Promise.resolve(run) + .then(() => this.handleSettledUpdateCheckPromise(attemptId)) + .catch((err) => { + if (!this.isActiveUpdateCheckAttempt(attemptId)) { + return + } + const wasUserInitiated = this.getSettledCheckUserInitiated() + this.backgroundCheckLaunchPending = false + this.backgroundCheckPromotedToUserInitiated = false + if (wasUserInitiated) { + this.userInitiatedCheck = false + } + void this.sendCheckFailureStatus( + String(err?.message ?? err), + wasUserInitiated, + 'promise', + err + ) + }) + return true + } + + protected checkForUpdatesInBackground(): void { + // Why: span records only check launch (always Success), not outcome; dashboards must filter `updater.outcome === 'launched'`, not this span's success rate. + void withUpdaterSpan({ stage: 'check' }, async (span) => { + span.setAttribute('updater.outcome', 'launched') + this.runBackgroundUpdateCheck() + }) + } + + protected enablePrereleaseManifestChecks(): void { + this.getAutoUpdater().allowPrerelease = true + } + + protected enableIncludePrerelease(): void { + if (this.includePrereleaseActive) { + return + } + // Why: this flag makes electron-updater accept prerelease manifests; we keep the manifest-probed generic feed over the native GitHub provider because cancelled RCs can appear without assets. + this.enablePrereleaseManifestChecks() + this.includePrereleaseActive = true + } +} diff --git a/src/main/updater/updater-setup.ts b/src/main/updater/updater-setup.ts new file mode 100644 index 00000000000..21354f97af3 --- /dev/null +++ b/src/main/updater/updater-setup.ts @@ -0,0 +1,251 @@ +import { app, powerMonitor } from 'electron' +import type { BrowserWindow } from 'electron' +import { is } from '@electron-toolkit/utils' +import type { ReleaseBuild, ReleaseChannel } from '../../shared/release-channel' +import type { + LinuxPackageInstallInstructions, + UpdateCheckOptions, + UpdateStatus +} from '../../shared/update-status-types' +import type { + RemoteServerUpdateInstallResult, + RemoteServerUpdaterSnapshot, + RemoteServerUpdateSupport +} from '../../shared/remote-server-update' +import { getLinuxRootPackageType } from '../linux-update-package-type' +import { createUpdaterDiagnosticLogger } from '../linux-package-install-diagnostic' +import { registerAutoUpdaterHandlers } from '../updater-events' +import { getServeUpdateHandoffFailure } from '../serve-update-handoff' +import { recordUpdaterLifecycle } from '../updater-lifecycle-diagnostics' +import { AUTO_UPDATE_CHECK_INTERVAL_MS } from './updater-state' +import { UpdaterDownloadInstall } from './updater-download-install' +import type { UpdateInstallMode } from './updater-state' + +export type UpdaterSetupOptions = { + getLastUpdateCheckAt?: () => number | null + onBeforeQuit?: () => void | Promise + setLastUpdateCheckAt?: (timestamp: number) => void + getPendingUpdateNudgeId?: () => string | null + getDismissedUpdateNudgeId?: () => string | null + setPendingUpdateNudgeId?: (id: string | null) => void + setDismissedUpdateNudgeId?: (id: string | null) => void + getReleaseChannelOverride?: () => ReleaseChannel | null + installMode?: UpdateInstallMode +} + +/** Initializes electron-updater and attaches lifecycle/event bridges. */ +export class UpdaterSetup extends UpdaterDownloadInstall { + checkForUpdates(): void { + this.checkForUpdatesInBackground() + } + + checkForUpdatesFromMenu(options?: UpdateCheckOptions): void { + super.checkForUpdatesFromMenu(options) + } + + downloadUpdate(): void { + super.downloadUpdate() + } + + quitAndInstall(): void { + super.quitAndInstall() + } + + isQuittingForUpdate(): boolean { + return super.isQuittingForUpdate() + } + + getUpdateStatus(): UpdateStatus { + return super.getUpdateStatus() + } + + getRemoteServerUpdateSupport(): RemoteServerUpdateSupport { + return super.getRemoteServerUpdateSupport() + } + + getRemoteServerUpdaterSnapshot(runtimeId: string): RemoteServerUpdaterSnapshot { + return super.getRemoteServerUpdaterSnapshot(runtimeId) + } + + checkForRemoteServerUpdate( + runtimeId: string, + options?: UpdateCheckOptions + ): RemoteServerUpdaterSnapshot { + return super.checkForRemoteServerUpdate(runtimeId, options) + } + + downloadRemoteServerUpdate(runtimeId: string): RemoteServerUpdaterSnapshot { + return super.downloadRemoteServerUpdate(runtimeId) + } + + installRemoteServerUpdate(runtimeId: string): RemoteServerUpdateInstallResult { + return super.installRemoteServerUpdate(runtimeId) + } + + resolveUpdateInstallMode(isServeMode: boolean): UpdateInstallMode { + return super.resolveUpdateInstallMode(isServeMode) + } + + async getLinuxPackageInstallInstructions(): Promise { + return super.getLinuxPackageInstallInstructions() + } + + async showLinuxPackage(): Promise { + return super.showLinuxPackage() + } + + async listAvailableReleaseBuilds(channel: ReleaseChannel): Promise { + return super.listAvailableReleaseBuilds(channel) + } + + dismissNudge(): void { + super.dismissNudge() + } + + dismissAvailableUpdate(): void { + super.dismissAvailableUpdate() + } + + setupAutoUpdater(mainWindow: BrowserWindow, opts?: UpdaterSetupOptions): void { + this.mainWindowRef = mainWindow + this.onBeforeQuitCleanup = opts?.onBeforeQuit ?? null + this.persistLastUpdateCheckAt = opts?.setLastUpdateCheckAt ?? null + this._getLastUpdateCheckAt = opts?.getLastUpdateCheckAt ?? null + this._getPendingUpdateNudgeId = opts?.getPendingUpdateNudgeId ?? null + this._getDismissedUpdateNudgeId = opts?.getDismissedUpdateNudgeId ?? null + this._setPendingUpdateNudgeId = opts?.setPendingUpdateNudgeId ?? null + this._setDismissedUpdateNudgeId = opts?.setDismissedUpdateNudgeId ?? null + this.getReleaseChannelOverride = opts?.getReleaseChannelOverride ?? null + this.updateInstallMode = opts?.installMode ?? 'interactive' + this.lastInstallDeferralVersion = { download: null, install: null } + + const serveHandoffFailure = getServeUpdateHandoffFailure() + if (serveHandoffFailure) { + recordUpdaterLifecycle( + 'headless_serve_handoff_failed', + { reason: serveHandoffFailure }, + { level: 'warn', message: 'Supervised serve update did not complete' } + ) + this.sendErrorStatus(`The server update did not complete: ${serveHandoffFailure}`, true) + } + + if (!app.isPackaged && !is.dev) { + return + } + if (is.dev) { + return + } + + const autoUpdater = this.getAutoUpdater() + autoUpdater.autoDownload = false + if (this.activeUpdateSource === 'release') { + autoUpdater.allowDowngrade = false + autoUpdater.disableDifferentialDownload = false + } + // Why: supervised serve installs require an explicit handoff; ordinary service quits must never install implicitly. + // Root Linux packages also opt out: an implicit quit-time escalation would fail after the UI is gone, leaving no recovery surface. + autoUpdater.autoInstallOnAppQuit = + this.updateInstallMode === 'interactive' && getLinuxRootPackageType() === null + // Why: MacUpdater ignores quitAndInstall arguments; the surviving CLI supervisor must be the only serve relaunch owner. + autoUpdater.autoRunAppAfterInstall = this.updateInstallMode === 'interactive' + // Why: our only on-machine window into electron-updater; otherwise an unexpected update-not-available or failed fetch is invisible. + autoUpdater.logger = createUpdaterDiagnosticLogger() as never + + // Security: never re-add a verifyUpdateCodeSignature override — a no-op disables electron-updater's built-in Authenticode check and accepts any installer. + if (this.activeUpdateSource === 'release') { + autoUpdater.setFeedURL({ + provider: 'generic', + url: 'https://github.com/stablyai/orca/releases/latest/download' + }) + } + if (this.autoUpdaterInitialized) { + return + } + this.autoUpdaterInitialized = true + + registerAutoUpdaterHandlers({ + autoUpdater, + clearBackgroundCheckLaunchPending: () => this.clearBackgroundCheckLaunchPending(), + clearAvailableUpdateContext: () => this.clearAvailableUpdateContext(), + consumeMissingManifestPrereleaseFallbackResult: () => + this.consumeMissingManifestPrereleaseFallbackResult(), + getPublishingWindowLastGoodCheck: () => this.getPublishingWindowLastGoodCheck(), + getMissingManifestPrereleaseFallbackUserInitiated: () => + this.getMissingManifestPrereleaseFallbackUserInitiated(), + getCurrentStatus: () => this.currentStatus, + getActiveUpdateCheckEventAttemptId: () => this.getActiveUpdateCheckEventAttemptId(), + getKnownReleaseUrl: () => this.getKnownReleaseUrl(), + getPendingInstallVersion: () => this.getPendingInstallVersion(), + getUserInitiatedCheck: () => this.userInitiatedCheck, + handleQuitAndInstallFailure: (error) => this.handleQuitAndInstallFailure(error), + isQuitAndInstallHandoffActive: () => this.isQuitAndInstallHandoffActive(), + hasInstallableDownloadedVersion: () => this.hasInstallableDownloadedVersion(), + isLocalBuildCheck: () => this.activeUpdateSource === 'local', + // Why: pinned jumps are deliberate, so update-available/-downloaded must not reject them for being older than the running version. + isPinnedBuildCheck: () => this.isPinnedBuildActive, + shouldHandleUpdaterErrorEvent: () => this.shouldHandleUpdaterErrorEvent(), + clearUpdateAvailableEventPending: (attemptId) => + this.clearUpdateAvailableEventPending(attemptId), + isActiveUpdateCheckAttempt: (attemptId) => this.isActiveUpdateCheckAttempt(attemptId), + markUpdateCheckEventAttempt: () => this.markUpdateCheckEventAttempt(), + markUpdateAvailableEventPending: (attemptId) => + this.markUpdateAvailableEventPending(attemptId), + markMissingManifestPrereleaseFallbackChecking: () => + this.markMissingManifestPrereleaseFallbackChecking(), + performQuitAndInstall: () => this.performQuitAndInstall(), + shouldDeferMacQuitForInstall: () => this.updateInstallMode === 'interactive', + recordCompletedUpdateCheck: () => this.recordCompletedUpdateCheck(), + restoreReleaseUpdateSource: () => this.restoreReleaseUpdateSource(), + sendCheckFailureStatus: (message, userInitiated, source, sourceError) => + this.sendCheckFailureStatus(message, userInitiated, source, sourceError), + sendErrorStatus: (message, userInitiated) => this.sendErrorStatus(message, userInitiated), + sendStatus: (status) => this.sendStatus(status), + scheduleAutomaticUpdateCheck: (delayMs) => this.scheduleAutomaticUpdateCheck(delayMs), + shouldSuppressMissingManifestPrereleaseFallbackEvent: (message, error) => + this.shouldSuppressMissingManifestPrereleaseFallbackEvent(message, error), + suppressMissingManifestPrereleaseFallbackPromiseFailure: (message) => + this.suppressMissingManifestPrereleaseFallbackPromiseFailure(message), + setAvailableReleaseUrl: (releaseUrl) => { + this.availableReleaseUrl = releaseUrl + }, + setAvailableVersion: (version) => { + this.availableVersion = version + }, + setUserInitiatedCheck: (value) => { + this.userInitiatedCheck = value + } + }) + + void this.checkForUpdateNudge() + this.scheduleUpdateNudgeCheck() + + const checkDailyOnWake = () => { + void this.checkForUpdateNudge() + if ( + this.backgroundCheckLaunchPending || + this.currentStatus.state === 'checking' || + this.currentStatus.state === 'downloading' + ) { + return + } + const lastCheck = this._getLastUpdateCheckAt?.() ?? null + const msSince = lastCheck === null ? Number.POSITIVE_INFINITY : Date.now() - lastCheck + if (msSince >= AUTO_UPDATE_CHECK_INTERVAL_MS) { + this.runBackgroundUpdateCheck() + this.scheduleAutomaticUpdateCheck(AUTO_UPDATE_CHECK_INTERVAL_MS) + } + } + powerMonitor.on('resume', checkDailyOnWake) + app.on('browser-window-focus', checkDailyOnWake) + + const lastUpdateCheckAt = opts?.getLastUpdateCheckAt?.() ?? null + const msSinceLastCheck = + lastUpdateCheckAt === null ? Number.POSITIVE_INFINITY : Date.now() - lastUpdateCheckAt + if (msSinceLastCheck >= AUTO_UPDATE_CHECK_INTERVAL_MS) { + this.runBackgroundUpdateCheck() + this.scheduleAutomaticUpdateCheck(AUTO_UPDATE_CHECK_INTERVAL_MS) + } else { + this.scheduleAutomaticUpdateCheck(AUTO_UPDATE_CHECK_INTERVAL_MS - msSinceLastCheck) + } + } +} diff --git a/src/main/updater/updater-state.ts b/src/main/updater/updater-state.ts new file mode 100644 index 00000000000..a410c5e10b8 --- /dev/null +++ b/src/main/updater/updater-state.ts @@ -0,0 +1,127 @@ +import type { BrowserWindow } from 'electron' +import type { ElectronAutoUpdater } from '../electron-updater-loader' +import type { LinuxPackageInstallDiagnostic } from '../linux-package-install-diagnostic' +import type { LocalBuildFeed } from '../local-builds/local-build-feed-server' +import type { UpdateSource, UpdateStatus } from '../../shared/update-status-types' +import type { ReleaseChannel } from '../../shared/release-channel' +import type { PrimaryEventSuppression, UpdateCheckVariant } from './updater-types' + +export const AUTO_UPDATE_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000 +export const AUTO_UPDATE_RETRY_INTERVAL_MS = 60 * 60 * 1000 +// Why: a persistently-failing feed used to re-arm the retry at a fixed 1h cadence forever (issue #7576); backoff doubles per failure up to this cap, any completed check resets. +export const MAX_AUTO_UPDATE_RETRY_INTERVAL_MS = 6 * 60 * 60 * 1000 +export const NUDGE_POLL_INTERVAL_MS = 30 * 60 * 1000 +export const NUDGE_ACTIVATION_COOLDOWN_MS = 5 * 60 * 1000 +export const QUIT_AND_INSTALL_DELAY_MS = 100 +export const PRE_QUIT_CLEANUP_TIMEOUT_MS = 2_500 +export const UPDATE_CHECK_SILENT_SETTLE_DELAY_MS = 1_000 +export const UPDATE_CHECK_STALL_TIMEOUT_MS = 45_000 + +export type CheckFailureSource = 'event' | 'promise' | 'fallback-promise' +export type MissingManifestPrereleaseFallbackResult = { userInitiated: boolean } +export type ReleaseFeedPreflightFailure = 'manifest-unavailable' | 'release-not-ready' +export type ReleaseFeedPreflightResult = 'ready' | 'not-available' +export type UpdateInstallMode = + | 'interactive' + | 'supervised-headless-serve' + | 'unsupported-headless-serve' + +// Why: expected preflight outcomes need typed context so UI routing never depends on matching error text. +export class ReleaseFeedPreflightError extends Error { + constructor( + readonly reason: ReleaseFeedPreflightFailure, + readonly releaseChannel: UpdateCheckVariant, + message: string + ) { + super(message) + this.name = 'ReleaseFeedPreflightError' + } +} + +export abstract class UpdaterState { + protected mainWindowRef: BrowserWindow | null = null + protected currentStatus: UpdateStatus = { state: 'idle' } + protected userInitiatedCheck = false + protected onBeforeQuitCleanup: (() => void | Promise) | null = null + protected autoUpdaterInitialized = false + // Why: modifier-clicking "Check for Updates" targets prerelease manifests; the feed still pins a concrete tag so cancelled prereleases without manifests are skipped. + protected includePrereleaseActive = false + protected availableVersion: string | null = null + protected availableReleaseUrl: string | null = null + protected pendingCheckFailureKey: string | null = null + protected pendingCheckFailurePromise: Promise | null = null + protected autoUpdateCheckTimer: ReturnType | null = null + protected nudgeCheckTimer: ReturnType | null = null + protected pendingQuitAndInstallTimer: ReturnType | null = null + protected quitAndInstallInProgress = false + // Why: the pre-install digest re-proof streams the whole package, so a second install request can + // arrive while it runs — after the quit timer was cleared but before the handoff owns the process. + protected linuxPackageRevalidationInFlight = false + protected updateInstallMode: UpdateInstallMode = 'interactive' + protected lastInstallDeferralVersion = { + download: null as string | null, + install: null as string | null + } + // Why: once install has committed, late 'error' events must not clear quittingForUpdate — that would re-enable dock activate mid-installer. + protected updateInstallCommitted = false + // Why: recovery must only run after the native quitAndInstall call; pre-native errors must not clear quittingForUpdate or look like install recovery. + protected quitAndInstallNativeInvoked = false + // Why: a synchronous throw out of quitAndInstall ends diagnostic capture before the catch runs, so stash the redacted text for it. + protected lastInstallAttemptDiagnostic: LinuxPackageInstallDiagnostic | null = null + protected persistLastUpdateCheckAt: ((timestamp: number) => void) | null = null + protected _getLastUpdateCheckAt: (() => number | null) | null = null + protected backgroundCheckLaunchPending = false + // Why: a promoted background check can emit an error event before its promise catch runs; keep the promotion attached to that launch. + protected backgroundCheckPromotedToUserInitiated = false + protected updateCheckStallTimer: ReturnType | null = null + protected updateCheckSilentSettleTimer: ReturnType | null = null + protected updateCheckAttemptSequence = 0 + protected activeUpdateCheckAttemptId: number | null = null + protected activeUpdateCheckLaunchAttemptId: number | null = null + protected activeUpdateCheckEventAttemptId: number | null = null + protected updateAvailableEventPendingAttemptId: number | null = null + protected pendingUserInitiatedCheckAfterInFlight: UpdateCheckVariant | null = null + protected activeUpdateNudgeId: string | null = null + protected awaitingNudgeCheckOutcome = false + protected nudgeCheckInFlight = false + protected lastNudgeCheckAt = 0 + protected publishingWindowLastGoodCheck: { lastGoodTag: string } | null = null + protected pendingPrereleaseFallback: { + primaryTag: string + fallbackTag: string + // Why: primary promise cleanup can run after fallback starts; fallback events need this attempt-scoped state, not the mutable global. + userInitiated: boolean + suppressedPrimaryPromiseFailureKey: string | null + suppressedPrimaryEventFailure: PrimaryEventSuppression | null + suppressedFallbackPromiseFailureKey: string | null + suppressedFallbackEventFailureKey: string | null + fallbackResultHandled: boolean + fallbackCheckingForUpdateSeen: boolean + retryLaunched: boolean + } | null = null + + protected _getPendingUpdateNudgeId: (() => string | null) | null = null + protected _getDismissedUpdateNudgeId: (() => string | null) | null = null + protected _setPendingUpdateNudgeId: ((id: string | null) => void) | null = null + protected _setDismissedUpdateNudgeId: ((id: string | null) => void) | null = null + // Why: guards against duplicate download() calls while an accepted request transitions status to 'downloading'. + protected downloadInFlight = false + /** Guards the macOS `activate` handler from reopening the old version while ShipIt replaces the .app bundle. */ + protected quittingForUpdate = false + protected autoUpdater: ElectronAutoUpdater | null = null + protected activeUpdateSource: 'release' | UpdateSource = 'release' + protected activeLocalBuildFeed: LocalBuildFeed | null = null + protected localBuildSelectionInProgress = false + // Why: a dev channel/tag jump may target an older build, so it needs allowDowngrade + // like local builds — but off a real release feed, not a loopback server. + protected pinnedBuildSelectionInProgress = false + // Why: a pinned jump to a stable/rc tag keeps the 'release' source but is still a + // deliberate downgrade, so newer-only gates must yield to it too. + protected isPinnedBuildActive = false + protected getReleaseChannelOverride: (() => ReleaseChannel | null) | null = null + + protected consecutiveAutomaticRetrySchedules = 0 + protected readonly installFailureCauseMaxLength = 200 + + constructor() {} +} diff --git a/src/main/updater/updater-status.ts b/src/main/updater/updater-status.ts new file mode 100644 index 00000000000..26b8f605084 --- /dev/null +++ b/src/main/updater/updater-status.ts @@ -0,0 +1,184 @@ +import { loadElectronAutoUpdater, type ElectronAutoUpdater } from '../electron-updater-loader' +import { statusesEqual } from '../updater-fallback' +import type { UpdateCheckOptions, UpdateStatus } from '../../shared/update-status-types' +import type { UpdateCheckVariant } from './updater-types' +import { UpdaterState as BaseUpdaterState } from './updater-state' + +export abstract class UpdaterStatus extends BaseUpdaterState { + protected getAutoUpdater(): ElectronAutoUpdater { + if (!this.autoUpdater) { + this.autoUpdater = loadElectronAutoUpdater() + } + return this.autoUpdater + } + + protected clearAvailableUpdateContext(): void { + this.availableVersion = null + this.availableReleaseUrl = null + } + + protected closeLocalBuildFeed(): void { + const feed = this.activeLocalBuildFeed + this.activeLocalBuildFeed = null + if (feed) { + void feed.close() + } + } + + protected restoreReleaseUpdateSource(): void { + this.closeLocalBuildFeed() + this.activeUpdateSource = 'release' + this.isPinnedBuildActive = false + if (this.autoUpdater) { + this.autoUpdater.allowDowngrade = false + this.autoUpdater.disableDifferentialDownload = false + // Why: a pinned jump forces allowPrerelease on; leaving it set would opt + // every later background check into the RC channel behind the user's back. + this.autoUpdater.allowPrerelease = this.includePrereleaseActive + } + } + + protected sendLocalBuildErrorAndRestore(message: string, userInitiated?: boolean): void { + this.clearAvailableUpdateContext() + if ( + this.currentStatus.state !== 'error' || + this.currentStatus.message !== message || + this.currentStatus.userInitiated !== userInitiated || + this.currentStatus.source !== 'local' + ) { + this.sendStatus({ state: 'error', message, userInitiated, source: 'local' }) + } + this.restoreReleaseUpdateSource() + } + + protected clearPrereleaseFallbackContext(): void { + this.pendingPrereleaseFallback = null + } + + protected clearPendingUpdateNudge(): void { + this.activeUpdateNudgeId = null + this.awaitingNudgeCheckOutcome = false + this._setPendingUpdateNudgeId?.(null) + } + + protected deferPendingUpdateNudgeUntilRetry(): void { + this.activeUpdateNudgeId = null + this.awaitingNudgeCheckOutcome = false + } + + protected clearPublishingWindowLastGoodCheck(): void { + this.publishingWindowLastGoodCheck = null + } + + protected getPublishingWindowLastGoodCheck(): { lastGoodTag: string } | null { + return this.publishingWindowLastGoodCheck + } + + protected getPersistedPendingUpdateNudgeId(): string | null { + return this._getPendingUpdateNudgeId?.() ?? null + } + + protected decorateStatusWithActiveNudge(status: UpdateStatus): UpdateStatus { + // Why: only actionable/error states carry the nudge marker so the renderer knows a dismiss should ack the campaign; cycle-boundary states never need it. + if (!this.activeUpdateNudgeId) { + return status + } + if ( + status.state === 'idle' || + status.state === 'checking' || + status.state === 'not-available' + ) { + return status + } + return { ...status, activeNudgeId: this.activeUpdateNudgeId } + } + + /** `force` re-delivers a status the renderer must not miss even when it repeats the current one. */ + protected sendStatus(status: UpdateStatus, options?: { force?: boolean }): void { + const pendingUserInitiatedCheckVariant = this.pendingUserInitiatedCheckAfterInFlight + const shouldLaunchPendingUserInitiatedCheck = + pendingUserInitiatedCheckVariant !== null && + (status.state === 'idle' || + status.state === 'not-available' || + status.state === 'available' || + status.state === 'error') + const shouldPreserveNudgeForPublishingWindow = + this.publishingWindowLastGoodCheck !== null && + (status.state === 'idle' || + status.state === 'not-available' || + status.state === 'available' || + status.state === 'error') + if (this.awaitingNudgeCheckOutcome) { + if (status.state === 'available') { + if (shouldPreserveNudgeForPublishingWindow) { + // Why: a last-good available update is only a temporary fallback; dismissing it must not consume the newest-release nudge campaign. + this.deferPendingUpdateNudgeUntilRetry() + } else { + this.awaitingNudgeCheckOutcome = false + } + } else if ( + status.state === 'idle' || + status.state === 'not-available' || + status.state === 'error' + ) { + if (shouldPreserveNudgeForPublishingWindow) { + // Why: last-good checks can say "not available" while the campaign's newest release is still publishing. + this.deferPendingUpdateNudgeUntilRetry() + } else { + // Why: on no-update, mark the campaign dismissed so a nudge covering already-up-to-date users doesn't re-fire every 30-min poll. + if (this.activeUpdateNudgeId) { + this._setDismissedUpdateNudgeId?.(this.activeUpdateNudgeId) + } + this.clearPendingUpdateNudge() + } + } + } + + const sourcedStatus: UpdateStatus = + this.activeUpdateSource === 'release' + ? status + : { ...status, source: this.activeUpdateSource } + const decoratedStatus = this.decorateStatusWithActiveNudge(sourcedStatus) + + if (this.isUpdateCheckResultState(status.state)) { + this.finishActiveUpdateCheckAttempt() + } + + if ( + status.state === 'idle' || + status.state === 'not-available' || + status.state === 'available' || + status.state === 'error' + ) { + this.clearPublishingWindowLastGoodCheck() + } + + // Why: reset the in-flight guard once status moves past the window where duplicate download() calls are possible. + if ( + decoratedStatus.state === 'downloading' || + decoratedStatus.state === 'error' || + decoratedStatus.state === 'idle' + ) { + this.downloadInFlight = false + } + if (shouldLaunchPendingUserInitiatedCheck) { + // Why: a forced status must still land before the queued check restarts the cycle. + if (options?.force) { + this.currentStatus = decoratedStatus + this.mainWindowRef?.webContents.send('updater:status', decoratedStatus) + } + this.launchPendingUserInitiatedCheckAfterInFlight(pendingUserInitiatedCheckVariant) + return + } + if (!options?.force && statusesEqual(this.currentStatus, decoratedStatus)) { + return + } + this.currentStatus = decoratedStatus + this.mainWindowRef?.webContents.send('updater:status', decoratedStatus) + } + + protected abstract finishActiveUpdateCheckAttempt(): void + protected abstract isUpdateCheckResultState(state: UpdateStatus['state']): boolean + protected abstract launchPendingUserInitiatedCheckAfterInFlight(variant: UpdateCheckVariant): void + protected abstract checkForUpdatesFromMenu(options?: UpdateCheckOptions): void +} diff --git a/src/main/updater/updater-types.ts b/src/main/updater/updater-types.ts new file mode 100644 index 00000000000..aeb91bfdbd1 --- /dev/null +++ b/src/main/updater/updater-types.ts @@ -0,0 +1,2 @@ +export type UpdateCheckVariant = 'default' | 'prerelease' | 'perf' +export type PrimaryEventSuppression = { failureKey: string; error: unknown } diff --git a/src/main/worktree-create-preparation-burst.ts b/src/main/worktree-create-preparation-burst.ts new file mode 100644 index 00000000000..d289927ffaa --- /dev/null +++ b/src/main/worktree-create-preparation-burst.ts @@ -0,0 +1,21 @@ +import { setBoundedMapEntry } from './runtime/runtime-async-boundaries' + +/** Two creates this close together mean more are likely; an isolated create earns no replacement. */ +export const WORKTREE_CREATE_BURST_MS = 5 * 60_000 +const WORKTREE_CREATE_PREPARATION_CONSUME_MAX = 64 + +/** When each preparation key was last consumed, so a burst can be told from an isolated create. */ +const lastConsumedAt = new Map() + +/** Records this consume and reports whether it continues a burst. A replacement checkout costs a + * full tree and holds disk until its TTL, so only a user who is already creating repeatedly earns + * one; the first create of a session pays nothing for a spare nobody claims. */ +export function recordPreparationConsume(key: string, now = Date.now()): boolean { + const previous = lastConsumedAt.get(key) + setBoundedMapEntry(lastConsumedAt, key, now, WORKTREE_CREATE_PREPARATION_CONSUME_MAX) + return previous !== undefined && now - previous <= WORKTREE_CREATE_BURST_MS +} + +export function resetPreparationConsumeHistoryForTests(): void { + lastConsumedAt.clear() +} diff --git a/src/main/worktree-create-preparation-claim.test.ts b/src/main/worktree-create-preparation-claim.test.ts new file mode 100644 index 00000000000..8abc42f30fe --- /dev/null +++ b/src/main/worktree-create-preparation-claim.test.ts @@ -0,0 +1,152 @@ +import { describe, expect, it } from 'vitest' +import { + preparationPathKey, + selectPreparationForCreate, + type PreparationCandidate, + type PreparationRequest +} from './worktree-create-preparation-claim' + +function candidate(overrides: Partial = {}): PreparationCandidate { + return { + repoPathKey: '/repo', + workspaceRootKey: '/workspace', + wslDistro: '', + baseBranch: 'origin/main', + canonicalBase: 'refs/remotes/origin/main', + createdAt: 1_000, + ...overrides + } +} + +function request(overrides: Partial = {}): PreparationRequest { + return { + repoPathKey: '/repo', + workspaceRootKey: '/workspace', + wslDistro: '', + baseBranch: 'origin/main', + canonicalBase: 'refs/remotes/origin/main', + ...overrides + } +} + +describe('selectPreparationForCreate', () => { + it('matches the identical base before any ref probe has run', () => { + const selection = selectPreparationForCreate([candidate()], request({ canonicalBase: null })) + + expect(selection).toEqual({ + kind: 'exact', + candidate: candidate(), + canonicalBase: 'refs/remotes/origin/main' + }) + }) + + it('asks for a canonical base only when something is armed under another spelling', () => { + expect( + selectPreparationForCreate( + [candidate()], + request({ baseBranch: 'main', canonicalBase: null }) + ) + ).toEqual({ kind: 'needs-canonical-base' }) + // Nothing armed for this repo, so the create must not pay a probe to learn that. + expect( + selectPreparationForCreate([], request({ baseBranch: 'main', canonicalBase: null })) + ).toEqual({ kind: 'miss', reason: 'none_armed' }) + }) + + it('matches when the two sides spell the same ref differently', () => { + const selection = selectPreparationForCreate( + [candidate()], + request({ baseBranch: 'refs/remotes/origin/main' }) + ) + + expect(selection).toEqual({ + kind: 'exact', + candidate: candidate(), + canonicalBase: 'refs/remotes/origin/main' + }) + }) + + it('retargets a local base onto the armed remote-tracking base of the same branch', () => { + const selection = selectPreparationForCreate( + [candidate()], + request({ baseBranch: 'main', canonicalBase: 'refs/heads/main' }) + ) + + expect(selection).toEqual({ + kind: 'retarget', + candidate: candidate(), + canonicalBase: 'refs/heads/main' + }) + }) + + it('prefers the freshest armed entry when several share the family', () => { + const older = candidate({ canonicalBase: 'refs/remotes/origin/main', createdAt: 1 }) + const newer = candidate({ canonicalBase: 'refs/remotes/upstream/main', createdAt: 2 }) + + const selection = selectPreparationForCreate( + [older, newer], + request({ baseBranch: 'main', canonicalBase: 'refs/heads/main' }) + ) + + expect(selection).toMatchObject({ kind: 'retarget', candidate: newer }) + }) + + it('refuses to retarget onto a different branch', () => { + const selection = selectPreparationForCreate( + [candidate()], + request({ baseBranch: 'origin/release', canonicalBase: 'refs/remotes/origin/release' }) + ) + + expect(selection).toEqual({ kind: 'miss', reason: 'base_mismatch' }) + }) + + it('refuses to retarget onto a bare commit id, whose divergence is unbounded', () => { + const selection = selectPreparationForCreate( + [candidate()], + request({ baseBranch: '1f2e3d4c5b6a7988', canonicalBase: '1f2e3d4c5b6a7988' }) + ) + + expect(selection).toEqual({ kind: 'miss', reason: 'base_mismatch' }) + }) + + it('names the key field that disagreed', () => { + expect(selectPreparationForCreate([], request())).toEqual({ + kind: 'miss', + reason: 'none_armed' + }) + // Something is warm, just not for this repo — the shape of a size-cap eviction. + expect( + selectPreparationForCreate([candidate()], request({ repoPathKey: '/other-repo' })) + ).toEqual({ kind: 'miss', reason: 'repo_mismatch' }) + expect(selectPreparationForCreate([candidate()], request({ wslDistro: 'Ubuntu' }))).toEqual({ + kind: 'miss', + reason: 'wsl_distro_mismatch' + }) + expect( + selectPreparationForCreate([candidate()], request({ workspaceRootKey: '/other' })) + ).toEqual({ kind: 'miss', reason: 'workspace_root_mismatch' }) + }) + + it('never crosses hosts to satisfy a family retarget', () => { + const selection = selectPreparationForCreate( + [candidate({ wslDistro: 'Ubuntu' })], + request({ baseBranch: 'main', canonicalBase: 'refs/heads/main' }) + ) + + expect(selection).toEqual({ kind: 'miss', reason: 'wsl_distro_mismatch' }) + }) +}) + +describe('preparationPathKey', () => { + it('normalizes a posix path without folding case', () => { + expect(preparationPathKey('/workspace/./repo/')).toBe('/workspace/repo/') + expect(preparationPathKey('/Workspace/Repo')).toBe('/Workspace/Repo') + }) + + it('folds case for Windows drive and UNC paths, which compare case-insensitively', () => { + expect(preparationPathKey('C:\\Workspace\\Repo')).toBe('c:\\workspace\\repo') + expect(preparationPathKey('\\\\wsl.localhost\\Ubuntu\\home\\jin')).toBe( + '\\\\wsl.localhost\\ubuntu\\home\\jin' + ) + }) +}) diff --git a/src/main/worktree-create-preparation-claim.ts b/src/main/worktree-create-preparation-claim.ts new file mode 100644 index 00000000000..e329282982d --- /dev/null +++ b/src/main/worktree-create-preparation-claim.ts @@ -0,0 +1,130 @@ +import { posix, win32 } from 'node:path' +import { isWindowsAbsolutePathLike } from '../shared/cross-platform-path' +import { worktreeBaseRefFamily } from '../shared/worktree/base-ref' +import type { PreparedCheckoutMissReason } from '../shared/worktree/create-types' + +/** The subset of miss reasons this selection can produce; the rest are decided by the caller + * (sparse/existing-branch skips) or by the finalize step. */ +export type PreparationSelectionMissReason = Extract< + PreparedCheckoutMissReason, + | 'none_armed' + | 'repo_mismatch' + | 'base_mismatch' + | 'workspace_root_mismatch' + | 'wsl_distro_mismatch' +> + +export type PreparationCandidate = { + repoPathKey: string + workspaceRootKey: string + wslDistro: string + /** The base exactly as the prefetch handler armed it. */ + baseBranch: string + /** That base after `resolveWorktreeAddBaseRef`, so `main` and `refs/heads/main` compare equal. */ + canonicalBase: string + createdAt: number +} + +export type PreparationRequest = { + repoPathKey: string + workspaceRootKey: string + wslDistro: string + baseBranch: string + /** `null` until the caller has paid the ref probe. A raw-base match resolves without it, so the + * common hit spawns no git at all. */ + canonicalBase: string | null +} + +/** Case-folded on Windows, so the arming and claiming sides key on the same path. */ +export function preparationPathKey(path: string): string { + if (isWindowsAbsolutePathLike(path)) { + return win32.normalize(path).toLowerCase() + } + return posix.normalize(path) +} + +/** Keyed on the canonical base so the prefetch and the create agree when they spell the same ref + * differently; a genuinely different ref still gets its own entry. */ +export function preparationEntryKey( + repoPathKey: string, + workspaceRootKey: string, + canonicalBase: string, + wslDistro: string +): string { + return `${repoPathKey}\0${workspaceRootKey}\0${canonicalBase}\0${wslDistro}` +} + +export type PreparationSelection = + /** `canonicalBase` is echoed back so the caller can re-arm on the create's own base without + * paying the ref probe a second time. */ + | { kind: 'exact'; candidate: T; canonicalBase: string } + | { kind: 'retarget'; candidate: T; canonicalBase: string } + /** Something is armed for this repo but not under this raw base; only a resolved canonical base + * can decide between a hit and a miss. */ + | { kind: 'needs-canonical-base' } + | { kind: 'miss'; reason: PreparationSelectionMissReason } + +/** + * Picks the armed preparation a create may claim. + * + * The two sides of the pool disagree in practice — the prefetch arms `origin/main` while the + * create resolves `main`, or vice versa — and an exact-string key turns every such disagreement + * into a silent cold create. Canonicalizing catches the spelling differences; the base-family + * retarget catches the local-vs-remote-tracking ones, where finalize's existing drift reset lands + * the checkout on the requested commit for far less than a cold add plus a full materialize. + * + * The bound matters: refs outside the same branch family are rejected, because a retarget across + * unrelated history degenerates into a full checkout and wins nothing. + * + * Synchronous on purpose: the caller claims the returned entry in the same run, so two concurrent + * creates cannot both walk away with the same prepared checkout. + */ +export function selectPreparationForCreate( + candidates: readonly T[], + request: PreparationRequest +): PreparationSelection { + if (candidates.length === 0) { + return { kind: 'miss', reason: 'none_armed' } + } + const sameRepo = candidates.filter((candidate) => candidate.repoPathKey === request.repoPathKey) + if (sameRepo.length === 0) { + // Separate from `none_armed`: this is what a size-cap eviction looks like from the create side. + return { kind: 'miss', reason: 'repo_mismatch' } + } + // Distro before root: the distro decides which filesystem the root is even on. + const sameHost = sameRepo.filter((candidate) => candidate.wslDistro === request.wslDistro) + if (sameHost.length === 0) { + return { kind: 'miss', reason: 'wsl_distro_mismatch' } + } + const sameRoot = sameHost.filter( + (candidate) => candidate.workspaceRootKey === request.workspaceRootKey + ) + if (sameRoot.length === 0) { + return { kind: 'miss', reason: 'workspace_root_mismatch' } + } + + const { canonicalBase } = request + if (canonicalBase === null) { + const rawMatch = sameRoot.find((candidate) => candidate.baseBranch === request.baseBranch) + // Same spelling, so the armed entry already holds this request's canonical form. + return rawMatch + ? { kind: 'exact', candidate: rawMatch, canonicalBase: rawMatch.canonicalBase } + : { kind: 'needs-canonical-base' } + } + + const canonicalMatch = sameRoot.find((candidate) => candidate.canonicalBase === canonicalBase) + if (canonicalMatch) { + return { kind: 'exact', candidate: canonicalMatch, canonicalBase } + } + + const family = worktreeBaseRefFamily(canonicalBase) + if (family) { + const retarget = sameRoot + .filter((candidate) => worktreeBaseRefFamily(candidate.canonicalBase) === family) + .sort((left, right) => right.createdAt - left.createdAt)[0] + if (retarget) { + return { kind: 'retarget', candidate: retarget, canonicalBase } + } + } + return { kind: 'miss', reason: 'base_mismatch' } +} diff --git a/src/main/worktree-create-preparation-pool.ts b/src/main/worktree-create-preparation-pool.ts new file mode 100644 index 00000000000..26ee1c41aad --- /dev/null +++ b/src/main/worktree-create-preparation-pool.ts @@ -0,0 +1,210 @@ +import { randomUUID } from 'node:crypto' +import { mkdir } from 'node:fs/promises' +import { posix, win32 } from 'node:path' +import { isWindowsAbsolutePathLike } from '../shared/cross-platform-path' +import { + WORKTREE_CREATE_PREPARATION_DIRECTORY, + createWorktreePreparationLockReason +} from '../shared/worktree/create-preparation' +import type { AddWorktreeOptions } from './git/worktree' +import { prepareWorktreeCreateCheckout } from './git/worktree-create-preparation' +import { toHostFilesystemPath } from './host-tree-removal' +import { preparationEntryKey, preparationPathKey } from './worktree-create-preparation-claim' +import { + cleanupStalePreparations, + hasPendingStalePreparationCleanup, + resetStalePreparationCleanupForTests +} from './worktree-create-preparation-stale-cleanup' +import { + discardPreparationWithRetry, + resetPendingPreparationDiscardsForTests, + trackPreparationDiscard +} from './worktree-preparation-discard-retry' + +export const WORKTREE_CREATE_PREPARATION_TTL_MS = 5 * 60_000 +export const WORKTREE_CREATE_PREPARATION_LIMIT = 3 + +export type PreparationEntry = { + key: string + repoPath: string + repoPathKey: string + workspaceRoot: string + workspaceRootKey: string + wslDistro: string + baseBranch: string + canonicalBase: string + preparedPath: string + options: AddWorktreeOptions + createdAt: number + ready: Promise + expiration: NodeJS.Timeout +} + +export type StartPreparationArgs = { + repoPath: string + workspaceRoot: string + baseBranch: string + canonicalBase: string + options: AddWorktreeOptions +} + +const preparations = new Map() + +/** One repo on one Git host: the scope a stranded discard is retried under. */ +function preparationHostKey(repoPathKey: string, wslDistro: string): string { + return `${repoPathKey}\0${wslDistro}` +} + +/** A prepared checkout is a create that is either in flight or imminent. */ +export function hasPendingPreparations(): boolean { + return preparations.size > 0 || hasPendingStalePreparationCleanup() +} + +function pathOps(path: string): Pick { + return isWindowsAbsolutePathLike(path) ? win32 : posix +} + +async function discardEntry(entry: PreparationEntry): Promise { + // A failed checkout self-discards, but that self-discard is best-effort too, so it can strand the + // registration for the same reason the discard here can. Enrol either way. + await entry.ready.catch(() => {}) + await discardPreparationWithRetry({ + hostKey: preparationHostKey(entry.repoPathKey, entry.wslDistro), + repoPath: entry.repoPath, + preparedPath: entry.preparedPath, + options: entry.options + }) +} + +function discardEntryInBackground(entry: PreparationEntry): void { + // Tracked, not bare `void`: the test reset must be able to settle it before dropping the registry. + trackPreparationDiscard(discardEntry(entry)) +} + +function expireEntry(entry: PreparationEntry): void { + if (preparations.get(entry.key) !== entry) { + return + } + preparations.delete(entry.key) + discardEntryInBackground(entry) +} + +/** + * Frees a slot for an incoming preparation, preferring one the same workspace already owns. + * + * The cap is a disk bound — a prepared checkout is a full tree, ~200 MB of tracked content in the + * repo this was measured against — so it stays small. But flipping through the composer's base + * picker arms several preparations for one repo, and a plain oldest-first eviction let that churn + * throw away another project's warm checkout, which is a structural miss for anyone working across + * several repos. Evict the incoming workspace's own oldest entry first; only reach across + * workspaces when this one holds none. + */ +function enforcePreparationLimit( + repoPathKey: string, + workspaceRootKey: string, + wslDistro: string +): void { + while (preparations.size >= WORKTREE_CREATE_PREPARATION_LIMIT) { + const byAge = [...preparations.values()].sort((left, right) => left.createdAt - right.createdAt) + const victim = + byAge.find( + (entry) => + entry.repoPathKey === repoPathKey && + entry.workspaceRootKey === workspaceRootKey && + entry.wslDistro === wslDistro + ) ?? byAge[0] + if (!victim) { + return + } + preparations.delete(victim.key) + clearTimeout(victim.expiration) + discardEntryInBackground(victim) + } +} + +export function listPreparations(): PreparationEntry[] { + return [...preparations.values()] +} + +export function findPreparation( + repoPathKey: string, + workspaceRootKey: string, + canonicalBase: string, + wslDistro: string +): PreparationEntry | undefined { + return preparations.get( + preparationEntryKey(repoPathKey, workspaceRootKey, canonicalBase, wslDistro) + ) +} + +/** Removes an entry from the pool so no other create can claim it. Callers must run this in the + * same synchronous turn as the selection that produced `entry`. */ +export function takePreparation(entry: PreparationEntry): void { + preparations.delete(entry.key) + clearTimeout(entry.expiration) +} + +export function startPreparation({ + repoPath, + workspaceRoot, + baseBranch, + canonicalBase, + options +}: StartPreparationArgs): Promise { + const repoPathKey = preparationPathKey(repoPath) + const workspaceRootKey = preparationPathKey(workspaceRoot) + const wslDistro = options.wslDistro ?? '' + const key = preparationEntryKey(repoPathKey, workspaceRootKey, canonicalBase, wslDistro) + enforcePreparationLimit(repoPathKey, workspaceRootKey, wslDistro) + const preparationId = `${process.pid}-${randomUUID()}` + const lockReason = createWorktreePreparationLockReason(preparationId) + const preparationRoot = pathOps(workspaceRoot).join( + workspaceRoot, + WORKTREE_CREATE_PREPARATION_DIRECTORY + ) + const preparedPath = pathOps(workspaceRoot).join(preparationRoot, preparationId) + const entry = {} as PreparationEntry + const expiration = setTimeout(() => expireEntry(entry), WORKTREE_CREATE_PREPARATION_TTL_MS) + expiration.unref() + Object.assign(entry, { + key, + repoPath, + repoPathKey, + workspaceRoot, + workspaceRootKey, + wslDistro, + baseBranch, + canonicalBase, + preparedPath, + options, + createdAt: Date.now(), + expiration, + ready: (async () => { + await cleanupStalePreparations(preparationHostKey(repoPathKey, wslDistro), repoPath, options) + await mkdir(toHostFilesystemPath(preparationRoot), { recursive: true }) + // Already canonical, so the add re-resolves nothing. + await prepareWorktreeCreateCheckout(repoPath, preparedPath, canonicalBase, lockReason, options) + })() + } satisfies PreparationEntry) + preparations.set(key, entry) + void entry.ready.catch(() => { + if (preparations.get(key) === entry) { + preparations.delete(key) + clearTimeout(entry.expiration) + } + }) + return entry.ready +} + +export async function _resetPreparationPoolForTests(): Promise { + const entries = [...preparations.values()] + preparations.clear() + resetStalePreparationCleanupForTests() + await Promise.all( + entries.map(async (entry) => { + clearTimeout(entry.expiration) + await discardEntry(entry) + }) + ) + await resetPendingPreparationDiscardsForTests() +} diff --git a/src/main/worktree-create-preparation-stale-cleanup.ts b/src/main/worktree-create-preparation-stale-cleanup.ts new file mode 100644 index 00000000000..fd02b7cc0d1 --- /dev/null +++ b/src/main/worktree-create-preparation-stale-cleanup.ts @@ -0,0 +1,84 @@ +import { + isWorktreeCreatePreparation, + parseWorktreePreparationOwnerPid, + parseWorktreePreparationPathOwnerPid +} from '../shared/worktree/create-preparation' +import type { AddWorktreeOptions } from './git/worktree' +import { listWorktreeGraph } from './git/worktree' +import { discardPreparedWorktree, unlockPreparedWorktree } from './git/worktree-create-preparation' +import { retryPendingPreparationDiscards } from './worktree-preparation-discard-retry' + +const STALE_PREPARATION_CLEANUP_CONCURRENCY = 4 + +const staleCleanupInFlight = new Map>() + +function isProcessAlive(pid: number): boolean { + try { + process.kill(pid, 0) + return true + } catch (error) { + return (error as NodeJS.ErrnoException).code !== 'ESRCH' + } +} + +/** Reclaims preparations a crashed process left registered. Single-flighted per host key so a burst + * of arming calls shares one worktree listing. */ +export async function cleanupStalePreparations( + cleanupKey: string, + repoPath: string, + options: AddWorktreeOptions +): Promise { + const existing = staleCleanupInFlight.get(cleanupKey) + if (existing) { + await existing.catch(() => {}) + return + } + const cleanup = (async () => { + // Not awaited: the create path awaits this cleanup, and one stranded discard costs an unlock plus + // a `worktree remove --force` bounded at 30s each. Reclaiming leaked scratch must not delay create. + void retryPendingPreparationDiscards(cleanupKey) + const worktrees = await listWorktreeGraph(repoPath, { + ...options, + includeCreatePreparations: true + }) + const staleWorktrees = worktrees.filter(isWorktreeCreatePreparation) + let nextIndex = 0 + async function discardNextStalePreparation(): Promise { + while (nextIndex < staleWorktrees.length) { + const worktree = staleWorktrees[nextIndex] + nextIndex += 1 + const lockOwnerPid = parseWorktreePreparationOwnerPid(worktree.lockReason) + const pathOwnerPid = parseWorktreePreparationPathOwnerPid(worktree.path) + if (!lockOwnerPid || isProcessAlive(lockOwnerPid)) { + continue + } + // Preserve a branch-attached final path after a crash; only detached or + // still-hidden preparations are safe to discard automatically. + if (worktree.branch && pathOwnerPid === null) { + await unlockPreparedWorktree(repoPath, worktree.path, options).catch(() => {}) + } else if (pathOwnerPid === lockOwnerPid) { + await discardPreparedWorktree(repoPath, worktree.path, options).catch(() => {}) + } + } + } + const workerCount = Math.min(STALE_PREPARATION_CLEANUP_CONCURRENCY, staleWorktrees.length) + await Promise.all(Array.from({ length: workerCount }, () => discardNextStalePreparation())) + })() + staleCleanupInFlight.set(cleanupKey, cleanup) + try { + await cleanup.catch(() => {}) + } finally { + if (staleCleanupInFlight.get(cleanupKey) === cleanup) { + staleCleanupInFlight.delete(cleanupKey) + } + } +} + +/** True while a crash-recovery scan is running, which means a create is in flight or imminent. */ +export function hasPendingStalePreparationCleanup(): boolean { + return staleCleanupInFlight.size > 0 +} + +export function resetStalePreparationCleanupForTests(): void { + staleCleanupInFlight.clear() +} diff --git a/src/main/worktree-create-preparation-wsl-root.test.ts b/src/main/worktree-create-preparation-wsl-root.test.ts index f0c8e664298..2c752b74a80 100644 --- a/src/main/worktree-create-preparation-wsl-root.test.ts +++ b/src/main/worktree-create-preparation-wsl-root.test.ts @@ -16,7 +16,8 @@ const mocks = vi.hoisted(() => ({ getWorktreeOptions: vi.fn(), getMirrorDistro: vi.fn(), getWslHome: vi.fn(), - getWslHomeAsync: vi.fn() + getWslHomeAsync: vi.fn(), + resolveBaseRef: vi.fn() })) vi.mock('node:fs/promises', () => ({ mkdir: mocks.mkdir })) @@ -27,6 +28,9 @@ vi.mock('./git/worktree-create-preparation', () => ({ discardPreparedWorktree: mocks.discard, unlockPreparedWorktree: mocks.unlock })) +vi.mock('./git/worktree-base-ref-probe', () => ({ + resolveLocalWorktreeBaseRef: mocks.resolveBaseRef +})) vi.mock('./project-runtime-git-options', () => ({ getLocalProjectWorktreeGitOptions: mocks.getWorktreeOptions, getWorktreeMirrorDistro: mocks.getMirrorDistro @@ -86,6 +90,9 @@ beforeEach(() => { throw new Error('the blocking wsl.exe home probe must not run while preparing') }) mocks.getWslHomeAsync.mockReset().mockResolvedValue(WSL_HOME) + mocks.resolveBaseRef + .mockReset() + .mockImplementation(async (_repoPath: string, baseRef: string) => `refs/remotes/${baseRef}`) }) afterEach(async () => { diff --git a/src/main/worktree-create-preparation.test.ts b/src/main/worktree-create-preparation.test.ts index 01f8addaded..06818fec422 100644 --- a/src/main/worktree-create-preparation.test.ts +++ b/src/main/worktree-create-preparation.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { Store } from './persistence' import type { Repo } from '../shared/repo-types' import { WORKTREE_CREATE_PREPARATION_DIRECTORY } from '../shared/worktree/create-preparation' +import { resolveWorktreeAddBaseRef } from '../shared/worktree/base-ref' const mocks = vi.hoisted(() => ({ mkdir: vi.fn(), @@ -12,7 +13,9 @@ const mocks = vi.hoisted(() => ({ unlock: vi.fn(), getWorktreeOptions: vi.fn(), computeWorkspaceRoot: vi.fn(), - computeWorkspaceRootAsync: vi.fn() + computeWorkspaceRootAsync: vi.fn(), + resolveBaseRef: vi.fn(), + measureDivergence: vi.fn() })) vi.mock('node:fs/promises', () => ({ mkdir: mocks.mkdir })) @@ -23,6 +26,12 @@ vi.mock('./git/worktree-create-preparation', () => ({ discardPreparedWorktree: mocks.discard, unlockPreparedWorktree: mocks.unlock })) +vi.mock('./git/worktree-base-ref-probe', () => ({ + resolveLocalWorktreeBaseRef: mocks.resolveBaseRef +})) +vi.mock('./git/worktree-base-divergence', () => ({ + measureRetargetDivergence: mocks.measureDivergence +})) vi.mock('./project-runtime-git-options', () => ({ getLocalProjectWorktreeGitOptions: mocks.getWorktreeOptions, getWorktreeMirrorDistro: () => undefined @@ -39,9 +48,20 @@ vi.mock('./ipc/worktree-logic', () => ({ import { _resetWorktreeCreatePreparationsForTests, consumePreparedWorktreeCreate, + hasPendingWorktreeCreatePreparations, prepareWorktreeCreateForRepo } from './worktree-create-preparation' +// Evictions and retries are fire-and-forget, so let them settle before asserting. +function flushBackgroundWork(ms = 0): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + +const EXISTING_REFS = new Set([ + 'refs/heads/main', + 'refs/remotes/origin/main', + 'refs/remotes/origin/release' +]) const repo = { id: 'repo-1', path: '/repo' } as Repo const store = { getSettings: () => ({}) } as unknown as Store @@ -53,6 +73,12 @@ beforeEach(() => { mocks.discard.mockReset().mockResolvedValue(undefined) mocks.unlock.mockReset().mockResolvedValue(undefined) mocks.getWorktreeOptions.mockReset().mockReturnValue({}) + mocks.measureDivergence.mockReset().mockResolvedValue('within') + mocks.resolveBaseRef + .mockReset() + .mockImplementation((_repoPath: string, baseRef: string) => + resolveWorktreeAddBaseRef(baseRef, async (candidate) => EXISTING_REFS.has(candidate)) + ) mocks.computeWorkspaceRoot.mockReset().mockImplementation(() => { throw new Error('synchronous workspace-root lookup must not run on the main thread') }) @@ -129,7 +155,7 @@ describe('worktree create preparation registry', () => { expect(mocks.prepareCheckout).toHaveBeenCalledTimes(1) }) - it('does not claim a preparation after the selected base changes', async () => { + it('does not claim a preparation after the selected base changes to another branch', async () => { await prepareWorktreeCreateForRepo(store, repo, 'origin/main') await expect( @@ -140,10 +166,185 @@ describe('worktree create preparation registry', () => { branch: 'feature/test', baseBranch: 'origin/release' }) - ).resolves.toBeNull() + ).resolves.toEqual({ status: 'miss', reason: 'base_mismatch' }) expect(mocks.finalize).not.toHaveBeenCalled() }) + it('claims across the local/remote spelling of the same base and reports the retarget', async () => { + await prepareWorktreeCreateForRepo(store, repo, 'origin/main') + + // `main` has no local ref here, so the canonical forms differ and only the base family matches. + await expect( + consumePreparedWorktreeCreate({ + repoPath: repo.path, + workspaceRoot: '/workspace', + worktreePath: '/workspace/final', + branch: 'feature/test', + baseBranch: 'main' + }) + ).resolves.toEqual({ status: 'hit', retargeted: true, result: {} }) + // Finalize still receives the requested base, so it resets onto the requested commit. + expect(mocks.finalize).toHaveBeenCalledWith( + repo.path, + expect.any(String), + '/workspace/final', + 'feature/test', + 'main', + undefined, + {} + ) + }) + + it('refuses a same-family retarget whose bases have drifted too far apart', async () => { + await prepareWorktreeCreateForRepo(store, repo, 'origin/main') + // An abandoned fork's `main` is the same base family but a whole-tree checkout away. + mocks.measureDivergence.mockResolvedValue('exceeded') + + await expect( + consumePreparedWorktreeCreate({ + repoPath: repo.path, + workspaceRoot: '/workspace', + worktreePath: '/workspace/final', + branch: 'feature/test', + baseBranch: 'main' + }) + ).resolves.toEqual({ status: 'miss', reason: 'retarget_too_divergent' }) + expect(mocks.finalize).not.toHaveBeenCalled() + // The preparation is left armed for the base it actually holds. + expect(mocks.discard).not.toHaveBeenCalled() + }) + + it('separates a drift check that said no from one that could not answer', async () => { + await prepareWorktreeCreateForRepo(store, repo, 'origin/main') + // A timed-out or aborted walk skipped a retarget that may well have been cheap; that is a + // tuning signal, not the bound working as intended, so it must not report as excess drift. + mocks.measureDivergence.mockResolvedValue('unknown') + + await expect( + consumePreparedWorktreeCreate({ + repoPath: repo.path, + workspaceRoot: '/workspace', + worktreePath: '/workspace/final', + branch: 'feature/test', + baseBranch: 'main' + }) + ).resolves.toEqual({ status: 'miss', reason: 'retarget_unverifiable' }) + expect(mocks.finalize).not.toHaveBeenCalled() + expect(mocks.discard).not.toHaveBeenCalled() + }) + + it('does not spend a divergence walk when the base matches exactly', async () => { + await prepareWorktreeCreateForRepo(store, repo, 'origin/main') + + await consumePreparedWorktreeCreate({ + repoPath: repo.path, + workspaceRoot: '/workspace', + worktreePath: '/workspace/final', + branch: 'feature/test', + baseBranch: 'origin/main' + }) + + expect(mocks.measureDivergence).not.toHaveBeenCalled() + }) + + it('claims when the two sides spell the same ref differently', async () => { + await prepareWorktreeCreateForRepo(store, repo, 'origin/main') + + await expect( + consumePreparedWorktreeCreate({ + repoPath: repo.path, + workspaceRoot: '/workspace', + worktreePath: '/workspace/final', + branch: 'feature/test', + baseBranch: 'refs/remotes/origin/main' + }) + ).resolves.toEqual({ status: 'hit', retargeted: false, result: {} }) + }) + + it('never hands the same prepared checkout to two concurrent creates', async () => { + await prepareWorktreeCreateForRepo(store, repo, 'origin/main') + + // `main` needs the ref probe, so the claim has to await mid-flight — the window where a + // second create could otherwise walk away with the same preparation. + const [first, second] = await Promise.all([ + consumePreparedWorktreeCreate({ + repoPath: repo.path, + workspaceRoot: '/workspace', + worktreePath: '/workspace/first', + branch: 'feature/first', + baseBranch: 'main' + }), + consumePreparedWorktreeCreate({ + repoPath: repo.path, + workspaceRoot: '/workspace', + worktreePath: '/workspace/second', + branch: 'feature/second', + baseBranch: 'main' + }) + ]) + + expect([first.status, second.status]).toContain('hit') + const preparedPaths = mocks.finalize.mock.calls.map((call) => call[1]) + expect(new Set(preparedPaths).size).toBe(preparedPaths.length) + }) + + it('reports which part of the claim key disagreed', async () => { + await prepareWorktreeCreateForRepo(store, repo, 'origin/main') + + await expect( + consumePreparedWorktreeCreate({ + repoPath: repo.path, + workspaceRoot: '/other-workspace', + worktreePath: '/other-workspace/final', + branch: 'feature/test', + baseBranch: 'origin/main' + }) + ).resolves.toEqual({ status: 'miss', reason: 'workspace_root_mismatch' }) + + await expect( + consumePreparedWorktreeCreate({ + repoPath: repo.path, + workspaceRoot: '/workspace', + worktreePath: '/workspace/final', + branch: 'feature/test', + baseBranch: 'origin/main', + options: { wslDistro: 'Ubuntu' } + }) + ).resolves.toEqual({ status: 'miss', reason: 'wsl_distro_mismatch' }) + + await expect( + consumePreparedWorktreeCreate({ + repoPath: '/other-repo', + workspaceRoot: '/workspace', + worktreePath: '/workspace/final', + branch: 'feature/test', + baseBranch: 'origin/main' + }) + ).resolves.toEqual({ status: 'miss', reason: 'repo_mismatch' }) + expect(mocks.finalize).not.toHaveBeenCalled() + }) + + it("evicts a repo's own stale preparation before another repo's", async () => { + const otherRepo = { id: 'repo-2', path: '/other-repo' } as Repo + await prepareWorktreeCreateForRepo(store, otherRepo, 'origin/main') + // Fill the pool from one repo, as flipping the composer's base picker does, until the next + // arm has to evict something. + await prepareWorktreeCreateForRepo(store, repo, 'origin/main') + await prepareWorktreeCreateForRepo(store, repo, 'origin/release') + await prepareWorktreeCreateForRepo(store, repo, 'main') + + // The eviction must cost `repo` a slot, not `otherRepo` its warm checkout. + await expect( + consumePreparedWorktreeCreate({ + repoPath: otherRepo.path, + workspaceRoot: '/workspace', + worktreePath: '/workspace/other', + branch: 'feature/other', + baseBranch: 'origin/main' + }) + ).resolves.toMatchObject({ status: 'hit' }) + }) + it('routes preparation and finalization through the selected WSL runtime', async () => { const options = { wslDistro: 'Ubuntu' } mocks.getWorktreeOptions.mockReturnValue(options) @@ -161,7 +362,7 @@ describe('worktree create preparation registry', () => { expect(mocks.prepareCheckout).toHaveBeenCalledWith( repo.path, expect.any(String), - 'origin/main', + 'refs/remotes/origin/main', expect.any(String), options ) @@ -241,7 +442,7 @@ describe('worktree create preparation registry', () => { ) }) - it('cleans up and returns null so normal add can run when finalization fails', async () => { + it('cleans up and reports a finalize miss so normal add can run', async () => { await prepareWorktreeCreateForRepo(store, repo, 'origin/main') mocks.finalize.mockRejectedValueOnce(new Error('submodules prevent worktree move')) @@ -253,8 +454,294 @@ describe('worktree create preparation registry', () => { branch: 'feature/test', baseBranch: 'origin/main' }) - ).resolves.toBeNull() + ).resolves.toEqual({ status: 'miss', reason: 'finalize_failed' }) expect(mocks.mkdir).toHaveBeenCalledWith('/workspace', { recursive: true }) expect(mocks.discard).toHaveBeenCalledTimes(1) }) + + async function consumeOnce(name: string): Promise { + await consumePreparedWorktreeCreate({ + repoPath: repo.path, + workspaceRoot: '/workspace', + worktreePath: `/workspace/${name}`, + branch: `feature/${name}`, + baseBranch: 'origin/main' + }) + } + + it('does not re-arm after an isolated create', async () => { + await prepareWorktreeCreateForRepo(store, repo, 'origin/main') + await consumeOnce('only') + + // Why: a lone create would otherwise leave a full spare checkout on disk for the whole TTL. + expect(mocks.prepareCheckout).toHaveBeenCalledTimes(1) + }) + + it('re-arms a preparation once creates arrive in a burst', async () => { + await prepareWorktreeCreateForRepo(store, repo, 'origin/main') + await consumeOnce('first') + expect(mocks.prepareCheckout).toHaveBeenCalledTimes(1) + + await prepareWorktreeCreateForRepo(store, repo, 'origin/main') + await consumeOnce('second') + await prepareWorktreeCreateForRepo(store, repo, 'origin/main') + + expect(mocks.prepareCheckout).toHaveBeenCalledTimes(3) + // The replacement is claimable, so a third create still skips the cold add. + await expect( + consumePreparedWorktreeCreate({ + repoPath: repo.path, + workspaceRoot: '/workspace', + worktreePath: '/workspace/third', + branch: 'feature/third', + baseBranch: 'origin/main' + }) + ).resolves.toEqual({ status: 'hit', retargeted: false, result: {} }) + expect(mocks.finalize).toHaveBeenCalledTimes(3) + }) + + it('does not re-arm when finalization failed', async () => { + await prepareWorktreeCreateForRepo(store, repo, 'origin/main') + await consumeOnce('first') + await prepareWorktreeCreateForRepo(store, repo, 'origin/main') + mocks.prepareCheckout.mockClear() + mocks.finalize.mockRejectedValueOnce(new Error('submodules prevent worktree move')) + + await consumeOnce('second') + + expect(mocks.prepareCheckout).not.toHaveBeenCalled() + }) + + it('retries a discard that failed while this process is still alive', async () => { + await prepareWorktreeCreateForRepo(store, repo, 'origin/main') + const leakedPath = mocks.prepareCheckout.mock.calls[0][1] as string + mocks.discard.mockRejectedValueOnce(new Error('EBUSY')) + + // Fill the registry so the first preparation is evicted while its owner pid is still alive. + for (const base of ['origin/one', 'origin/two', 'origin/three']) { + await prepareWorktreeCreateForRepo(store, repo, base) + } + await flushBackgroundWork() + expect(mocks.discard).toHaveBeenCalledWith(repo.path, leakedPath, {}) + + mocks.discard.mockClear() + await prepareWorktreeCreateForRepo(store, repo, 'origin/four') + await flushBackgroundWork() + expect(mocks.discard).toHaveBeenCalledWith(repo.path, leakedPath, {}) + + mocks.discard.mockClear() + await prepareWorktreeCreateForRepo(store, repo, 'origin/five') + await flushBackgroundWork() + expect(mocks.discard).not.toHaveBeenCalledWith(repo.path, leakedPath, {}) + }) + + it('retries only the leaked paths belonging to the host being prepared', async () => { + const otherRepo = { ...repo, id: 'repo-2', path: '/other-repo' } as Repo + const unremovable = new Set() + mocks.discard.mockImplementation(async (_repoPath: string, path: string) => { + if (unremovable.has(path)) { + throw new Error('EBUSY') + } + }) + + await prepareWorktreeCreateForRepo(store, repo, 'origin/main') + const leakedHere = mocks.prepareCheckout.mock.calls[0][1] as string + await prepareWorktreeCreateForRepo(store, otherRepo, 'origin/main') + const leakedElsewhere = mocks.prepareCheckout.mock.calls[1][1] as string + unremovable.add(leakedHere) + unremovable.add(leakedElsewhere) + + // Evict through each host's own arming: eviction prefers the incoming workspace's oldest + // entry, so preparing for `repo` no longer reaches across and takes `otherRepo`'s. + for (const base of ['origin/one', 'origin/two']) { + await prepareWorktreeCreateForRepo(store, repo, base) + } + for (const base of ['origin/one', 'origin/two']) { + await prepareWorktreeCreateForRepo(store, otherRepo, base) + } + await flushBackgroundWork() + expect(mocks.discard).toHaveBeenCalledWith(repo.path, leakedHere, {}) + expect(mocks.discard).toHaveBeenCalledWith(otherRepo.path, leakedElsewhere, {}) + + mocks.discard.mockClear() + await prepareWorktreeCreateForRepo(store, repo, 'origin/four') + await flushBackgroundWork() + expect(mocks.discard).toHaveBeenCalledWith(repo.path, leakedHere, {}) + expect(mocks.discard.mock.calls.some((call) => call[1] === leakedElsewhere)).toBe(false) + }) + + it('stops retrying a preparation that never becomes removable', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + try { + await prepareWorktreeCreateForRepo(store, repo, 'origin/main') + const leakedPath = mocks.prepareCheckout.mock.calls[0][1] as string + mocks.discard.mockImplementation(async (_repoPath: string, path: string) => { + if (path === leakedPath) { + throw new Error('EBUSY') + } + }) + const leakedDiscards = (): number => + mocks.discard.mock.calls.filter((call) => call[1] === leakedPath).length + + for (const base of ['origin/one', 'origin/two', 'origin/three']) { + await prepareWorktreeCreateForRepo(store, repo, base) + } + await flushBackgroundWork() + expect(leakedDiscards()).toBe(1) + + for (const base of ['origin/four', 'origin/five', 'origin/six']) { + await prepareWorktreeCreateForRepo(store, repo, base) + await flushBackgroundWork() + } + expect(leakedDiscards()).toBe(3) + expect(warn).toHaveBeenCalledWith( + expect.stringContaining(`could not be discarded in 3 attempts; ${leakedPath}`), + expect.any(Error) + ) + } finally { + warn.mockRestore() + } + }) + + it('retries a failed checkout whose own self-discard also left the path registered', async () => { + let failCheckout!: (error: Error) => void + mocks.prepareCheckout.mockImplementationOnce( + () => + new Promise((_resolve, reject) => { + failCheckout = reject + }) + ) + const failing = prepareWorktreeCreateForRepo(store, repo, 'origin/main') + await flushBackgroundWork() + const leakedPath = mocks.prepareCheckout.mock.calls[0][1] as string + + // Evict it while its checkout is still in flight, so discardEntry runs on a failed preparation. + for (const base of ['origin/one', 'origin/two', 'origin/three']) { + await prepareWorktreeCreateForRepo(store, repo, base) + } + mocks.discard.mockRejectedValueOnce(new Error('EBUSY')) + failCheckout(new Error('worktree add failed')) + await failing.catch(() => {}) + await flushBackgroundWork() + expect(mocks.discard).toHaveBeenCalledWith(repo.path, leakedPath, {}) + + mocks.discard.mockClear() + await prepareWorktreeCreateForRepo(store, repo, 'origin/four') + await flushBackgroundWork() + expect(mocks.discard).toHaveBeenCalledWith(repo.path, leakedPath, {}) + }) + + it('scopes retries to the WSL distro whose preparation leaked', async () => { + const unremovable = new Set() + mocks.discard.mockImplementation(async (_repoPath: string, path: string) => { + if (unremovable.has(path)) { + throw new Error('EBUSY') + } + }) + + mocks.getWorktreeOptions.mockReturnValue({ wslDistro: 'Ubuntu' }) + await prepareWorktreeCreateForRepo(store, repo, 'origin/main') + const leakedOnUbuntu = mocks.prepareCheckout.mock.calls[0][1] as string + mocks.getWorktreeOptions.mockReturnValue({ wslDistro: 'Debian' }) + await prepareWorktreeCreateForRepo(store, repo, 'origin/main') + const leakedOnDebian = mocks.prepareCheckout.mock.calls[1][1] as string + unremovable.add(leakedOnUbuntu) + unremovable.add(leakedOnDebian) + + // Evict through each distro's own arming: the eviction scope includes the distro, so arming + // under Ubuntu no longer reaches across and takes the Debian entry. + mocks.getWorktreeOptions.mockReturnValue({ wslDistro: 'Ubuntu' }) + for (const base of ['origin/one', 'origin/two']) { + await prepareWorktreeCreateForRepo(store, repo, base) + } + mocks.getWorktreeOptions.mockReturnValue({ wslDistro: 'Debian' }) + await prepareWorktreeCreateForRepo(store, repo, 'origin/one') + mocks.getWorktreeOptions.mockReturnValue({ wslDistro: 'Ubuntu' }) + await flushBackgroundWork() + expect(mocks.discard).toHaveBeenCalledWith(repo.path, leakedOnUbuntu, { wslDistro: 'Ubuntu' }) + expect(mocks.discard).toHaveBeenCalledWith(repo.path, leakedOnDebian, { wslDistro: 'Debian' }) + + mocks.discard.mockClear() + await prepareWorktreeCreateForRepo(store, repo, 'origin/four') + await flushBackgroundWork() + expect(mocks.discard).toHaveBeenCalledWith(repo.path, leakedOnUbuntu, { wslDistro: 'Ubuntu' }) + expect(mocks.discard.mock.calls.some((call) => call[1] === leakedOnDebian)).toBe(false) + }) + + it('drops recorded discards when the registry is reset for tests', async () => { + await prepareWorktreeCreateForRepo(store, repo, 'origin/main') + const leakedPath = mocks.prepareCheckout.mock.calls[0][1] as string + // Reject on a real timer so the fire-and-forget discard is still in flight at reset. + mocks.discard.mockImplementationOnce(async () => { + await new Promise((resolve) => setTimeout(resolve, 5)) + throw new Error('EBUSY') + }) + + for (const base of ['origin/one', 'origin/two', 'origin/three']) { + await prepareWorktreeCreateForRepo(store, repo, base) + } + await _resetWorktreeCreatePreparationsForTests() + // Past the rejection timer: the reset must have absorbed the failure, not raced ahead of it. + await flushBackgroundWork(20) + + mocks.discard.mockClear() + await prepareWorktreeCreateForRepo(store, repo, 'origin/four') + await flushBackgroundWork() + expect(mocks.discard).not.toHaveBeenCalledWith(repo.path, leakedPath, {}) + }) + + it("settles an evicted preparation's discard before the reset drops the registry", async () => { + let failCheckout!: (error: Error) => void + mocks.prepareCheckout.mockImplementationOnce( + () => + new Promise((_resolve, reject) => { + failCheckout = reject + }) + ) + const failing = prepareWorktreeCreateForRepo(store, repo, 'origin/main') + await flushBackgroundWork() + const leakedPath = mocks.prepareCheckout.mock.calls[0][1] as string + mocks.discard.mockImplementation(async (_repoPath: string, path: string) => { + if (path === leakedPath) { + throw new Error('EBUSY') + } + }) + + for (const base of ['origin/one', 'origin/two', 'origin/three']) { + await prepareWorktreeCreateForRepo(store, repo, base) + } + // The eviction's discard is still parked on the checkout, so the reset has to wait for it. + const reset = _resetWorktreeCreatePreparationsForTests() + await flushBackgroundWork(5) + failCheckout(new Error('worktree add failed')) + await failing.catch(() => {}) + await reset + await flushBackgroundWork(5) + + mocks.discard.mockClear() + await prepareWorktreeCreateForRepo(store, repo, 'origin/four') + await flushBackgroundWork() + expect(mocks.discard).not.toHaveBeenCalledWith(repo.path, leakedPath, {}) + }) + + it('reports a pending create while a stale-cleanup scan is running', async () => { + let releaseListing!: () => void + mocks.listWorktreeGraph.mockReturnValueOnce( + new Promise((resolve) => { + releaseListing = () => resolve([]) + }) + ) + const arming = prepareWorktreeCreateForRepo(store, repo, 'origin/main') + // Anchor on the scan actually starting, not on a fixed number of microtasks: an await added + // ahead of it would otherwise make this pass vacuously rather than fail. + while (mocks.listWorktreeGraph.mock.calls.length === 0) { + await Promise.resolve() + } + + // Why: the idle gate must not start repo maintenance while crash recovery is mid-scan. + expect(hasPendingWorktreeCreatePreparations()).toBe(true) + + releaseListing() + await arming + }) }) diff --git a/src/main/worktree-create-preparation.ts b/src/main/worktree-create-preparation.ts index 4a39393347e..b13916194ca 100644 --- a/src/main/worktree-create-preparation.ts +++ b/src/main/worktree-create-preparation.ts @@ -1,47 +1,52 @@ -import { randomUUID } from 'node:crypto' import { mkdir } from 'node:fs/promises' import { posix, win32 } from 'node:path' import type { Store } from './persistence' import type { Repo } from '../shared/repo-types' import { isFolderRepo } from '../shared/repo-kind' import { isWindowsAbsolutePathLike } from '../shared/cross-platform-path' -import { - WORKTREE_CREATE_PREPARATION_DIRECTORY, - createWorktreePreparationLockReason, - isWorktreeCreatePreparation, - parseWorktreePreparationOwnerPid, - parseWorktreePreparationPathOwnerPid -} from '../shared/worktree/create-preparation' +import type { PreparedCheckoutMissReason } from '../shared/worktree/create-types' import type { AddWorktreeOptions, AddWorktreeResult } from './git/worktree' -import { listWorktreeGraph } from './git/worktree' +import { measureRetargetDivergence } from './git/worktree-base-divergence' +import { resolveLocalWorktreeBaseRef } from './git/worktree-base-ref-probe' +import { preparationPathKey, selectPreparationForCreate } from './worktree-create-preparation-claim' +import { + _resetPreparationPoolForTests, + findPreparation, + hasPendingPreparations, + listPreparations, + startPreparation, + takePreparation, + type PreparationEntry +} from './worktree-create-preparation-pool' import { discardPreparedWorktree, - finalizePreparedWorktree, - unlockPreparedWorktree, - prepareWorktreeCreateCheckout + finalizePreparedWorktree } from './git/worktree-create-preparation' import { getLocalProjectWorktreeGitOptions, getWorktreeMirrorDistro } from './project-runtime-git-options' import { computeWorkspaceRootAsync, getWorktreePathSettings } from './ipc/worktree-logic' +import { + recordPreparationConsume, + resetPreparationConsumeHistoryForTests +} from './worktree-create-preparation-burst' import { toHostFilesystemPath } from './host-tree-removal' -export const WORKTREE_CREATE_PREPARATION_TTL_MS = 5 * 60_000 -export const WORKTREE_CREATE_PREPARATION_LIMIT = 3 -const STALE_PREPARATION_CLEANUP_CONCURRENCY = 4 +export { + WORKTREE_CREATE_PREPARATION_LIMIT, + WORKTREE_CREATE_PREPARATION_TTL_MS +} from './worktree-create-preparation-pool' -type PreparationEntry = { - key: string - repoPath: string - workspaceRoot: string - preparedPath: string - options: AddWorktreeOptions - createdAt: number - ready: Promise - expiration: NodeJS.Timeout +/** A prepared checkout is a create that is either in flight or imminent. */ +export function hasPendingWorktreeCreatePreparations(): boolean { + return hasPendingPreparations() } +export type PreparedWorktreeCreateAttempt = + | { status: 'hit'; retargeted: boolean; result: AddWorktreeResult } + | { status: 'miss'; reason: PreparedCheckoutMissReason } + type ConsumePreparedWorktreeArgs = { repoPath: string workspaceRoot: string @@ -52,109 +57,16 @@ type ConsumePreparedWorktreeArgs = { options?: AddWorktreeOptions } -const preparations = new Map() -const staleCleanupInFlight = new Map>() - -function pathOps(path: string): Pick { - return isWindowsAbsolutePathLike(path) ? win32 : posix -} - -function pathKey(path: string): string { - const normalized = pathOps(path).normalize(path) - return isWindowsAbsolutePathLike(path) ? normalized.toLowerCase() : normalized -} - -function preparationKey( +function canonicalBaseRef( repoPath: string, - workspaceRoot: string, baseBranch: string, options: AddWorktreeOptions -): string { - return `${pathKey(repoPath)}\0${pathKey(workspaceRoot)}\0${baseBranch}\0${options.wslDistro ?? ''}` -} - -function isProcessAlive(pid: number): boolean { - try { - process.kill(pid, 0) - return true - } catch (error) { - return (error as NodeJS.ErrnoException).code !== 'ESRCH' - } -} - -async function discardEntry(entry: PreparationEntry): Promise { - await entry.ready.catch(() => {}) - await discardPreparedWorktree(entry.repoPath, entry.preparedPath, entry.options).catch(() => {}) -} - -function expireEntry(entry: PreparationEntry): void { - if (preparations.get(entry.key) !== entry) { - return - } - preparations.delete(entry.key) - void discardEntry(entry) -} - -function enforcePreparationLimit(): void { - while (preparations.size >= WORKTREE_CREATE_PREPARATION_LIMIT) { - const oldest = [...preparations.values()].sort( - (left, right) => left.createdAt - right.createdAt - )[0] - if (!oldest) { - return - } - preparations.delete(oldest.key) - clearTimeout(oldest.expiration) - void discardEntry(oldest) - } -} - -async function cleanupStalePreparations( - repoPath: string, - options: AddWorktreeOptions -): Promise { - const cleanupKey = `${pathKey(repoPath)}\0${options.wslDistro ?? ''}` - const existing = staleCleanupInFlight.get(cleanupKey) - if (existing) { - await existing.catch(() => {}) - return - } - const cleanup = (async () => { - const worktrees = await listWorktreeGraph(repoPath, { - ...options, - includeCreatePreparations: true - }) - const staleWorktrees = worktrees.filter(isWorktreeCreatePreparation) - let nextIndex = 0 - async function discardNextStalePreparation(): Promise { - while (nextIndex < staleWorktrees.length) { - const worktree = staleWorktrees[nextIndex] - nextIndex += 1 - const lockOwnerPid = parseWorktreePreparationOwnerPid(worktree.lockReason) - const pathOwnerPid = parseWorktreePreparationPathOwnerPid(worktree.path) - if (!lockOwnerPid || isProcessAlive(lockOwnerPid)) { - continue - } - // Preserve a branch-attached final path after a crash; only detached or - // still-hidden preparations are safe to discard automatically. - if (worktree.branch && pathOwnerPid === null) { - await unlockPreparedWorktree(repoPath, worktree.path, options).catch(() => {}) - } else if (pathOwnerPid === lockOwnerPid) { - await discardPreparedWorktree(repoPath, worktree.path, options).catch(() => {}) - } - } - } - const workerCount = Math.min(STALE_PREPARATION_CLEANUP_CONCURRENCY, staleWorktrees.length) - await Promise.all(Array.from({ length: workerCount }, () => discardNextStalePreparation())) - })() - staleCleanupInFlight.set(cleanupKey, cleanup) - try { - await cleanup.catch(() => {}) - } finally { - if (staleCleanupInFlight.get(cleanupKey) === cleanup) { - staleCleanupInFlight.delete(cleanupKey) - } - } +): Promise { + return resolveLocalWorktreeBaseRef( + repoPath, + baseBranch, + options.wslDistro ? { wslDistro: options.wslDistro } : {} + ) } export async function prepareWorktreeCreateForRepo( @@ -174,91 +86,147 @@ export async function prepareWorktreeCreateForRepo( repo.path, getWorktreePathSettings(repo, store.getSettings(), getWorktreeMirrorDistro(store, repo)) ) - const key = preparationKey(repo.path, workspaceRoot, baseBranch, options) - const existing = preparations.get(key) + const canonicalBase = await canonicalBaseRef(repo.path, baseBranch, options) + const existing = findPreparation( + preparationPathKey(repo.path), + preparationPathKey(workspaceRoot), + canonicalBase, + options.wslDistro ?? '' + ) if (existing) { return existing.ready } - enforcePreparationLimit() - const preparationId = `${process.pid}-${randomUUID()}` - const lockReason = createWorktreePreparationLockReason(preparationId) - const preparedPath = pathOps(workspaceRoot).join( - workspaceRoot, - WORKTREE_CREATE_PREPARATION_DIRECTORY, - preparationId - ) - const entry = {} as PreparationEntry - const expiration = setTimeout(() => expireEntry(entry), WORKTREE_CREATE_PREPARATION_TTL_MS) - expiration.unref() - Object.assign(entry, { - key, + return startPreparation({ repoPath: repo.path, workspaceRoot, - preparedPath, - options, - createdAt: Date.now(), - expiration, - ready: (async () => { - await cleanupStalePreparations(repo.path, options) - await mkdir( - toHostFilesystemPath( - pathOps(workspaceRoot).join(workspaceRoot, WORKTREE_CREATE_PREPARATION_DIRECTORY) - ), - { recursive: true } - ) - await prepareWorktreeCreateCheckout(repo.path, preparedPath, baseBranch, lockReason, options) - })() - } satisfies PreparationEntry) - preparations.set(key, entry) - void entry.ready.catch(() => { - if (preparations.get(key) === entry) { - preparations.delete(key) - clearTimeout(entry.expiration) - } + baseBranch, + canonicalBase, + options }) - return entry.ready } +type ClaimedPreparation = + | { status: 'claimed'; entry: PreparationEntry; retargeted: boolean; canonicalBase: string } + | { status: 'miss'; reason: PreparedCheckoutMissReason } + async function claimPreparedWorktree( - repoPath: string, - workspaceRoot: string, - baseBranch: string, + args: ConsumePreparedWorktreeArgs, options: AddWorktreeOptions -): Promise { - const key = preparationKey(repoPath, workspaceRoot, baseBranch, options) - const entry = preparations.get(key) - if (!entry) { - return null +): Promise { + const request = { + repoPathKey: preparationPathKey(args.repoPath), + workspaceRootKey: preparationPathKey(args.workspaceRoot), + wslDistro: options.wslDistro ?? '', + baseBranch: args.baseBranch } - preparations.delete(key) - clearTimeout(entry.expiration) + let selection = selectPreparationForCreate(listPreparations(), { + ...request, + canonicalBase: null + }) + if (selection.kind === 'needs-canonical-base') { + // The probe is the only await here, and the pool is re-read after it, so the select-and-take + // below stays one synchronous run and no other create can hold the same entry. + const canonicalBase = await canonicalBaseRef(args.repoPath, args.baseBranch, options) + selection = selectPreparationForCreate(listPreparations(), { ...request, canonicalBase }) + } + if (selection.kind !== 'exact' && selection.kind !== 'retarget') { + return { + status: 'miss', + reason: selection.kind === 'miss' ? selection.reason : 'base_mismatch' + } + } + if (selection.kind === 'retarget') { + const candidate = selection.candidate + const { canonicalBase } = selection + const divergence = await measureRetargetDivergence( + args.repoPath, + candidate.canonicalBase, + canonicalBase, + { + ...(options.wslDistro ? { wslDistro: options.wslDistro } : {}), + // Why forward it: a cancelled create must stop these probes now, not at the deadline. + ...(options.signal ? { signal: options.signal } : {}) + } + ) + if (divergence !== 'within') { + return { + status: 'miss', + reason: divergence === 'exceeded' ? 'retarget_too_divergent' : 'retarget_unverifiable' + } + } + // Re-select after the walk: the pool may have gained an exact match or lost this entry. A + // different retarget candidate is left for the next create rather than claimed unverified. + selection = selectPreparationForCreate(listPreparations(), { ...request, canonicalBase }) + if (selection.kind === 'miss' || selection.kind === 'needs-canonical-base') { + return { status: 'miss', reason: 'base_mismatch' } + } + if (selection.kind === 'retarget' && selection.candidate !== candidate) { + return { status: 'miss', reason: 'base_mismatch' } + } + } + const entry = selection.candidate + takePreparation(entry) try { await entry.ready - return entry + return { + status: 'claimed', + entry, + retargeted: selection.kind === 'retarget', + canonicalBase: selection.canonicalBase + } } catch { - return null + return { status: 'miss', reason: 'prepare_failed' } } } +/** Replaces a just-consumed preparation, re-armed on the base the create actually used so the + * next one hits exactly — but only once the user has shown they are creating in a burst. A + * replacement costs a full checkout and ~5 minutes of disk until its TTL, so arming one after an + * isolated create spends that on nobody. Never awaited: create has already returned by the time + * the replacement checkout finishes. */ +function rearmPreparation( + entry: PreparationEntry, + baseBranch: string, + canonicalBase: string +): void { + // Record first: a prefetch that re-armed this key while we finalized would otherwise swallow the + // consume, and the next create would look isolated when it is really the middle of a burst. + const continuesBurst = recordPreparationConsume(entry.key) + if ( + !continuesBurst || + findPreparation(entry.repoPathKey, entry.workspaceRootKey, canonicalBase, entry.wslDistro) + ) { + return + } + void startPreparation({ + repoPath: entry.repoPath, + workspaceRoot: entry.workspaceRoot, + baseBranch, + canonicalBase, + options: entry.options + }).catch(() => { + // Why: a warm-up failure is recovered by the normal add on the next create. + }) +} + export async function consumePreparedWorktreeCreate( args: ConsumePreparedWorktreeArgs -): Promise { +): Promise { const options = args.options ?? {} - const entry = await claimPreparedWorktree( - args.repoPath, - args.workspaceRoot, - args.baseBranch, - options - ) - if (!entry) { - return null + const claim = await claimPreparedWorktree(args, options) + if (claim.status === 'miss') { + return { status: 'miss', reason: claim.reason } } + const { entry } = claim try { - await mkdir(toHostFilesystemPath(pathOps(args.worktreePath).dirname(args.worktreePath)), { - recursive: true - }) - return await finalizePreparedWorktree( + const parentDir = isWindowsAbsolutePathLike(args.worktreePath) + ? win32.dirname(args.worktreePath) + : posix.dirname(args.worktreePath) + await mkdir(toHostFilesystemPath(parentDir), { recursive: true }) + // Finalize resolves the requested base itself and resets the prepared checkout onto that + // commit, so a retargeted claim is handed over at the requested commit or not at all. + const result = await finalizePreparedWorktree( args.repoPath, entry.preparedPath, args.worktreePath, @@ -267,24 +235,21 @@ export async function consumePreparedWorktreeCreate( args.refreshLocalBaseRef, options ) + // Consuming the only prepared checkout leaves the next create cold. Re-arm for a user who is + // creating in a burst; the TTL and the preparation limit still bound an unused replacement. + rearmPreparation(entry, args.baseBranch, claim.canonicalBase) + return { status: 'hit', retargeted: claim.retargeted, result } } catch (error) { await discardPreparedWorktree(args.repoPath, entry.preparedPath, options).catch(() => {}) console.warn( '[worktree-create] prepared checkout could not be finalized; using normal add', error ) - return null + return { status: 'miss', reason: 'finalize_failed' } } } export async function _resetWorktreeCreatePreparationsForTests(): Promise { - const entries = [...preparations.values()] - preparations.clear() - staleCleanupInFlight.clear() - await Promise.all( - entries.map(async (entry) => { - clearTimeout(entry.expiration) - await discardEntry(entry) - }) - ) + resetPreparationConsumeHistoryForTests() + await _resetPreparationPoolForTests() } diff --git a/src/main/worktree-create-timing.ts b/src/main/worktree-create-timing.ts index 433a9ac0098..bc1e49f4842 100644 --- a/src/main/worktree-create-timing.ts +++ b/src/main/worktree-create-timing.ts @@ -1,4 +1,5 @@ import type { + PreparedCheckoutOutcome, WorktreeCreateTiming, WorktreeCreateTimingPhase } from '../shared/worktree/create-types' @@ -8,6 +9,7 @@ type TimingClock = () => number export type WorktreeCreateTimingRecorder = { time(phase: string, operation: () => Promise): Promise timeSync(phase: string, operation: () => T): T + recordPreparedCheckout(outcome: PreparedCheckoutOutcome): void finish(): WorktreeCreateTiming } @@ -37,6 +39,7 @@ export function createWorktreeCreateTimingRecorder( ): WorktreeCreateTimingRecorder { const startedAt = clock() const phases: WorktreeCreateTimingPhase[] = [] + let preparedCheckout: PreparedCheckoutOutcome | undefined const recordPhase = (phase: string, operationStartedAt: number): void => { phases.push(createPhase(phase, operationStartedAt, clock(), startedAt)) @@ -59,10 +62,14 @@ export function createWorktreeCreateTimingRecorder( recordPhase(phase, operationStartedAt) } }, + recordPreparedCheckout(outcome: PreparedCheckoutOutcome): void { + preparedCheckout = outcome + }, finish() { return { totalDurationMs: clampDuration(clock() - startedAt), - phases: [...phases] + phases: [...phases], + ...(preparedCheckout ? { preparedCheckout } : {}) } } } diff --git a/src/main/worktree-identity-persistence.test.ts b/src/main/worktree-identity-persistence.test.ts index c66a2d72ce8..0b6dd178e84 100644 --- a/src/main/worktree-identity-persistence.test.ts +++ b/src/main/worktree-identity-persistence.test.ts @@ -5,12 +5,26 @@ import { tmpdir } from 'node:os' import type { PersistedState } from '../shared/persisted-state-types' import { canonicalWorktreeIdentity } from '../shared/worktree/identity' import { composeWorktreeHostIdentity } from '../shared/worktree/host-qualified-identity' -import { createStore, readDataFile, testState, writeDataFile } from './persistence-test-harness' +import type { Store } from './persistence/loading-store/store' +import { + createStore, + makeRepo, + readDataFile, + testState, + writeDataFile +} from './persistence-test-harness' describe('host-qualified worktree metadata', () => { const worktreeId = 'repo-1::/workspace/feature' const ROTATED_INSTANCE_ID = '44444444-4444-4444-8444-444444444444' + // Registered on purpose: rows owned by an unregistered repo id are swept as orphans on load. + const createStoreWithRepo = (): Store => { + const store = createStore() + store.addRepo(makeRepo({ id: 'repo-1', path: '/workspace' })) + return store + } + beforeEach(() => { testState.dir = mkdtempSync(join(tmpdir(), 'orca-worktree-identity-')) }) @@ -52,7 +66,7 @@ describe('host-qualified worktree metadata', () => { }) }) it('reloads host-specific metadata without collapsing it to the legacy locator', () => { - const store = createStore() + const store = createStoreWithRepo() store.setWorktreeMetaForHost(worktreeId, 'local', { displayName: 'Local feature' }) store.setWorktreeMetaForHost(worktreeId, 'ssh:build-box', { displayName: 'Remote feature' }) store.flush() @@ -72,7 +86,7 @@ describe('host-qualified worktree metadata', () => { expect(store.getWorktreeMetaForHost(worktreeId, 'local')?.comment).toBe('after') }) it('backfills one stable instance for legacy metadata that omitted it', () => { - const seed = createStore() + const seed = createStoreWithRepo() seed.setWorktreeMeta(worktreeId, { displayName: 'Legacy feature' }) seed.flush() const legacy = readDataFile() as PersistedState @@ -97,7 +111,7 @@ describe('host-qualified worktree metadata', () => { // Fails open on purpose: an ambiguous alias used to brick reads and throw out of the worktree // listing loop, taking every workspace in the repo down with it and never self-healing. it('collapses an ambiguous locator onto its most recently active instance', () => { - const seed = createStore() + const seed = createStoreWithRepo() const first = seed.setWorktreeMetaForHost(worktreeId, 'local', { displayName: 'First' }) seed.flush() const persisted = readDataFile() as PersistedState @@ -262,7 +276,7 @@ describe('host-qualified worktree metadata', () => { it('repairs a missing canonical instance id while re-adopting an SSH target', () => { const oldHostId = 'ssh:old-target' as const const newHostId = 'ssh:new-target' as const - const seed = createStore() + const seed = createStoreWithRepo() seed.setWorktreeMetaForHost(worktreeId, oldHostId, { displayName: 'Remote feature' }) seed.flush() const persisted = readDataFile() as PersistedState @@ -318,7 +332,7 @@ describe('host-qualified worktree metadata', () => { it('deduplicates an equivalent destination during SSH target re-adoption', () => { const oldHostId = 'ssh:old-target' as const const newHostId = 'ssh:new-target' as const - const seed = createStore() + const seed = createStoreWithRepo() seed.setWorktreeMetaForHost(worktreeId, oldHostId, { displayName: 'Remote feature' }) seed.flush() const persisted = readDataFile() as PersistedState diff --git a/src/main/worktree-preparation-discard-retry.ts b/src/main/worktree-preparation-discard-retry.ts new file mode 100644 index 00000000000..e18f890084c --- /dev/null +++ b/src/main/worktree-preparation-discard-retry.ts @@ -0,0 +1,71 @@ +import type { AddWorktreeOptions } from './git/worktree' +import { discardPreparedWorktree } from './git/worktree-create-preparation' + +// Stale cleanup only reclaims preparations whose owner pid is dead, so a discard that fails inside +// the live process would strand its scratch checkout until the app restarts. Remember the failure +// and retry it on the next preparation for the same host. +const PREPARATION_DISCARD_ATTEMPT_LIMIT = 3 + +export type PreparationDiscardTarget = { + hostKey: string + repoPath: string + preparedPath: string + options: AddWorktreeOptions +} + +const pendingDiscards = new Map() +const inFlightDiscards = new Set>() + +/** Keeps a fire-and-forget discard settleable by the test reset, which would otherwise race it. */ +export function trackPreparationDiscard(work: Promise): void { + inFlightDiscards.add(work) + void work.finally(() => inFlightDiscards.delete(work)) +} + +function pendingKey(target: PreparationDiscardTarget): string { + // Paths are generated by this process, so exact equality already identifies the preparation. + return `${target.hostKey}\0${target.preparedPath}` +} + +async function runDiscard(target: PreparationDiscardTarget, attempts: number): Promise { + try { + await discardPreparedWorktree(target.repoPath, target.preparedPath, target.options) + } catch (error) { + // Bounded: a path that never becomes removable must not tax every later preparation. + if (attempts >= PREPARATION_DISCARD_ATTEMPT_LIMIT) { + console.warn( + `[worktree-create] prepared checkout could not be discarded in ${attempts} attempts; ${target.preparedPath} stays registered until this process exits`, + error + ) + return + } + pendingDiscards.set(pendingKey(target), { ...target, attempts }) + } +} + +/** Never rejects. Records the target for a later retry when the discard fails. */ +export function discardPreparationWithRetry( + target: PreparationDiscardTarget, + attempts = 1 +): Promise { + // Claim the record so an overlapping retry pass cannot run the same discard twice. + pendingDiscards.delete(pendingKey(target)) + const discard = runDiscard(target, attempts) + trackPreparationDiscard(discard) + return discard +} + +export function retryPendingPreparationDiscards(hostKey: string): Promise { + const pending = [...pendingDiscards.values()].filter((target) => target.hostKey === hostKey) + return Promise.all( + pending.map(({ attempts, ...target }) => discardPreparationWithRetry(target, attempts + 1)) + ).then(() => undefined) +} + +/** Test-only: settle the fire-and-forget discards before dropping the registry. */ +export async function resetPendingPreparationDiscardsForTests(): Promise { + while (inFlightDiscards.size > 0) { + await Promise.all(inFlightDiscards) + } + pendingDiscards.clear() +} diff --git a/src/main/zsh-startup-hook-user-config-equivalence.live-shell.test.ts b/src/main/zsh-startup-hook-user-config-equivalence.live-shell.test.ts index e5e45d2c22a..cbb7d2da7c5 100644 --- a/src/main/zsh-startup-hook-user-config-equivalence.live-shell.test.ts +++ b/src/main/zsh-startup-hook-user-config-equivalence.live-shell.test.ts @@ -16,7 +16,7 @@ */ import { existsSync, mkdirSync, mkdtempSync, renameSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' -import { dirname, join } from 'node:path' +import { basename, dirname, join } from 'node:path' import { afterAll, beforeAll, describe, expect, it } from 'vitest' import { getShellLaunchConfig } from './providers/local-pty-shell-ready' import { selectShellStartupFeatures } from './shell-startup-features' @@ -382,9 +382,12 @@ describe.skipIf(process.platform === 'win32')('the fixes the old wrapper was bui // value this wrapper cannot use degrades to $HOME, where zsh itself looks. const home = makeZshHome({ '.zshrc': 'export ORCA_TEST_FROM_ZSHRC=1\n' }) try { + // Unique per run: a fixed name here shares one path with every other run in + // the system temp dir, so a killed run leaves a stale directory behind and + // every later rename onto it fails with ENOTEMPTY. const { values } = await runFromRelocatedRoot( home, - join(dirname(userDataPath), '홍길동-wsl-view') + join(dirname(userDataPath), `홍길동-${basename(userDataPath)}`) ) expect(values.ORCA_TEST_FROM_ZSHRC).toBe('1') diff --git a/src/preload/api/agent-awake-bridge.ts b/src/preload/api/agent-awake-bridge.ts new file mode 100644 index 00000000000..eff4263398e --- /dev/null +++ b/src/preload/api/agent-awake-bridge.ts @@ -0,0 +1,13 @@ +import { ipcRenderer } from 'electron' +import type { ComputerAwakeStatus } from '../../shared/computer-awake-mode' +import type { PreloadApi } from '../api-types' + +export const agentAwakeApi = { + getStatus: (): Promise => ipcRenderer.invoke('agentAwake:getStatus'), + onChanged: (callback: (status: ComputerAwakeStatus) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, status: ComputerAwakeStatus): void => + callback(status) + ipcRenderer.on('agentAwake:changed', listener) + return () => ipcRenderer.removeListener('agentAwake:changed', listener) + } +} satisfies PreloadApi['agentAwake'] diff --git a/src/preload/api/agent-status-bridge.ts b/src/preload/api/agent-status-bridge.ts new file mode 100644 index 00000000000..b5ac5c8b5b2 --- /dev/null +++ b/src/preload/api/agent-status-bridge.ts @@ -0,0 +1,89 @@ +import { ipcRenderer } from 'electron' +import type { + AgentStatusClearIpcPayload, + AgentStatusIpcPayload, + MigrationUnsupportedPtyEntry +} from '../../shared/agent-status-types' +import type { AgentInterruptInferenceRequest } from '../../shared/agent-interrupt-intent' +import type { AgentQuestionAnsweredInferenceRequest } from '../../shared/agent-question-answered-intent' +import type { PreloadApi } from '../api-types' + +export const agentStatusApi = { + /** Listen for agent status updates forwarded from native hook receivers. */ + onSet: (callback: (data: AgentStatusIpcPayload) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, data: AgentStatusIpcPayload) => + callback(data) + ipcRenderer.on('agentStatus:set', listener) + return () => ipcRenderer.removeListener('agentStatus:set', listener) + }, + onClear: (callback: (data: AgentStatusClearIpcPayload) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, data: AgentStatusClearIpcPayload) => + callback(data) + ipcRenderer.on('agentStatus:clear', listener) + return () => ipcRenderer.removeListener('agentStatus:clear', listener) + }, + /** Pull cached hook statuses after renderer hydration, so startup replays aren't lost before tabs exist. */ + getSnapshot: (): Promise => + ipcRenderer.invoke('agentStatus:getSnapshot'), + inferInterrupt: (request: AgentInterruptInferenceRequest): Promise => + ipcRenderer.invoke('agentStatus:inferInterrupt', request), + inferQuestionAnswered: (request: AgentQuestionAnsweredInferenceRequest): Promise => + ipcRenderer.invoke('agentStatus:inferQuestionAnswered', request), + onMigrationUnsupported: ( + callback: (entry: MigrationUnsupportedPtyEntry) => void + ): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, entry: MigrationUnsupportedPtyEntry) => + callback(entry) + ipcRenderer.on('agentStatus:migrationUnsupported', listener) + return () => ipcRenderer.removeListener('agentStatus:migrationUnsupported', listener) + }, + onMigrationUnsupportedClear: (callback: (data: { ptyId: string }) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, data: { ptyId: string }) => callback(data) + ipcRenderer.on('agentStatus:migrationUnsupportedClear', listener) + return () => ipcRenderer.removeListener('agentStatus:migrationUnsupportedClear', listener) + }, + onLegacyWorkerTerminalRecovery: ( + callback: (data: { + paneKey: string + resolution: 'adopted' | 'exited' | 'rolled_back' + ptyId?: string + }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { + paneKey: string + resolution: 'adopted' | 'exited' | 'rolled_back' + ptyId?: string + } + ) => callback(data) + ipcRenderer.on('agentStatus:legacyWorkerTerminalRecovery', listener) + return () => ipcRenderer.removeListener('agentStatus:legacyWorkerTerminalRecovery', listener) + }, + getMigrationUnsupportedSnapshot: (): Promise => + ipcRenderer.invoke('agentStatus:getMigrationUnsupportedSnapshot'), + /** Drop the cached hook status for a paneKey on both sides (memory + on-disk) so a relaunch can't resurrect a dismissed row. */ + drop: (paneKey: string): void => { + ipcRenderer.send('agentStatus:drop', paneKey) + }, + reconcileEndedProcess: (paneKey: string): void => { + ipcRenderer.send('agentStatus:reconcileEndedProcess', paneKey) + }, + /** Drop all cached hook statuses under one terminal tab prefix; fired on explicit tab close even without a local row. */ + dropByTabPrefix: (tabId: string): void => { + ipcRenderer.send('agentStatus:dropByTabPrefix', tabId) + }, + retirePaneAuthority: (paneKey: string): void => { + ipcRenderer.send('agentStatus:retirePaneAuthority', paneKey) + }, + restorePaneAuthority: (paneKey: string): void => { + ipcRenderer.send('agentStatus:restorePaneAuthority', paneKey) + }, + transferPaneAuthority: (args: { + fromPaneKey: string + toPaneKey: string + ptyId?: string + }): void => { + ipcRenderer.send('agentStatus:transferPaneAuthority', args) + } +} satisfies PreloadApi['agentStatus'] diff --git a/src/preload/api/agent-trust-bridge.ts b/src/preload/api/agent-trust-bridge.ts new file mode 100644 index 00000000000..5aca3fd805c --- /dev/null +++ b/src/preload/api/agent-trust-bridge.ts @@ -0,0 +1,10 @@ +import { ipcRenderer } from 'electron' +import type { PreloadApi } from '../api-types' + +export const agentTrustApi = { + markTrusted: (args: { + preset: 'cursor' | 'copilot' | 'codex' + workspacePath: string + connectionId?: string + }): Promise => ipcRenderer.invoke('agentTrust:markTrusted', args) +} satisfies PreloadApi['agentTrust'] diff --git a/src/preload/api/ai-vault-bridge.ts b/src/preload/api/ai-vault-bridge.ts new file mode 100644 index 00000000000..917c9f02b63 --- /dev/null +++ b/src/preload/api/ai-vault-bridge.ts @@ -0,0 +1,34 @@ +import { ipcRenderer } from 'electron' +import type { + AiVaultDeleteSessionArgs, + AiVaultDeleteSessionResult +} from '../../shared/ai-vault-session-deletion' +import type { + AiVaultFirstUserPromptArgs, + AiVaultListArgs, + AiVaultSubagentListArgs +} from '../../shared/ai-vault-types' +import type { AiVaultSessionTitlesArgs } from '../../shared/ai-vault-session-title' +import type { AiVaultPrepareSessionResumeArgs } from '../../shared/ai-vault-resume-preparation' +import type { PreloadApi } from '../api-types' + +export const aiVaultApi = { + listSessions: (args?: AiVaultListArgs) => ipcRenderer.invoke('aiVault:listSessions', args), + resolveSessionTitles: (args: AiVaultSessionTitlesArgs) => + ipcRenderer.invoke('aiVault:resolveSessionTitles', args), + cancelListSessions: (args: { requestToken: string }): Promise => + ipcRenderer.invoke('aiVault:cancelListSessions', args), + prepareSessionResume: (args: AiVaultPrepareSessionResumeArgs) => + ipcRenderer.invoke('aiVault:prepareSessionResume', args), + listSubagentSessions: (args: AiVaultSubagentListArgs) => + ipcRenderer.invoke('aiVault:listSubagentSessions', args), + getFirstUserPrompt: (args: AiVaultFirstUserPromptArgs) => + ipcRenderer.invoke('aiVault:getFirstUserPrompt', args), + deleteSession: (args: AiVaultDeleteSessionArgs): Promise => + ipcRenderer.invoke('aiVault:deleteSession', args), + onWindowFocused: (callback: () => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent) => callback() + ipcRenderer.on('aiVault:windowFocused', listener) + return () => ipcRenderer.removeListener('aiVault:windowFocused', listener) + } +} satisfies PreloadApi['aiVault'] diff --git a/src/preload/api/app-bridge.ts b/src/preload/api/app-bridge.ts new file mode 100644 index 00000000000..22d46cc40d2 --- /dev/null +++ b/src/preload/api/app-bridge.ts @@ -0,0 +1,77 @@ +import { ipcRenderer } from 'electron' +import type { AppIdentity } from '../../shared/app-identity' +import type { FloatingTerminalCwdRequest } from '../../shared/ui-chrome-types' +import type { MacCapturedDigitRowChord } from '../../shared/macos-symbolic-hotkeys' +import type { MarkdownDocument } from '../../shared/filesystem-entry-types' +import type { WriteTerminalRenderDesyncEvidenceArgs } from '../../shared/terminal-render-desync-evidence' +import { + KEYBOARD_LAYOUT_CHANGED_CHANNEL, + type KeyboardLayoutChangeEvent +} from '../../shared/keyboard-layout-events' +import { prepareAndInvokeAppRestart } from '../renderer-restart-wiring' +import { awaitBeforeUnloadCheckpoint, startupDiagnosticsEnabled } from '../preload-runtime-support' +import type { PreloadApi } from '../api-types' + +export const appApi = { + getIdentity: (): Promise => ipcRenderer.invoke('app:getIdentity'), + getFeatureWallAssetBaseUrl: (): Promise => + ipcRenderer.invoke('app:getFeatureWallAssetBaseUrl'), + relaunch: (): Promise => + prepareAndInvokeAppRestart( + window, + () => ipcRenderer.invoke('app:relaunch'), + awaitBeforeUnloadCheckpoint + ), + restart: (): Promise => + prepareAndInvokeAppRestart( + window, + () => ipcRenderer.invoke('app:restart'), + awaitBeforeUnloadCheckpoint + ), + reload: (): Promise => + prepareAndInvokeAppRestart( + window, + () => ipcRenderer.invoke('app:reload'), + awaitBeforeUnloadCheckpoint + ), + stageBeforeUnloadSync: (args: Parameters[0]) => { + const result = ipcRenderer.sendSync('app:stage-before-unload-sync', args) as { ok?: unknown } + if (result?.ok !== true) { + throw new Error('Failed to stage renderer state before unload.') + } + }, + awaitBeforeUnloadCheckpoint: () => awaitBeforeUnloadCheckpoint(), + awaitFirstWindowStartupServices: (): Promise => + ipcRenderer.invoke('app:awaitFirstWindowStartupServices'), + prepareTerminalStartupRestoration: (): Promise => + ipcRenderer.invoke('app:prepareTerminalStartupRestoration'), + recoverLegacyWorkerTerminalsForRendererStartup: (): Promise => + ipcRenderer.invoke('app:recoverLegacyWorkerTerminalsForRendererStartup'), + startupDiagnostic: (event: string, details?: Record): Promise => + startupDiagnosticsEnabled + ? ipcRenderer.invoke('app:startupDiagnostic', event, details) + : Promise.resolve(), + getKeyboardInputSourceId: (): Promise => + ipcRenderer.invoke('app:getKeyboardInputSourceId'), + getMacCapturedDigitRowChords: (): Promise => + ipcRenderer.invoke('app:getMacCapturedDigitRowChords'), + getKeyboardLayoutSnapshot: () => ipcRenderer.invoke('app:getKeyboardLayoutSnapshot'), + onKeyboardLayoutChanged: (callback: (event: KeyboardLayoutChangeEvent) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, event: KeyboardLayoutChangeEvent): void => + callback(event) + ipcRenderer.on(KEYBOARD_LAYOUT_CHANGED_CHANNEL, listener) + return () => ipcRenderer.removeListener(KEYBOARD_LAYOUT_CHANGED_CHANNEL, listener) + }, + setUnreadDockBadgeCount: (count: number): Promise => + ipcRenderer.invoke('app:setUnreadDockBadgeCount', count), + getFloatingTerminalCwd: (args?: FloatingTerminalCwdRequest): Promise => + ipcRenderer.invoke('app:getFloatingTerminalCwd', args), + getFloatingMarkdownDirectory: (): Promise => + ipcRenderer.invoke('app:getFloatingMarkdownDirectory'), + pickFloatingMarkdownDocument: (): Promise => + ipcRenderer.invoke('app:pickFloatingMarkdownDocument'), + pickFloatingWorkspaceDirectory: (): Promise => + ipcRenderer.invoke('app:pickFloatingWorkspaceDirectory'), + writeTerminalRenderDesyncEvidence: (args: WriteTerminalRenderDesyncEvidenceArgs) => + ipcRenderer.invoke('terminal:writeRenderDesyncEvidence', args) +} satisfies PreloadApi['app'] diff --git a/src/preload/api/automations-bridge.ts b/src/preload/api/automations-bridge.ts new file mode 100644 index 00000000000..43c3df528d8 --- /dev/null +++ b/src/preload/api/automations-bridge.ts @@ -0,0 +1,59 @@ +import { ipcRenderer } from 'electron' +import type { ExternalAutomationManagerResult, PreloadApi } from '../api-types' +import type { + AutomationDispatchRequest, + AutomationDispatchResult, + ExternalAutomationRunsPage, + AutomationRun, + AutomationPrecheckResult +} from '../../shared/automations-types' +import type { AutomationOwnerRef } from '../../shared/automation-owner-ref' +import type { + ScopedExternalManagerActionRequest, + ScopedExternalManagerCreateRequest, + ScopedExternalManagerListRequest, + ScopedExternalManagerRunsRequest, + ScopedExternalManagerUpdateRequest +} from '../../shared/external-automation-scope' +import type { AutomationsChangedPayload } from '../../shared/runtime-client-events' + +export const automationsApi = { + listExternalManagerForOwner: ( + request: ScopedExternalManagerListRequest + ): Promise => + ipcRenderer.invoke('automations:listExternalManagerForOwner', request), + listExternalRunsForOwner: ( + request: ScopedExternalManagerRunsRequest + ): Promise => + ipcRenderer.invoke('automations:listExternalRunsForOwner', request), + createExternalForOwner: (request: ScopedExternalManagerCreateRequest): Promise => + ipcRenderer.invoke('automations:createExternalForOwner', request), + updateExternalForOwner: (request: ScopedExternalManagerUpdateRequest): Promise => + ipcRenderer.invoke('automations:updateExternalForOwner', request), + runExternalActionForOwner: (request: ScopedExternalManagerActionRequest): Promise => + ipcRenderer.invoke('automations:runExternalActionForOwner', request), + retainExternalScopes: (request: { owners: readonly AutomationOwnerRef[] }): Promise => + ipcRenderer.invoke('automations:retainExternalScopes', request), + runPrecheck: (args: { + automationId: string + runId: string + }): Promise => + ipcRenderer.invoke('automations:runPrecheck', args), + markDispatchResult: (result: AutomationDispatchResult): Promise => + ipcRenderer.invoke('automations:markDispatchResult', result), + snapshotWorkspaceName: (args: { workspaceId: string; displayName: string }): Promise => + ipcRenderer.invoke('automations:snapshotWorkspaceName', args), + rendererReady: (): Promise => ipcRenderer.invoke('automations:rendererReady'), + onDispatchRequested: (callback: (request: AutomationDispatchRequest) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, request: AutomationDispatchRequest) => + callback(request) + ipcRenderer.on('automations:dispatchRequested', listener) + return () => ipcRenderer.removeListener('automations:dispatchRequested', listener) + }, + onChanged: (callback: (payload: AutomationsChangedPayload) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, payload: AutomationsChangedPayload) => + callback(payload) + ipcRenderer.on('automations:changed', listener) + return () => ipcRenderer.removeListener('automations:changed', listener) + } +} satisfies PreloadApi['automations'] diff --git a/src/preload/api/bitbucket-bridge.ts b/src/preload/api/bitbucket-bridge.ts new file mode 100644 index 00000000000..dd683b1387b --- /dev/null +++ b/src/preload/api/bitbucket-bridge.ts @@ -0,0 +1,17 @@ +import { ipcRenderer } from 'electron' +import type { PreloadApi } from '../api-types' + +export const bitbucketApi = { + connect: (args: { + authMode: 'token' | 'basic' + accessToken?: string | null + email?: string | null + apiToken?: string | null + baseUrl?: string | null + }): Promise<{ ok: true; account: string | null } | { ok: false; error: string }> => + ipcRenderer.invoke('bitbucket:connect', args), + + disconnect: (): Promise => ipcRenderer.invoke('bitbucket:disconnect'), + + status: () => ipcRenderer.invoke('bitbucket:status') +} satisfies PreloadApi['bitbucket'] diff --git a/src/preload/api/browser-bridge-guest-registration-and-downloads.ts b/src/preload/api/browser-bridge-guest-registration-and-downloads.ts new file mode 100644 index 00000000000..9d782971d96 --- /dev/null +++ b/src/preload/api/browser-bridge-guest-registration-and-downloads.ts @@ -0,0 +1,198 @@ +import { ipcRenderer } from 'electron' +import type { BrowserViewportOverride } from '../../shared/browser-workspace-types' +import type { + BrowserWebAuthnAccountRequest, + BrowserWebAuthnAccountResponse +} from '../../shared/browser-webauthn-account' +import { readBrowserClientHostIdArgument } from '../../shared/browser-client-host-id-argument' +import { browserClientPageRendererRequests } from '../preload-runtime-support' +import type { PreloadApi } from '../api-types' + +export const browserGuestRegistrationAndDownloadsApi = { + onClientPageRendererRequest: browserClientPageRendererRequests.subscribe, + readClientHostId: (): string | null => readBrowserClientHostIdArgument(process.argv), + registerGuest: (args: { + browserPageId: string + workspaceId: string + worktreeId: string + sessionProfileId?: string | null + webContentsId: number + }): Promise => ipcRenderer.invoke('browser:registerGuest', args), + isGuestRegistered: (args: { browserPageId: string; webContentsId: number }): Promise => + ipcRenderer.invoke('browser:isGuestRegistered', args), + repairGuestRegistration: (args: { + browserPageId: string + workspaceId: string + worktreeId: string + sessionProfileId?: string | null + webContentsId: number + }): Promise => ipcRenderer.invoke('browser:repairGuestRegistration', args), + unregisterGuest: (args: { browserPageId: string }): Promise => + ipcRenderer.invoke('browser:unregisterGuest', args), + onWebAuthnAccountRequest: ( + callback: (request: BrowserWebAuthnAccountRequest) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + request: BrowserWebAuthnAccountRequest + ): void => callback(request) + ipcRenderer.on('browser:webauthn-account-requested', listener) + return () => ipcRenderer.removeListener('browser:webauthn-account-requested', listener) + }, + onWebAuthnAccountRequestClosed: ( + callback: (event: { requestId: string }) => void + ): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, data: { requestId: string }): void => + callback(data) + ipcRenderer.on('browser:webauthn-account-request-closed', listener) + return () => ipcRenderer.removeListener('browser:webauthn-account-request-closed', listener) + }, + respondWebAuthnAccount: (response: BrowserWebAuthnAccountResponse): Promise => + ipcRenderer.invoke('browser:respondWebAuthnAccount', response), + openDevTools: (args: { browserPageId: string }): Promise => + ipcRenderer.invoke('browser:openDevTools', args), + setViewportOverride: (args: { + browserPageId: string + override: BrowserViewportOverride | null + }): Promise => ipcRenderer.invoke('browser:setViewportOverride', args), + reportViewportScrollState: (args: { + browserPageId: string + state: { + scrollLeft: number + scrollTop: number + maxScrollLeft: number + maxScrollTop: number + } + }): void => ipcRenderer.send('browser:reportViewportScrollState', args), + setAnnotationViewportBridge: (args): Promise => + ipcRenderer.invoke('browser:setAnnotationViewportBridge', args), + publishClientPageMetadata: (args) => + ipcRenderer.invoke('browser:publishClientPageMetadata', args), + onGuestLoadFailed: ( + callback: (args: { + browserPageId: string + loadError: { code: number; description: string; validatedUrl: string } + }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { + browserPageId: string + loadError: { code: number; description: string; validatedUrl: string } + } + ) => callback(data) + ipcRenderer.on('browser:guest-load-failed', listener) + 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) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { browserPageId: string; permission: string; origin: string } + ) => callback(data) + ipcRenderer.on('browser:permission-denied', listener) + return () => ipcRenderer.removeListener('browser:permission-denied', listener) + }, + onPopup: ( + callback: (event: { + browserPageId: string + origin: string + action: 'opened-in-orca' | 'opened-external' | 'blocked' + }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { + browserPageId: string + origin: string + action: 'opened-in-orca' | 'opened-external' | 'blocked' + } + ) => callback(data) + ipcRenderer.on('browser:popup', listener) + return () => ipcRenderer.removeListener('browser:popup', listener) + }, + onDownloadRequested: ( + callback: (event: { + browserPageId: string + downloadId: string + origin: string + filename: string + totalBytes: number | null + mimeType: string | null + savePath: string + status: 'downloading' + }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { + browserPageId: string + downloadId: string + origin: string + filename: string + totalBytes: number | null + mimeType: string | null + savePath: string + status: 'downloading' + } + ) => callback(data) + ipcRenderer.on('browser:download-requested', listener) + return () => ipcRenderer.removeListener('browser:download-requested', listener) + }, + onDownloadProgress: ( + callback: (event: { + browserPageId?: string + downloadId: string + receivedBytes: number + totalBytes: number | null + state: 'progressing' | 'interrupted' | null + }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { + browserPageId?: string + downloadId: string + receivedBytes: number + totalBytes: number | null + state: 'progressing' | 'interrupted' | null + } + ) => callback(data) + ipcRenderer.on('browser:download-progress', listener) + return () => ipcRenderer.removeListener('browser:download-progress', listener) + }, + onDownloadFinished: ( + callback: (event: { + browserPageId?: string + downloadId: string + status: 'completed' | 'canceled' | 'failed' + savePath: string | null + remoteDestination?: { workspaceRelativePath: string; hostLabel: string } + error: string | null + }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { + browserPageId?: string + downloadId: string + status: 'completed' | 'canceled' | 'failed' + savePath: string | null + remoteDestination?: { workspaceRelativePath: string; hostLabel: string } + error: string | null + } + ) => callback(data) + ipcRenderer.on('browser:download-finished', listener) + return () => ipcRenderer.removeListener('browser:download-finished', listener) + } +} satisfies Partial diff --git a/src/preload/api/browser-bridge-page-interaction-and-sessions.ts b/src/preload/api/browser-bridge-page-interaction-and-sessions.ts new file mode 100644 index 00000000000..93d6001e61c --- /dev/null +++ b/src/preload/api/browser-bridge-page-interaction-and-sessions.ts @@ -0,0 +1,148 @@ +import { ipcRenderer } from 'electron' +import type { PreloadApi } from '../api-types' + +export const browserPageInteractionAndSessionsApi = { + onContextMenuRequested: ( + callback: (event: { + browserPageId: string + x: number + y: number + screenX: number + screenY: number + pageUrl: string + linkUrl: string | null + selectionText: string + canGoBack: boolean + canGoForward: boolean + }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { + browserPageId: string + x: number + y: number + screenX: number + screenY: number + pageUrl: string + linkUrl: string | null + selectionText: string + canGoBack: boolean + canGoForward: boolean + } + ) => callback(data) + ipcRenderer.on('browser:context-menu-requested', listener) + return () => ipcRenderer.removeListener('browser:context-menu-requested', listener) + }, + onContextMenuDismissed: (callback: (event: { browserPageId: string }) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, data: { browserPageId: string }) => + callback(data) + ipcRenderer.on('browser:context-menu-dismissed', listener) + return () => ipcRenderer.removeListener('browser:context-menu-dismissed', listener) + }, + onNavigationUpdate: ( + callback: (event: { browserPageId: string; url: string; title: string }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { browserPageId: string; url: string; title: string } + ) => callback(data) + ipcRenderer.on('browser:navigation-update', listener) + return () => ipcRenderer.removeListener('browser:navigation-update', listener) + }, + onActivateView: ( + callback: (data: { worktreeId?: string; browserPageId?: string }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { worktreeId?: string; browserPageId?: string } + ) => callback(data) + ipcRenderer.on('browser:activateView', listener) + return () => ipcRenderer.removeListener('browser:activateView', listener) + }, + onPaneFocus: ( + callback: (data: { worktreeId: string | null; browserPageId: string }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { worktreeId: string | null; browserPageId: string } + ) => callback(data) + ipcRenderer.on('browser:pane-focus', listener) + return () => ipcRenderer.removeListener('browser:pane-focus', listener) + }, + onOpenLinkInOrcaTab: ( + callback: (event: { browserPageId: string; url: string }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { browserPageId: string; url: string } + ) => callback(data) + ipcRenderer.on('browser:open-link-in-orca-tab', listener) + return () => ipcRenderer.removeListener('browser:open-link-in-orca-tab', listener) + }, + cancelDownload: (args: { downloadId: string }): Promise => + ipcRenderer.invoke('browser:cancelDownload', args), + setGrabMode: (args: { browserPageId: string; enabled: boolean }) => + ipcRenderer.invoke('browser:setGrabMode', args), + awaitGrabSelection: (args: { browserPageId: string; opId: string }) => + ipcRenderer.invoke('browser:awaitGrabSelection', args), + cancelGrab: (args: { browserPageId: string }): Promise => + ipcRenderer.invoke('browser:cancelGrab', args), + captureSelectionScreenshot: (args: { + browserPageId: string + rect: { x: number; y: number; width: number; height: number } + }) => ipcRenderer.invoke('browser:captureSelectionScreenshot', args), + extractHoverPayload: (args: { browserPageId: string }) => + ipcRenderer.invoke('browser:extractHoverPayload', args), + onGrabModeToggle: (callback: (browserPageId: string) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, browserPageId: string) => + callback(browserPageId) + ipcRenderer.on('browser:grabModeToggle', listener) + return () => ipcRenderer.removeListener('browser:grabModeToggle', listener) + }, + onGrabActionShortcut: ( + callback: (args: { browserPageId: string; key: 'c' | 's' }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { browserPageId: string; key: 'c' | 's' } + ) => callback(data) + ipcRenderer.on('browser:grabActionShortcut', listener) + return () => ipcRenderer.removeListener('browser:grabActionShortcut', listener) + }, + sessionListProfiles: () => ipcRenderer.invoke('browser:session:listProfiles'), + prepareSshWorkspacePartition: (args: { + targetId: string + browserProfileId?: string + skipProbe?: boolean + }): Promise<{ partition: string }> => + ipcRenderer.invoke('browser:prepareSshWorkspacePartition', args), + sessionCreateProfile: (args: { + scope: 'default' | 'isolated' | 'imported' + label: string + userAgentMode?: 'clean' | 'native' + }) => ipcRenderer.invoke('browser:session:createProfile', args), + sessionDeleteProfile: (args: { profileId: string }): Promise => + ipcRenderer.invoke('browser:session:deleteProfile', args), + sessionImportCookies: (args: { profileId: string }) => + ipcRenderer.invoke('browser:session:importCookies', args), + sessionResolvePartition: (args: { profileId: string | null }): Promise => + ipcRenderer.invoke('browser:session:resolvePartition', args), + sessionDetectBrowsers: () => ipcRenderer.invoke('browser:session:detectBrowsers'), + sessionDetectBrowsersForClientHost: (args: { environmentId: string }) => + ipcRenderer.invoke('browser:session:detectBrowsersForClientHost', args), + sessionImportFromBrowser: (args: { profileId: string; browserFamily: string }) => + ipcRenderer.invoke('browser:session:importFromBrowser', args), + sessionImportFromBrowserForClientHost: (args: { + environmentId: string + profileId: string + browserFamily: string + browserProfile?: string + }) => ipcRenderer.invoke('browser:session:importFromBrowserForClientHost', args), + sessionClientRouteImportSources: (args: { environmentId: string }) => + ipcRenderer.invoke('browser:session:clientRouteImportSources', args), + sessionClearDefaultCookies: (): Promise => + ipcRenderer.invoke('browser:session:clearDefaultCookies'), + notifyActiveTabChanged: (args: { browserPageId: string }): Promise => + ipcRenderer.invoke('browser:activeTabChanged', args) +} satisfies Partial diff --git a/src/preload/api/browser-bridge.ts b/src/preload/api/browser-bridge.ts new file mode 100644 index 00000000000..d29b7365dc2 --- /dev/null +++ b/src/preload/api/browser-bridge.ts @@ -0,0 +1,8 @@ +import { browserGuestRegistrationAndDownloadsApi } from './browser-bridge-guest-registration-and-downloads' +import { browserPageInteractionAndSessionsApi } from './browser-bridge-page-interaction-and-sessions' +import type { PreloadApi } from '../api-types' + +export const browserApi = { + ...browserGuestRegistrationAndDownloadsApi, + ...browserPageInteractionAndSessionsApi +} satisfies PreloadApi['browser'] diff --git a/src/preload/api/cache-bridge.ts b/src/preload/api/cache-bridge.ts new file mode 100644 index 00000000000..73fd9db88c0 --- /dev/null +++ b/src/preload/api/cache-bridge.ts @@ -0,0 +1,7 @@ +import { ipcRenderer } from 'electron' +import type { PreloadApi } from '../api-types' + +export const cacheApi = { + getGitHub: () => ipcRenderer.invoke('cache:getGitHub'), + setGitHub: (args) => ipcRenderer.invoke('cache:setGitHub', args) +} satisfies PreloadApi['cache'] diff --git a/src/preload/api/claude-accounts-bridge.ts b/src/preload/api/claude-accounts-bridge.ts new file mode 100644 index 00000000000..69586525962 --- /dev/null +++ b/src/preload/api/claude-accounts-bridge.ts @@ -0,0 +1,18 @@ +import { ipcRenderer } from 'electron' +import type { PreloadApi } from '../api-types' + +export const claudeAccountsApi = { + list: () => ipcRenderer.invoke('claudeAccounts:list'), + add: (args?: { runtime?: 'host' | 'wsl'; wslDistro?: string | null }) => + ipcRenderer.invoke('claudeAccounts:add', args), + cancelPendingLogin: (): Promise => + ipcRenderer.invoke('claudeAccounts:cancelPendingLogin'), + reauthenticate: (args: { accountId: string }) => + ipcRenderer.invoke('claudeAccounts:reauthenticate', args), + remove: (args: { accountId: string }) => ipcRenderer.invoke('claudeAccounts:remove', args), + select: (args: { + accountId: string | null + runtime?: 'host' | 'wsl' + wslDistro?: string | null + }) => ipcRenderer.invoke('claudeAccounts:select', args) +} satisfies PreloadApi['claudeAccounts'] diff --git a/src/preload/api/claude-usage-bridge.ts b/src/preload/api/claude-usage-bridge.ts new file mode 100644 index 00000000000..1b98202d88c --- /dev/null +++ b/src/preload/api/claude-usage-bridge.ts @@ -0,0 +1,8 @@ +import { ipcRenderer } from 'electron' +import { createUsageProviderApi } from '../usage-provider-api' +import type { PreloadApi } from '../api-types' + +export const claudeUsageApi = createUsageProviderApi( + ipcRenderer, + 'claudeUsage' +) satisfies PreloadApi['claudeUsage'] diff --git a/src/preload/api/cli-bridge.ts b/src/preload/api/cli-bridge.ts new file mode 100644 index 00000000000..76b574a2f44 --- /dev/null +++ b/src/preload/api/cli-bridge.ts @@ -0,0 +1,15 @@ +import { ipcRenderer } from 'electron' +import type { CliInstallStatus } from '../../shared/cli-install-types' +import type { PreloadApi } from '../api-types' + +export const cliApi = { + getInstallStatus: (): Promise => ipcRenderer.invoke('cli:getInstallStatus'), + install: (): Promise => ipcRenderer.invoke('cli:install'), + remove: (): Promise => ipcRenderer.invoke('cli:remove'), + getWslInstallStatus: (args?: { distro?: string | null }): Promise => + ipcRenderer.invoke('cli:getWslInstallStatus', args), + installWsl: (args?: { distro?: string | null }): Promise => + ipcRenderer.invoke('cli:installWsl', args), + removeWsl: (args?: { distro?: string | null }): Promise => + ipcRenderer.invoke('cli:removeWsl', args) +} satisfies PreloadApi['cli'] diff --git a/src/preload/api/codex-accounts-bridge.ts b/src/preload/api/codex-accounts-bridge.ts new file mode 100644 index 00000000000..d085de45855 --- /dev/null +++ b/src/preload/api/codex-accounts-bridge.ts @@ -0,0 +1,30 @@ +import { ipcRenderer } from 'electron' +import type { PreloadApi } from '../api-types' + +export const codexAccountsApi = { + list: () => ipcRenderer.invoke('codexAccounts:list'), + add: (args?: { runtime?: 'host' | 'wsl'; wslDistro?: string | null }) => + ipcRenderer.invoke('codexAccounts:add', args), + reauthenticate: (args: { accountId: string; activateIfSelectionWasEmpty?: boolean }) => + ipcRenderer.invoke('codexAccounts:reauthenticate', args), + remove: (args: { accountId: string }) => ipcRenderer.invoke('codexAccounts:remove', args), + select: (args: { + accountId: string | null + runtime?: 'host' | 'wsl' + wslDistro?: string | null + }) => ipcRenderer.invoke('codexAccounts:select', args), + listStalePanes: (args: { + ptyIds: string[] + }): Promise< + { + ptyId: string + launchAccountId: string | null + activeAccountId: string | null + reason?: 'account-change' | 'home-route-change' + }[] + > => ipcRenderer.invoke('codexAccounts:listStalePanes', args), + listRecordedPaneLanes: (args: { ptyIds: string[] }): Promise> => + ipcRenderer.invoke('codexAccounts:listRecordedPaneLanes', args), + forgetStalePanes: (args: { ptyIds: string[] }): Promise => + ipcRenderer.invoke('codexAccounts:forgetStalePanes', args) +} satisfies PreloadApi['codexAccounts'] diff --git a/src/preload/api/codex-config-sync-bridge.ts b/src/preload/api/codex-config-sync-bridge.ts new file mode 100644 index 00000000000..82eedfaf0ec --- /dev/null +++ b/src/preload/api/codex-config-sync-bridge.ts @@ -0,0 +1,7 @@ +import { ipcRenderer } from 'electron' +import type { CodexConfigSyncStatus } from '../../shared/codex-config-sync-types' +import type { PreloadApi } from '../api-types' + +export const codexConfigSyncApi = { + status: (): Promise => ipcRenderer.invoke('codexConfigSync:status') +} satisfies PreloadApi['codexConfigSync'] diff --git a/src/preload/api/codex-usage-bridge.ts b/src/preload/api/codex-usage-bridge.ts new file mode 100644 index 00000000000..2575f2bb0e9 --- /dev/null +++ b/src/preload/api/codex-usage-bridge.ts @@ -0,0 +1,8 @@ +import { ipcRenderer } from 'electron' +import { createUsageProviderApi } from '../usage-provider-api' +import type { PreloadApi } from '../api-types' + +export const codexUsageApi = createUsageProviderApi( + ipcRenderer, + 'codexUsage' +) satisfies PreloadApi['codexUsage'] diff --git a/src/preload/api/computer-use-permissions-bridge.ts b/src/preload/api/computer-use-permissions-bridge.ts new file mode 100644 index 00000000000..bd36efbdcc3 --- /dev/null +++ b/src/preload/api/computer-use-permissions-bridge.ts @@ -0,0 +1,9 @@ +import { ipcRenderer } from 'electron' +import type { PreloadApi } from '../api-types' + +export const computerUsePermissionsApi = { + getStatus: () => ipcRenderer.invoke('computerUsePermissions:getStatus'), + openSetup: (args?: { id?: string }) => + ipcRenderer.invoke('computerUsePermissions:openSetup', args), + reset: () => ipcRenderer.invoke('computerUsePermissions:reset') +} satisfies PreloadApi['computerUsePermissions'] diff --git a/src/preload/api/crash-reports-bridge.ts b/src/preload/api/crash-reports-bridge.ts new file mode 100644 index 00000000000..a77ad412eb7 --- /dev/null +++ b/src/preload/api/crash-reports-bridge.ts @@ -0,0 +1,32 @@ +import { ipcRenderer } from 'electron' +import type { + CrashReportBreadcrumbData, + CrashReportCopyDiagnosticsArgs, + CrashReportSubmitArgs, + CrashReportSubmitResult, + ReactErrorBoundaryReportArgs, + ReactErrorBoundaryReportResult +} from '../../shared/crash-reporting' +import type { RendererHeapStatistics } from '../../shared/renderer-heap-statistics' +import type { RendererProcessMemory } from '../../shared/renderer-process-memory' +import { readRendererHeapStatistics } from '../renderer-heap-statistics-reader' +import { readRendererProcessMemory } from '../renderer-process-memory-reader' +import type { PreloadApi } from '../api-types' + +export const crashReportsApi = { + getLatestPending: () => ipcRenderer.invoke('crashReports:getLatestPending'), + getLatestReport: () => ipcRenderer.invoke('crashReports:getLatestReport'), + dismiss: (args: { reportId: string }) => ipcRenderer.invoke('crashReports:dismiss', args), + recordRendererError: ( + args: ReactErrorBoundaryReportArgs + ): Promise => + ipcRenderer.invoke('crashReports:recordRendererError', args), + recordBreadcrumb: (args: { name: string; data?: CrashReportBreadcrumbData }): void => + ipcRenderer.send('crashReports:recordBreadcrumb', args), + submit: (args: CrashReportSubmitArgs): Promise => + ipcRenderer.invoke('crashReports:submit', args), + copyLatestDiagnostics: (args?: CrashReportCopyDiagnosticsArgs) => + ipcRenderer.invoke('crashReports:copyLatestDiagnostics', args), + readHeapStatistics: (): RendererHeapStatistics | null => readRendererHeapStatistics(), + readProcessMemory: (): Promise => readRendererProcessMemory() +} satisfies PreloadApi['crashReports'] diff --git a/src/preload/api/dashboard-bridge.ts b/src/preload/api/dashboard-bridge.ts new file mode 100644 index 00000000000..e6862504da9 --- /dev/null +++ b/src/preload/api/dashboard-bridge.ts @@ -0,0 +1,75 @@ +import { ipcRenderer } from 'electron' +import type { + DashboardRevealAgentArgs, + DashboardSleepWorkspaceArgs, + DashboardSnapshot, + DashboardSpawnAgentArgs +} from '../../shared/dashboard-snapshot' +import type { PreloadApi } from '../api-types' + +export const dashboardApi = { + // Open the pop-out dashboard window, or focus it if already open. + openPopout: (view?: 'board' | 'map'): Promise => + ipcRenderer.invoke('dashboardPopout:open', view), + + // ── Producer side (main window) ────────────────────────────────────── + publishSnapshot: (snapshot: DashboardSnapshot): Promise => + ipcRenderer.invoke('dashboard:publishSnapshot', snapshot), + getPopoutOpen: (): Promise => ipcRenderer.invoke('dashboard:getPopoutOpen'), + onPopoutOpenChanged: (callback: (open: boolean) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, open: boolean): void => callback(open) + ipcRenderer.on('dashboard:popoutOpenChanged', listener) + return () => ipcRenderer.removeListener('dashboard:popoutOpenChanged', listener) + }, + onSnapshotRequested: (callback: () => void): (() => void) => { + const listener = (): void => callback() + ipcRenderer.on('dashboard:snapshotRequested', listener) + return () => ipcRenderer.removeListener('dashboard:snapshotRequested', listener) + }, + onRevealAgent: (callback: (args: DashboardRevealAgentArgs) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, args: DashboardRevealAgentArgs): void => + callback(args) + ipcRenderer.on('ui:revealDashboardAgent', listener) + return () => ipcRenderer.removeListener('ui:revealDashboardAgent', listener) + }, + onAckAgent: (callback: (paneKey: string) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, paneKey: string): void => callback(paneKey) + ipcRenderer.on('ui:ackDashboardAgent', listener) + return () => ipcRenderer.removeListener('ui:ackDashboardAgent', listener) + }, + onSpawnAgent: (callback: (args: DashboardSpawnAgentArgs) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, args: DashboardSpawnAgentArgs): void => + callback(args) + ipcRenderer.on('ui:spawnDashboardAgent', listener) + return () => ipcRenderer.removeListener('ui:spawnDashboardAgent', listener) + }, + onSleepWorkspace: (callback: (args: DashboardSleepWorkspaceArgs) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, args: DashboardSleepWorkspaceArgs): void => + callback(args) + ipcRenderer.on('ui:sleepDashboardWorkspace', listener) + return () => ipcRenderer.removeListener('ui:sleepDashboardWorkspace', listener) + }, + + // ── Consumer side (pop-out window) ─────────────────────────────────── + requestSnapshot: (): Promise => ipcRenderer.invoke('dashboard:requestSnapshot'), + onSnapshot: (callback: (snapshot: DashboardSnapshot) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, snapshot: DashboardSnapshot): void => + callback(snapshot) + ipcRenderer.on('dashboard:snapshot', listener) + return () => ipcRenderer.removeListener('dashboard:snapshot', listener) + }, + onViewRequested: (callback: (view: 'board' | 'map') => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, view: 'board' | 'map'): void => + callback(view) + ipcRenderer.on('dashboard:viewRequested', listener) + return () => ipcRenderer.removeListener('dashboard:viewRequested', listener) + }, + revealAgent: (args: DashboardRevealAgentArgs): Promise => + ipcRenderer.invoke('dashboardPopout:revealAgent', args), + ackAgent: (paneKey: string): Promise => + ipcRenderer.invoke('dashboardPopout:ackAgent', { paneKey }), + spawnAgent: (args: DashboardSpawnAgentArgs): Promise => + ipcRenderer.invoke('dashboardPopout:spawnAgent', args), + sleepWorkspace: (args: DashboardSleepWorkspaceArgs): Promise => + ipcRenderer.invoke('dashboardPopout:sleepWorkspace', args) +} satisfies PreloadApi['dashboard'] diff --git a/src/preload/api/developer-permissions-bridge.ts b/src/preload/api/developer-permissions-bridge.ts new file mode 100644 index 00000000000..94158cd7818 --- /dev/null +++ b/src/preload/api/developer-permissions-bridge.ts @@ -0,0 +1,11 @@ +import { ipcRenderer } from 'electron' +import type { PreloadApi } from '../api-types' + +export const developerPermissionsApi = { + getStatus: () => ipcRenderer.invoke('developerPermissions:getStatus'), + request: (args: { id: string }) => ipcRenderer.invoke('developerPermissions:request', args), + openSettings: (args: { id: string }): Promise => + ipcRenderer.invoke('developerPermissions:openSettings', args), + testLocalNetworkConnection: (args: { host: string; port: number }) => + ipcRenderer.invoke('developerPermissions:testLocalNetworkConnection', args) +} satisfies PreloadApi['developerPermissions'] diff --git a/src/preload/api/diagnostics-bridge.ts b/src/preload/api/diagnostics-bridge.ts new file mode 100644 index 00000000000..bff79fe5817 --- /dev/null +++ b/src/preload/api/diagnostics-bridge.ts @@ -0,0 +1,16 @@ +import { ipcRenderer } from 'electron' +import type { PreloadApi } from '../api-types' + +export const diagnosticsApi = { + getStatus: () => ipcRenderer.invoke('diagnostics:getStatus'), + collectBundle: (lookbackMinutes?: number) => + ipcRenderer.invoke('diagnostics:collectBundle', lookbackMinutes), + openBundlePreview: (bundleSubmissionId: string): Promise => + ipcRenderer.invoke('diagnostics:openBundlePreview', bundleSubmissionId), + discardBundlePreview: (bundleSubmissionId: string): Promise => + ipcRenderer.invoke('diagnostics:discardBundlePreview', bundleSubmissionId), + uploadBundle: (bundleSubmissionId: string) => + ipcRenderer.invoke('diagnostics:uploadBundle', bundleSubmissionId), + deleteBundle: (ticketId: string): Promise => + ipcRenderer.invoke('diagnostics:deleteBundle', ticketId) +} satisfies PreloadApi['diagnostics'] diff --git a/src/preload/api/doc-preview-bridge.ts b/src/preload/api/doc-preview-bridge.ts new file mode 100644 index 00000000000..97fbb8da975 --- /dev/null +++ b/src/preload/api/doc-preview-bridge.ts @@ -0,0 +1,32 @@ +import { ipcRenderer } from 'electron' +import { + DOC_PREVIEW_EXTERNAL_LINK_CHANNEL, + DOC_PREVIEW_LOAD_FAILURE_CHANNEL, + DOC_PREVIEW_AUTHORIZE_DIRECTORY_CHANNEL, + DOC_PREVIEW_MINT_GRANT_CHANNEL, + DOC_PREVIEW_REVOKE_GRANT_CHANNEL, + type DocPreviewFailure +} from '../../shared/doc-preview-scheme' +import type { DocPreviewGrantRequest } from '../api/doc-preview-api' +import type { PreloadApi } from '../api-types' + +export const docPreviewApi = { + mintGrant: (request: DocPreviewGrantRequest): Promise<{ grantId: string; url: string }> => + ipcRenderer.invoke(DOC_PREVIEW_MINT_GRANT_CHANNEL, request), + revokeGrant: (grantId: string): Promise => + ipcRenderer.invoke(DOC_PREVIEW_REVOKE_GRANT_CHANNEL, grantId), + authorizeDirectory: (grantId: string, relativePath: string): Promise => + ipcRenderer.invoke(DOC_PREVIEW_AUTHORIZE_DIRECTORY_CHANNEL, grantId, relativePath), + onExternalLink: (callback: (payload: { url: string }) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, payload: { url: string }): void => + callback(payload) + ipcRenderer.on(DOC_PREVIEW_EXTERNAL_LINK_CHANNEL, listener) + return () => ipcRenderer.removeListener(DOC_PREVIEW_EXTERNAL_LINK_CHANNEL, listener) + }, + onLoadFailure: (callback: (payload: DocPreviewFailure) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, payload: DocPreviewFailure): void => + callback(payload) + ipcRenderer.on(DOC_PREVIEW_LOAD_FAILURE_CHANNEL, listener) + return () => ipcRenderer.removeListener(DOC_PREVIEW_LOAD_FAILURE_CHANNEL, listener) + } +} satisfies PreloadApi['docPreview'] diff --git a/src/preload/api/e2e-bridge.ts b/src/preload/api/e2e-bridge.ts new file mode 100644 index 00000000000..900b17a9fcf --- /dev/null +++ b/src/preload/api/e2e-bridge.ts @@ -0,0 +1,6 @@ +import { preloadE2EConfig } from '../e2e-config' +import type { PreloadApi } from '../api-types' + +export const e2eApi = { + getConfig: () => preloadE2EConfig +} satisfies PreloadApi['e2e'] diff --git a/src/preload/api/emulator-bridge.ts b/src/preload/api/emulator-bridge.ts new file mode 100644 index 00000000000..8ab56471a42 --- /dev/null +++ b/src/preload/api/emulator-bridge.ts @@ -0,0 +1,99 @@ +import { ipcRenderer } from 'electron' +import type { PreloadApi } from '../api-types' + +export const emulatorApi = { + startFrameStream: (args: { + streamUrl: string + streamKey?: string + }): Promise<{ + streamId: string + }> => ipcRenderer.invoke('emulator:frameStreamStart', args), + stopFrameStream: (args: { streamId: string }): Promise => + ipcRenderer.invoke('emulator:frameStreamStop', args), + onFrameStreamFrame: ( + callback: (data: { streamId: string; bytes: ArrayBuffer }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { streamId: string; bytes: ArrayBuffer } + ) => callback(data) + ipcRenderer.on('emulator:frameStreamFrame', listener) + return () => ipcRenderer.removeListener('emulator:frameStreamFrame', listener) + }, + onFrameStreamError: ( + callback: (data: { streamId: string; message: string }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { streamId: string; message: string } + ) => callback(data) + ipcRenderer.on('emulator:frameStreamError', listener) + return () => ipcRenderer.removeListener('emulator:frameStreamError', listener) + }, + startVideoStream: (args: { deviceId: string; streamId: string }): Promise<{ streamId: string }> => + ipcRenderer.invoke('emulator:videoStreamStart', args), + stopVideoStream: (args: { streamId: string }): Promise => + ipcRenderer.invoke('emulator:videoStreamStop', args), + onVideoStreamMeta: ( + callback: (data: { + streamId: string + deviceId: string + meta: { codecId: string; width: number; height: number } + }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { + streamId: string + deviceId: string + meta: { codecId: string; width: number; height: number } + } + ) => callback(data) + ipcRenderer.on('emulator:videoStreamMeta', listener) + return () => ipcRenderer.removeListener('emulator:videoStreamMeta', listener) + }, + onVideoStreamFrame: ( + callback: (data: { + streamId: string + deviceId: string + config: boolean + keyFrame: boolean + bytes: ArrayBuffer + }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { + streamId: string + deviceId: string + config: boolean + keyFrame: boolean + bytes: ArrayBuffer + } + ) => callback(data) + ipcRenderer.on('emulator:videoStreamFrame', listener) + return () => ipcRenderer.removeListener('emulator:videoStreamFrame', listener) + }, + onPaneFocus: (callback: (data: { worktreeId: string }) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, data: { worktreeId: string }) => + callback(data) + ipcRenderer.on('emulator:pane-focus', listener) + return () => ipcRenderer.removeListener('emulator:pane-focus', listener) + }, + onAutoAttach: ( + callback: (data: { + worktreeId: string + info: { deviceUdid: string; streamUrl: string; wsUrl: string; axUrl?: string } + }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { + worktreeId: string + info: { deviceUdid: string; streamUrl: string; wsUrl: string; axUrl?: string } + } + ) => callback(data) + ipcRenderer.on('ui:emulatorAutoAttach', listener) + return () => ipcRenderer.removeListener('ui:emulatorAutoAttach', listener) + } +} satisfies PreloadApi['emulator'] diff --git a/src/preload/api/ephemeral-vm-bridge.ts b/src/preload/api/ephemeral-vm-bridge.ts new file mode 100644 index 00000000000..9f55530d6b8 --- /dev/null +++ b/src/preload/api/ephemeral-vm-bridge.ts @@ -0,0 +1,25 @@ +import { ipcRenderer } from 'electron' +import type { PreloadApi } from '../api-types' + +export const ephemeralVmApi = { + listRecipes: (args) => ipcRenderer.invoke('ephemeralVm:listRecipes', args), + listRecipeCatalog: () => ipcRenderer.invoke('ephemeralVm:listRecipeCatalog'), + doctor: (args) => ipcRenderer.invoke('ephemeralVm:doctor', args), + provision: (args) => ipcRenderer.invoke('ephemeralVm:provision', args), + cancelProvision: (args) => ipcRenderer.invoke('ephemeralVm:cancelProvision', args), + onProvisionEvent: (callback) => { + const listener = ( + _event: Electron.IpcRendererEvent, + event: { provisionId: string; stream: 'stdout' | 'stderr'; chunk: string } + ): void => callback(event) + ipcRenderer.on('ephemeralVm:provisionEvent', listener) + return () => ipcRenderer.removeListener('ephemeralVm:provisionEvent', listener) + }, + listRuntimes: () => ipcRenderer.invoke('ephemeralVm:listRuntimes'), + attachWorkspace: (args) => ipcRenderer.invoke('ephemeralVm:attachWorkspace', args), + suspendWorkspace: (args) => ipcRenderer.invoke('ephemeralVm:suspendWorkspace', args), + resumeWorkspace: (args) => ipcRenderer.invoke('ephemeralVm:resumeWorkspace', args), + cleanup: (args) => ipcRenderer.invoke('ephemeralVm:cleanup', args), + stopCleanup: (args) => ipcRenderer.invoke('ephemeralVm:stopCleanup', args), + getCleanupCommand: (args) => ipcRenderer.invoke('ephemeralVm:getCleanupCommand', args) +} satisfies PreloadApi['ephemeralVm'] diff --git a/src/preload/api/export-bridge.ts b/src/preload/api/export-bridge.ts new file mode 100644 index 00000000000..67ffbfae109 --- /dev/null +++ b/src/preload/api/export-bridge.ts @@ -0,0 +1,11 @@ +import { ipcRenderer } from 'electron' +import type { PreloadApi } from '../api-types' + +export const exportApi = { + htmlToPdf: (args: { + html: string + title: string + }): Promise< + { success: true; filePath: string } | { success: false; cancelled?: boolean; error?: string } + > => ipcRenderer.invoke('export:html-to-pdf', args) +} satisfies PreloadApi['export'] diff --git a/src/preload/api/feedback-bridge.ts b/src/preload/api/feedback-bridge.ts new file mode 100644 index 00000000000..4241c5cacf9 --- /dev/null +++ b/src/preload/api/feedback-bridge.ts @@ -0,0 +1,14 @@ +import { ipcRenderer } from 'electron' +import type { PreloadApi } from '../api-types' + +export const feedbackApi = { + submit: (args: { + feedback: string + submitAnonymously?: boolean + githubLogin: string | null + githubEmail: string | null + images?: { contentType: string; data: Uint8Array }[] + }): Promise< + { ok: true; imagesDelivered?: boolean } | { ok: false; status: number | null; error: string } + > => ipcRenderer.invoke('feedback:submit', args) +} satisfies PreloadApi['feedback'] diff --git a/src/preload/api/folder-workspaces-bridge.ts b/src/preload/api/folder-workspaces-bridge.ts new file mode 100644 index 00000000000..d085719d8ab --- /dev/null +++ b/src/preload/api/folder-workspaces-bridge.ts @@ -0,0 +1,10 @@ +import { ipcRenderer } from 'electron' +import type { PreloadApi } from '../api-types' + +export const folderWorkspacesApi = { + list: () => ipcRenderer.invoke('folderWorkspaces:list'), + getPathStatus: (args) => ipcRenderer.invoke('folderWorkspaces:getPathStatus', args), + create: (args) => ipcRenderer.invoke('folderWorkspaces:create', args), + update: (args) => ipcRenderer.invoke('folderWorkspaces:update', args), + delete: (args) => ipcRenderer.invoke('folderWorkspaces:delete', args) +} satisfies PreloadApi['folderWorkspaces'] diff --git a/src/preload/api/fs-bridge.ts b/src/preload/api/fs-bridge.ts new file mode 100644 index 00000000000..c67e9abd9e3 --- /dev/null +++ b/src/preload/api/fs-bridge.ts @@ -0,0 +1,220 @@ +import { ipcRenderer } from 'electron' +import type { SshMutationExpectation } from '../../shared/ssh-types' +import type { SearchResult } from '../../shared/code-search-types' +import type { FsChangedPayload } from '../../shared/filesystem-entry-types' +import type { + LocalLogTailChangedPayload, + LocalLogTailReadArgs, + LocalLogTailReadResult, + LocalLogTailWatchArgs +} from '../../shared/local-log-tail-types' +import type { PreloadApi } from '../api-types' + +export const fsApi = { + readDir: (args: { + dirPath: string + connectionId?: string + }): Promise<{ name: string; isDirectory: boolean; isSymlink: boolean }[]> => + ipcRenderer.invoke('fs:readDir', args), + readFile: (args: { + filePath: string + connectionId?: string + includeLocalLogMetadata?: boolean + }): Promise<{ + content: string + isBinary: boolean + isImage?: boolean + mimeType?: string + fileIdentity?: string + }> => ipcRenderer.invoke('fs:readFile', args), + readLocalLogTail: (args: LocalLogTailReadArgs): Promise => + ipcRenderer.invoke('fs:readLocalLogTail', args), + startLocalLogTail: (args: LocalLogTailWatchArgs): Promise => + ipcRenderer.invoke('fs:startLocalLogTail', args), + stopLocalLogTail: (args: { subscriptionId: string }): Promise => + ipcRenderer.invoke('fs:stopLocalLogTail', args), + onLocalLogTailChanged: ( + callback: (payload: LocalLogTailChangedPayload) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + payload: LocalLogTailChangedPayload + ): void => callback(payload) + ipcRenderer.on('fs:localLogTailChanged', listener) + return () => ipcRenderer.removeListener('fs:localLogTailChanged', listener) + }, + downloadFile: (args: { + filePath: string + connectionId: string + }): Promise<{ canceled: true } | { canceled: false; destinationPath: string }> => + ipcRenderer.invoke('fs:downloadFile', args), + downloadFolder: (args: { + dirPath: string + connectionId: string + }): Promise<{ canceled: true } | { canceled: false; destinationPath: string }> => + ipcRenderer.invoke('fs:downloadFolder', args), + saveDownloadedFile: (args: { + suggestedName: string + content: string + encoding: 'utf8' | 'base64' + }): Promise<{ canceled: true } | { canceled: false; destinationPath: string }> => + ipcRenderer.invoke('fs:saveDownloadedFile', args), + startDownloadedFile: (args: { + suggestedName: string + }): Promise< + { canceled: true } | { canceled: false; transferId: string; destinationPath: string } + > => ipcRenderer.invoke('fs:startDownloadedFile', args), + appendDownloadedFileChunk: (args: { + transferId: string + contentBase64: string + }): Promise<{ ok: true }> => ipcRenderer.invoke('fs:appendDownloadedFileChunk', args), + finishDownloadedFile: (args: { + transferId: string + }): Promise<{ canceled: false; destinationPath: string }> => + ipcRenderer.invoke('fs:finishDownloadedFile', args), + cancelDownloadedFile: (args: { transferId: string }): Promise<{ ok: true }> => + ipcRenderer.invoke('fs:cancelDownloadedFile', args), + listMarkdownDocuments: (args: { + rootPath: string + connectionId?: string + }): Promise<{ filePath: string; relativePath: string; basename: string; name: string }[]> => + ipcRenderer.invoke('fs:listMarkdownDocuments', args), + writeFile: ( + args: { + filePath: string + content: string + connectionId?: string + } & SshMutationExpectation + ): Promise => ipcRenderer.invoke('fs:writeFile', args), + createFile: ( + args: { filePath: string; connectionId?: string } & SshMutationExpectation + ): Promise => ipcRenderer.invoke('fs:createFile', args), + createDir: ( + args: { dirPath: string; connectionId?: string } & SshMutationExpectation + ): Promise => ipcRenderer.invoke('fs:createDir', args), + rename: ( + args: { oldPath: string; newPath: string; connectionId?: string } & SshMutationExpectation + ): Promise => ipcRenderer.invoke('fs:rename', args), + copy: ( + args: { + sourcePath: string + destinationPath: string + connectionId?: string + } & SshMutationExpectation + ): Promise => ipcRenderer.invoke('fs:copy', args), + deletePath: ( + args: { + targetPath: string + connectionId?: string + recursive?: boolean + } & SshMutationExpectation + ): Promise => ipcRenderer.invoke('fs:deletePath', args), + authorizeExternalPath: (args: { targetPath: string }): Promise => + ipcRenderer.invoke('fs:authorizeExternalPath', args), + stat: (args: { + filePath: string + connectionId?: string + }): Promise<{ size: number; isDirectory: boolean; mtime: number }> => + ipcRenderer.invoke('fs:stat', args), + pathExists: (args: { filePath: string; connectionId?: string }): Promise => + ipcRenderer.invoke('fs:pathExists', args), + listFiles: (args: { + rootPath: string + connectionId?: string + excludePaths?: string[] + requestToken?: string + maxResults?: number + searchQuery?: string + }): Promise => ipcRenderer.invoke('fs:listFiles', args), + cancelListFiles: (args: { requestToken: string }): Promise => + ipcRenderer.invoke('fs:cancelListFiles', args), + search: (args: { + query: string + rootPath: string + caseSensitive?: boolean + wholeWord?: boolean + useRegex?: boolean + includePattern?: string + excludePattern?: string + maxResults?: number + connectionId?: string + }): Promise => ipcRenderer.invoke('fs:search', args), + importExternalPaths: ( + args: { + sourcePaths: string[] + destDir: string + connectionId?: string + ensureDir?: boolean + } & SshMutationExpectation + ): Promise<{ + results: ( + | { + sourcePath: string + status: 'imported' + destPath: string + kind: 'file' | 'directory' + renamed: boolean + } + | { + sourcePath: string + status: 'skipped' + reason: 'missing' | 'symlink' | 'permission-denied' | 'unsupported' + } + | { + sourcePath: string + status: 'failed' + reason: string + } + )[] + }> => ipcRenderer.invoke('fs:importExternalPaths', args), + stageExternalPathsForRuntimeUpload: (args: { + sourcePaths: string[] + }): Promise<{ + sources: ( + | { + sourcePath: string + status: 'staged' + name: string + kind: 'file' | 'directory' + entries: ( + | { relativePath: string; kind: 'directory' } + | { relativePath: string; kind: 'file'; contentBase64: string } + )[] + } + | { + sourcePath: string + status: 'skipped' + reason: 'missing' | 'symlink' | 'permission-denied' | 'unsupported' + } + | { + sourcePath: string + status: 'failed' + reason: string + } + )[] + }> => ipcRenderer.invoke('fs:stageExternalPathsForRuntimeUpload', args), + resolveDroppedPathsForAgent: ( + args: { + paths: string[] + worktreePath: string + connectionId?: string + } & SshMutationExpectation + ): Promise<{ + resolvedPaths: string[] + skipped: { + sourcePath: string + reason: 'missing' | 'symlink' | 'permission-denied' | 'unsupported' + }[] + failed: { sourcePath: string; reason: string }[] + }> => ipcRenderer.invoke('fs:resolveDroppedPathsForAgent', args), + watchWorktree: (args: { worktreePath: string; connectionId?: string }): Promise => + ipcRenderer.invoke('fs:watchWorktree', args), + unwatchWorktree: (args: { worktreePath: string; connectionId?: string }): Promise => + ipcRenderer.invoke('fs:unwatchWorktree', args), + onFsChanged: (callback: (payload: FsChangedPayload) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, payload: FsChangedPayload) => + callback(payload) + ipcRenderer.on('fs:changed', listener) + return () => ipcRenderer.removeListener('fs:changed', listener) + } +} satisfies PreloadApi['fs'] diff --git a/src/preload/api/gh-bridge-mutations-and-projects.ts b/src/preload/api/gh-bridge-mutations-and-projects.ts new file mode 100644 index 00000000000..80b746bfb79 --- /dev/null +++ b/src/preload/api/gh-bridge-mutations-and-projects.ts @@ -0,0 +1,203 @@ +import { ipcRenderer } from 'electron' +import type { GitHubCommentResult } from '../../shared/github/comment-types' +import type { GitHubAssignableUser, GitHubOwnerRepo } from '../../shared/github/pull-request-types' +import type { GetRateLimitResult } from '../../shared/github/rate-limit-types' +import type { GhAuthDiagnostic } from '../../shared/github/auth-types' +import type { TaskSourceContext } from '../../shared/task-source-context' +import type { + GetProjectViewTableResult, + GitHubProjectCommentMutationResult, + GitHubProjectMutationResult, + ListAccessibleProjectsResult, + ListAssignableUsersBySlugResult, + ListIssueTypesBySlugResult, + ListLabelsBySlugResult, + ListProjectViewsResult, + ProjectWorkItemDetailsBySlugResult, + ResolveProjectRefResult +} from '../../shared/github/project-result-types' +import type { + AddIssueCommentBySlugArgs, + ClearProjectItemFieldArgs, + DeleteIssueCommentBySlugArgs, + GetProjectViewTableArgs, + ListAccessibleProjectsArgs, + ListAssignableUsersBySlugArgs, + ListIssueTypesBySlugArgs, + ListLabelsBySlugArgs, + ListProjectViewsArgs, + ProjectWorkItemDetailsBySlugArgs, + ResolveProjectRefArgs, + UpdateIssueBySlugArgs, + UpdateIssueCommentBySlugArgs, + UpdateIssueTypeBySlugArgs, + UpdatePullRequestBySlugArgs, + UpdateProjectItemFieldArgs +} from '../../shared/github/project-request-types' +import type { AppStarSource } from '../../shared/gh-star-source' +import type { PreloadApi } from '../api-types' + +export const ghMutationsAndProjectsApi = { + setPRAutoMerge: (args: { + repoPath: string + repoId?: string | null + sourceContext?: TaskSourceContext | null + prNumber: number + enabled: boolean + method?: 'merge' | 'squash' | 'rebase' + prRepo?: GitHubOwnerRepo | null + }): Promise<{ ok: true } | { ok: false; error: string }> => + ipcRenderer.invoke('gh:setPRAutoMerge', args), + updatePRState: (args: { + repoPath: string + repoId?: string | null + sourceContext?: TaskSourceContext | null + prNumber: number + updates: { state: 'open' | 'closed' } + prRepo?: GitHubOwnerRepo | null + }): Promise<{ ok: true } | { ok: false; error: string }> => + ipcRenderer.invoke('gh:updatePRState', args), + markPRReadyForReview: (args: { + repoPath: string + repoId?: string | null + sourceContext?: TaskSourceContext | null + prNumber: number + prRepo?: GitHubOwnerRepo | null + }): Promise<{ ok: true } | { ok: false; error: string }> => + ipcRenderer.invoke('gh:markPRReadyForReview', args), + requestPRReviewers: (args: { + repoPath: string + repoId?: string | null + sourceContext?: TaskSourceContext | null + prNumber: number + reviewers: string[] + prRepo?: GitHubOwnerRepo | null + }): Promise<{ ok: true } | { ok: false; error: string }> => + ipcRenderer.invoke('gh:requestPRReviewers', args), + removePRReviewers: (args: { + repoPath: string + repoId?: string | null + sourceContext?: TaskSourceContext | null + prNumber: number + reviewers: string[] + prRepo?: GitHubOwnerRepo | null + }): Promise<{ ok: true } | { ok: false; error: string }> => + ipcRenderer.invoke('gh:removePRReviewers', args), + updateIssue: (args: { + repoPath: string + repoId?: string | null + sourceContext?: TaskSourceContext | null + number: number + updates: unknown + }): Promise<{ ok: true } | { ok: false; error: string }> => + ipcRenderer.invoke('gh:updateIssue', args), + addIssueComment: (args: { + repoPath: string + repoId?: string | null + sourceContext?: TaskSourceContext | null + number: number + body: string + type?: 'issue' | 'pr' + prRepo?: GitHubOwnerRepo | null + }): Promise => ipcRenderer.invoke('gh:addIssueComment', args), + addPRReviewCommentReply: (args: { + repoPath: string + repoId?: string | null + sourceContext?: TaskSourceContext | null + prNumber: number + commentId: number + body: string + threadId?: string + path?: string + line?: number + prRepo?: GitHubOwnerRepo | null + }): Promise => ipcRenderer.invoke('gh:addPRReviewCommentReply', args), + addPRReviewComment: (args: { + repoPath: string + repoId?: string | null + sourceContext?: TaskSourceContext | null + prNumber: number + prRepo?: GitHubOwnerRepo | null + commitId: string + path: string + line: number + startLine?: number + body: string + }): Promise => ipcRenderer.invoke('gh:addPRReviewComment', args), + listLabels: (args: { + repoPath: string + repoId?: string | null + sourceContext?: TaskSourceContext | null + }): Promise => ipcRenderer.invoke('gh:listLabels', args), + listAssignableUsers: (args: { + repoPath: string + repoId?: string | null + sourceContext?: TaskSourceContext | null + }): Promise => ipcRenderer.invoke('gh:listAssignableUsers', args), + onWorkItemMutated: ( + callback: (payload: { + repoPath: string + repoId?: string + type: 'issue' | 'pr' + number: number + }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + payload: { repoPath: string; repoId?: string; type: 'issue' | 'pr'; number: number } + ): void => callback(payload) + ipcRenderer.on('gh:workItemMutated', listener) + return () => ipcRenderer.removeListener('gh:workItemMutated', listener) + }, + checkOrcaStarred: (): Promise => ipcRenderer.invoke('gh:checkOrcaStarred'), + starOrca: (source: AppStarSource): Promise => ipcRenderer.invoke('gh:starOrca', source), + rateLimit: (args?: { force?: boolean }): Promise => + ipcRenderer.invoke('gh:rateLimit', args), + diagnoseAuth: (args?: { host?: string }): Promise => + ipcRenderer.invoke('gh:diagnoseAuth', args), + listAccessibleProjects: ( + args?: ListAccessibleProjectsArgs + ): Promise => ipcRenderer.invoke('gh:listAccessibleProjects', args), + resolveProjectRef: (args: ResolveProjectRefArgs): Promise => + ipcRenderer.invoke('gh:resolveProjectRef', args), + listProjectViews: (args: ListProjectViewsArgs): Promise => + ipcRenderer.invoke('gh:listProjectViews', args), + getProjectViewTable: (args: GetProjectViewTableArgs): Promise => + ipcRenderer.invoke('gh:getProjectViewTable', args), + projectWorkItemDetailsBySlug: ( + args: ProjectWorkItemDetailsBySlugArgs + ): Promise => + ipcRenderer.invoke('gh:projectWorkItemDetailsBySlug', args), + updateProjectItemField: ( + args: UpdateProjectItemFieldArgs + ): Promise => ipcRenderer.invoke('gh:updateProjectItemField', args), + clearProjectItemField: (args: ClearProjectItemFieldArgs): Promise => + ipcRenderer.invoke('gh:clearProjectItemField', args), + updateIssueBySlug: (args: UpdateIssueBySlugArgs): Promise => + ipcRenderer.invoke('gh:updateIssueBySlug', args), + updatePullRequestBySlug: ( + args: UpdatePullRequestBySlugArgs + ): Promise => ipcRenderer.invoke('gh:updatePullRequestBySlug', args), + addIssueCommentBySlug: ( + args: AddIssueCommentBySlugArgs + ): Promise => + ipcRenderer.invoke('gh:addIssueCommentBySlug', args), + updateIssueCommentBySlug: ( + args: UpdateIssueCommentBySlugArgs + ): Promise => + ipcRenderer.invoke('gh:updateIssueCommentBySlug', args), + deleteIssueCommentBySlug: ( + args: DeleteIssueCommentBySlugArgs + ): Promise => + ipcRenderer.invoke('gh:deleteIssueCommentBySlug', args), + listLabelsBySlug: (args: ListLabelsBySlugArgs): Promise => + ipcRenderer.invoke('gh:listLabelsBySlug', args), + listAssignableUsersBySlug: ( + args: ListAssignableUsersBySlugArgs + ): Promise => + ipcRenderer.invoke('gh:listAssignableUsersBySlug', args), + listIssueTypesBySlug: (args: ListIssueTypesBySlugArgs): Promise => + ipcRenderer.invoke('gh:listIssueTypesBySlug', args), + updateIssueTypeBySlug: (args: UpdateIssueTypeBySlugArgs): Promise => + ipcRenderer.invoke('gh:updateIssueTypeBySlug', args) +} satisfies Partial diff --git a/src/preload/api/gh-bridge-pull-requests-and-work-items.ts b/src/preload/api/gh-bridge-pull-requests-and-work-items.ts new file mode 100644 index 00000000000..3e4a5f6ce2a --- /dev/null +++ b/src/preload/api/gh-bridge-pull-requests-and-work-items.ts @@ -0,0 +1,194 @@ +import { ipcRenderer } from 'electron' +import type { GitHubReactionContent } from '../../shared/github/comment-types' +import type { + GitHubPRRefreshCandidate, + GitHubPRRefreshEvent, + GitHubPRRefreshReason +} from '../../shared/github/pull-request-refresh-types' +import type { GitHubOwnerRepo } from '../../shared/github/pull-request-types' +import type { GitHubWorkItem, ListWorkItemsResult } from '../../shared/github/work-item-types' +import type { GitHubCreateIssueResult } from '../../shared/issue-mutation-types' +import type { TaskSourceContext } from '../../shared/task-source-context' +import type { PreloadApi } from '../api-types' + +export const ghPullRequestsAndWorkItemsApi = { + viewer: () => ipcRenderer.invoke('gh:viewer'), + repoSlug: (args: { repoPath: string; repoId?: string }) => + ipcRenderer.invoke('gh:repoSlug', args), + repoUpstream: (args: { repoPath: string; repoId?: string }) => + ipcRenderer.invoke('gh:repoUpstream', args), + prForBranch: (args: { + repoPath: string + repoId?: string | null + branch: string + linkedPRNumber?: number | null + fallbackPRNumber?: number | null + acceptMergedFallbackPR?: boolean + currentHeadOid?: string | null + }) => ipcRenderer.invoke('gh:prForBranch', args), + refreshPRNow: (args: { candidate: GitHubPRRefreshCandidate }) => + ipcRenderer.invoke('gh:refreshPRNow', args), + enqueuePRRefresh: (args: { + candidate: GitHubPRRefreshCandidate + reason: GitHubPRRefreshReason + priority?: number + }) => ipcRenderer.invoke('gh:enqueuePRRefresh', args), + reportVisiblePRRefreshCandidates: (args: { + candidates: GitHubPRRefreshCandidate[] + generation: number + }) => ipcRenderer.invoke('gh:reportVisiblePRRefreshCandidates', args), + onPRRefreshEvent: (callback: (event: GitHubPRRefreshEvent) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, event: GitHubPRRefreshEvent): void => + callback(event) + ipcRenderer.on('gh:prRefreshEvent', listener) + return () => ipcRenderer.removeListener('gh:prRefreshEvent', listener) + }, + issue: (args: { + repoPath: string + repoId?: string | null + sourceContext?: TaskSourceContext | null + number: number + }) => ipcRenderer.invoke('gh:issue', args), + workItem: (args: { + repoPath: string + repoId?: string | null + sourceContext?: TaskSourceContext | null + number: number + type?: 'issue' | 'pr' + }) => ipcRenderer.invoke('gh:workItem', args), + workItemByOwnerRepo: (args: { + repoPath: string + repoId?: string | null + owner: string + repo: string + host?: string + number: number + type: 'issue' | 'pr' + }) => ipcRenderer.invoke('gh:workItemByOwnerRepo', args), + workItemDetails: (args: { + repoPath: string + repoId?: string | null + sourceContext?: TaskSourceContext | null + number: number + type?: 'issue' | 'pr' + }) => ipcRenderer.invoke('gh:workItemDetails', args), + notifyWorkItemMutated: (args: { + repoPath: string + repoId?: string | null + type: 'issue' | 'pr' + number: number + }): Promise => ipcRenderer.invoke('gh:notifyWorkItemMutated', args), + prFileContents: (args: { + repoPath: string + repoId?: string | null + sourceContext?: TaskSourceContext | null + prNumber: number + prRepo?: GitHubOwnerRepo | null + path: string + oldPath?: string + status: string + headSha: string + baseSha: string + }) => ipcRenderer.invoke('gh:prFileContents', args), + listIssues: (args: { repoPath: string; repoId?: string; limit?: number }) => + ipcRenderer.invoke('gh:listIssues', args), + createIssue: (args: { + repoPath: string + repoId?: string | null + sourceContext?: TaskSourceContext | null + title: string + body: string + labels?: string[] + assignees?: string[] + }): Promise => ipcRenderer.invoke('gh:createIssue', args), + countWorkItems: (args: { repoPath: string; repoId?: string; query?: string }): Promise => + ipcRenderer.invoke('gh:countWorkItems', args), + listWorkItems: (args: { + repoPath: string + repoId?: string | null + limit?: number + query?: string + page?: number + noCache?: boolean + }): Promise>> => + ipcRenderer.invoke('gh:listWorkItems', args), + prChecks: (args: { + repoPath: string + repoId?: string | null + sourceContext?: TaskSourceContext | null + prNumber: number + headSha?: string + prRepo?: GitHubOwnerRepo | null + noCache?: boolean + }) => ipcRenderer.invoke('gh:prChecks', args), + prCheckDetails: (args: { + repoPath: string + repoId?: string | null + sourceContext?: TaskSourceContext | null + checkRunId?: number + workflowRunId?: number + checkName?: string + url?: string | null + prRepo?: GitHubOwnerRepo | null + }) => ipcRenderer.invoke('gh:prCheckDetails', args), + rerunPRChecks: (args: { + repoPath: string + repoId?: string | null + sourceContext?: TaskSourceContext | null + prNumber: number + headSha?: string + failedOnly?: boolean + prRepo?: GitHubOwnerRepo | null + }): Promise<{ ok: true; count: number } | { ok: false; error: string }> => + ipcRenderer.invoke('gh:rerunPRChecks', args), + prComments: (args: { + repoPath: string + repoId?: string | null + sourceContext?: TaskSourceContext | null + prNumber: number + prRepo?: GitHubOwnerRepo | null + noCache?: boolean + }) => ipcRenderer.invoke('gh:prComments', args), + setPRCommentReaction: (args: { + repoPath: string + repoId?: string | null + sourceContext?: TaskSourceContext | null + reactionSubjectId: string + content: GitHubReactionContent + reacted: boolean + prRepo?: GitHubOwnerRepo | null + }): Promise => ipcRenderer.invoke('gh:setPRCommentReaction', args), + resolveReviewThread: (args: { + repoPath: string + repoId?: string | null + sourceContext?: TaskSourceContext | null + threadId: string + resolve: boolean + prRepo?: GitHubOwnerRepo | null + }): Promise => ipcRenderer.invoke('gh:resolveReviewThread', args), + setPRFileViewed: (args: { + repoPath: string + repoId?: string | null + sourceContext?: TaskSourceContext | null + prNumber: number + prRepo?: GitHubOwnerRepo | null + pullRequestId: string + path: string + viewed: boolean + }): Promise => ipcRenderer.invoke('gh:setPRFileViewed', args), + updatePRTitle: (args: { + repoPath: string + repoId?: string | null + prNumber: number + title: string + prRepo?: GitHubOwnerRepo | null + }): Promise => ipcRenderer.invoke('gh:updatePRTitle', args), + mergePR: (args: { + repoPath: string + repoId?: string | null + sourceContext?: TaskSourceContext | null + prNumber: number + method?: 'merge' | 'squash' | 'rebase' + prRepo?: GitHubOwnerRepo | null + }): Promise<{ ok: true } | { ok: false; error: string }> => ipcRenderer.invoke('gh:mergePR', args) +} satisfies Partial diff --git a/src/preload/api/gh-bridge.ts b/src/preload/api/gh-bridge.ts new file mode 100644 index 00000000000..c7da93698e6 --- /dev/null +++ b/src/preload/api/gh-bridge.ts @@ -0,0 +1,8 @@ +import type { PreloadApi } from '../api-types' +import { ghPullRequestsAndWorkItemsApi } from './gh-bridge-pull-requests-and-work-items' +import { ghMutationsAndProjectsApi } from './gh-bridge-mutations-and-projects' + +export const ghApi = { + ...ghPullRequestsAndWorkItemsApi, + ...ghMutationsAndProjectsApi +} satisfies PreloadApi['gh'] diff --git a/src/preload/api/git-bash-bridge.ts b/src/preload/api/git-bash-bridge.ts new file mode 100644 index 00000000000..c2186d12aa3 --- /dev/null +++ b/src/preload/api/git-bash-bridge.ts @@ -0,0 +1,6 @@ +import { ipcRenderer } from 'electron' +import type { PreloadApi } from '../api-types' + +export const gitBashApi = { + isAvailable: (): Promise => ipcRenderer.invoke('gitBash:isAvailable') +} satisfies PreloadApi['gitBash'] diff --git a/src/preload/api/git-bridge.ts b/src/preload/api/git-bridge.ts new file mode 100644 index 00000000000..987abab14fc --- /dev/null +++ b/src/preload/api/git-bridge.ts @@ -0,0 +1,195 @@ +import { ipcRenderer } from 'electron' +import type { GitForkSyncExpectedUpstream, GitForkSyncResult } from '../../shared/git-fork-sync' +import type { GitStagingArea, GitUpstreamStatus } from '../../shared/git-status-types' +import type { GitPushTarget } from '../../shared/worktree/types' +import type { GitHistoryOptions, GitHistoryResult } from '../../shared/git-history' +import type { PreloadApi } from '../api-types' + +export const gitApi = { + status: (args: { + worktreePath: string + connectionId?: string + includeIgnored?: boolean + bypassEffectiveUpstreamNegativeCache?: boolean + reuseLineStats?: boolean + branchLineTotalMergeBase?: string + requestToken?: string + }) => ipcRenderer.invoke('git:status', args), + cancelStatus: (args: { requestToken: string }): Promise => + ipcRenderer.invoke('git:cancelStatus', args), + setStatusUpstreamRefWatch: (args: { + worktreeId: string + worktreePath: string + executionHostId: string + connectionId?: string + branch?: string + upstreamName?: string + }): Promise => ipcRenderer.invoke('git:setStatusUpstreamRefWatch', args), + submoduleStatus: (args: { + worktreePath: string + submodulePath: string + connectionId?: string + area?: GitStagingArea + }) => ipcRenderer.invoke('git:submoduleStatus', args), + checkIgnored: (args: { + worktreePath: string + paths: string[] + connectionId?: string + }): Promise => ipcRenderer.invoke('git:checkIgnored', args), + findHugeFoldersToIgnore: (args: { worktreePath: string }): Promise => + ipcRenderer.invoke('git:findHugeFoldersToIgnore', args), + appendGitignore: (args: { worktreePath: string; folderName: string }): Promise => + ipcRenderer.invoke('git:appendGitignore', args), + history: ( + args: { worktreePath: string; connectionId?: string } & GitHistoryOptions + ): Promise => ipcRenderer.invoke('git:history', args), + conflictOperation: (args: { worktreePath: string; connectionId?: string }) => + ipcRenderer.invoke('git:conflictOperation', args), + abortMerge: (args: { worktreePath: string; connectionId?: string }): Promise => + ipcRenderer.invoke('git:abortMerge', args), + abortRebase: (args: { worktreePath: string; connectionId?: string }): Promise => + ipcRenderer.invoke('git:abortRebase', args), + diff: (args: { + worktreePath: string + filePath: string + staged: boolean + compareAgainstHead?: boolean + connectionId?: string + }) => ipcRenderer.invoke('git:diff', args), + branchCompare: (args: { worktreePath: string; baseRef: string; connectionId?: string }) => + ipcRenderer.invoke('git:branchCompare', args), + commitCompare: (args: { worktreePath: string; commitId: string; connectionId?: string }) => + ipcRenderer.invoke('git:commitCompare', args), + upstreamStatus: (args: { + worktreePath: string + connectionId?: string + pushTarget?: GitPushTarget + }): Promise => ipcRenderer.invoke('git:upstreamStatus', args), + fetch: (args: { + worktreePath: string + connectionId?: string + pushTarget?: GitPushTarget + }): Promise => ipcRenderer.invoke('git:fetch', args), + syncFork: (args: { + worktreePath: string + connectionId?: string + expectedUpstream: GitForkSyncExpectedUpstream + }): Promise => ipcRenderer.invoke('git:syncFork', args), + push: (args: { + worktreePath: string + publish?: boolean + forceWithLease?: boolean + connectionId?: string + pushTarget?: unknown + }): Promise => ipcRenderer.invoke('git:push', args), + pull: (args: { + worktreePath: string + connectionId?: string + pushTarget?: GitPushTarget + }): Promise => ipcRenderer.invoke('git:pull', args), + fastForward: (args: { + worktreePath: string + connectionId?: string + pushTarget?: GitPushTarget + }): Promise => ipcRenderer.invoke('git:fastForward', args), + rebaseFromBase: (args: { + worktreePath: string + baseRef: string + connectionId?: string + }): Promise => ipcRenderer.invoke('git:rebaseFromBase', args), + branchDiff: (args: { + worktreePath: string + compare: { baseRef: string; baseOid: string; headOid: string; mergeBase: string } + filePath: string + oldPath?: string + connectionId?: string + }) => ipcRenderer.invoke('git:branchDiff', args), + commitDiff: (args: { + worktreePath: string + commitOid: string + parentOid?: string | null + filePath: string + oldPath?: string + connectionId?: string + }) => ipcRenderer.invoke('git:commitDiff', args), + commit: (args: { + worktreePath: string + message: string + connectionId?: string + }): Promise<{ success: boolean; error?: string }> => ipcRenderer.invoke('git:commit', args), + generateCommitMessage: (args: { + worktreePath: string + worktreeId?: string + repoId?: string + connectionId?: string + sourceControlAiResolvedParams?: unknown + sourceControlAi?: unknown + agentCmdOverrides?: Record + }) => ipcRenderer.invoke('git:generateCommitMessage', args), + discoverCommitMessageModels: (args: { + agentId: string + worktreePath?: string + connectionId?: string + }) => ipcRenderer.invoke('git:discoverCommitMessageModels', args), + cancelGenerateCommitMessage: (args: { + worktreePath: string + connectionId?: string + }): Promise => ipcRenderer.invoke('git:cancelGenerateCommitMessage', args), + generatePullRequestFields: (args: { + worktreePath: string + worktreeId?: string + repoId?: string + base: string + title: string + body: string + draft: boolean + provider?: unknown + useTemplate?: boolean + connectionId?: string + sourceControlAiResolvedParams?: unknown + sourceControlAi?: unknown + agentCmdOverrides?: Record + }) => ipcRenderer.invoke('git:generatePullRequestFields', args), + cancelGeneratePullRequestFields: (args: { + worktreePath: string + connectionId?: string + }): Promise => ipcRenderer.invoke('git:cancelGeneratePullRequestFields', args), + stage: (args: { worktreePath: string; filePath: string; connectionId?: string }): Promise => + ipcRenderer.invoke('git:stage', args), + bulkStage: (args: { + worktreePath: string + filePaths: string[] + connectionId?: string + }): Promise => ipcRenderer.invoke('git:bulkStage', args), + unstage: (args: { + worktreePath: string + filePath: string + connectionId?: string + }): Promise => ipcRenderer.invoke('git:unstage', args), + bulkUnstage: (args: { + worktreePath: string + filePaths: string[] + connectionId?: string + }): Promise => ipcRenderer.invoke('git:bulkUnstage', args), + discard: (args: { + worktreePath: string + filePath: string + connectionId?: string + }): Promise => ipcRenderer.invoke('git:discard', args), + bulkDiscard: (args: { + worktreePath: string + filePaths: string[] + connectionId?: string + }): Promise => ipcRenderer.invoke('git:bulkDiscard', args), + remoteFileUrl: (args: { + worktreePath: string + relativePath: string + line: number + connectionId?: string + }): Promise => ipcRenderer.invoke('git:remoteFileUrl', args), + remoteCommitUrl: (args: { + worktreePath: string + sha: string + connectionId?: string + }): Promise => ipcRenderer.invoke('git:remoteCommitUrl', args) +} satisfies PreloadApi['git'] diff --git a/src/preload/api/gl-bridge.ts b/src/preload/api/gl-bridge.ts new file mode 100644 index 00000000000..3977536a838 --- /dev/null +++ b/src/preload/api/gl-bridge.ts @@ -0,0 +1,4 @@ +import { glApi } from '../gitlab' +import type { PreloadApi } from '../api-types' + +export const glApiBridge = glApi satisfies PreloadApi['gl'] diff --git a/src/preload/api/grok-accounts-bridge.ts b/src/preload/api/grok-accounts-bridge.ts new file mode 100644 index 00000000000..246fc97bc56 --- /dev/null +++ b/src/preload/api/grok-accounts-bridge.ts @@ -0,0 +1,7 @@ +import { ipcRenderer } from 'electron' +import type { GrokAccountStatus } from '../../shared/rate-limit-types' +import type { PreloadApi } from '../api-types' + +export const grokAccountsApi = { + getStatus: (): Promise => ipcRenderer.invoke('grokAccounts:getStatus') +} satisfies PreloadApi['grokAccounts'] diff --git a/src/preload/api/hooks-bridge.ts b/src/preload/api/hooks-bridge.ts new file mode 100644 index 00000000000..75c48281b16 --- /dev/null +++ b/src/preload/api/hooks-bridge.ts @@ -0,0 +1,36 @@ +import { ipcRenderer } from 'electron' +import type { WorktreeSetupLaunch } from '../../shared/worktree/launch-types' +import type { ExecutionHostId } from '../../shared/execution-host' +import type { PreloadApi } from '../api-types' + +export const hooksApi = { + check: (args: { repoId: string; hostId?: ExecutionHostId }) => + ipcRenderer.invoke('hooks:check', args), + + inspectSetupScriptImports: (args: { repoId: string; hostId?: ExecutionHostId }) => + ipcRenderer.invoke('hooks:inspectSetupScriptImports', args), + + createIssueCommandRunner: (args: { + repoId: string + worktreePath: string + command: string + }): Promise => ipcRenderer.invoke('hooks:createIssueCommandRunner', args), + + readIssueCommand: (args: { + repoId: string + hostId?: ExecutionHostId + }): Promise<{ + status?: 'ok' | 'error' + localContent: string | null + sharedContent: string | null + effectiveContent: string | null + localFilePath: string + source: 'local' | 'shared' | 'none' + }> => ipcRenderer.invoke('hooks:readIssueCommand', args), + + writeIssueCommand: (args: { + repoId: string + content: string + hostId?: ExecutionHostId + }): Promise => ipcRenderer.invoke('hooks:writeIssueCommand', args) +} satisfies PreloadApi['hooks'] diff --git a/src/preload/api/hosted-review-bridge.ts b/src/preload/api/hosted-review-bridge.ts new file mode 100644 index 00000000000..b7e0d12af5c --- /dev/null +++ b/src/preload/api/hosted-review-bridge.ts @@ -0,0 +1,12 @@ +import { ipcRenderer } from 'electron' +import type { HostedReviewForBranchArgs } from '../../shared/hosted-review' +import type { PreloadApi } from '../api-types' + +export const hostedReviewApi = { + forBranch: (args: HostedReviewForBranchArgs) => + ipcRenderer.invoke('hostedReview:forBranch', args), + getCreationEligibility: (args: unknown) => + ipcRenderer.invoke('hostedReview:getCreationEligibility', args), + create: (args: unknown) => ipcRenderer.invoke('hostedReview:create', args), + createStacked: (args: unknown) => ipcRenderer.invoke('hostedReview:createStacked', args) +} satisfies PreloadApi['hostedReview'] diff --git a/src/preload/api/jira-bridge.ts b/src/preload/api/jira-bridge.ts new file mode 100644 index 00000000000..4b6b991095d --- /dev/null +++ b/src/preload/api/jira-bridge.ts @@ -0,0 +1,90 @@ +import { ipcRenderer } from 'electron' +import type { JiraProjectStatusOrder } from '../../shared/jira-types' +import type { PreloadApi } from '../api-types' + +export const jiraApi = { + connect: (args: { + siteUrl: string + email: string + apiToken: string + authType?: 'cloud' | 'server' + }) => ipcRenderer.invoke('jira:connect', args), + + disconnect: (args?: { siteId?: string }): Promise => + ipcRenderer.invoke('jira:disconnect', args), + + selectSite: (args: { siteId: string }) => ipcRenderer.invoke('jira:selectSite', args), + + status: () => ipcRenderer.invoke('jira:status'), + + readStatus: () => ipcRenderer.invoke('jira:readStatus'), + + testConnection: (args?: { siteId?: string }) => ipcRenderer.invoke('jira:testConnection', args), + + searchIssues: (args: { jql: string; limit?: number; siteId?: string; requestId?: string }) => + ipcRenderer.invoke('jira:searchIssues', args), + cancelSearchIssues: (args: { requestId: string }): Promise => + ipcRenderer.invoke('jira:cancelSearchIssues', args), + + listIssues: (args?: { + filter?: 'assigned' | 'reported' | 'all' | 'done' + limit?: number + siteId?: string + }) => ipcRenderer.invoke('jira:listIssues', args), + + getIssue: (args: { key: string; siteId?: string }) => ipcRenderer.invoke('jira:getIssue', args), + + lookupIssueSummary: (args: { key: string; siteId: string; requestId?: string }) => + ipcRenderer.invoke('jira:lookupIssueSummary', args), + cancelIssueSummary: (args: { requestId: string }): Promise => + ipcRenderer.invoke('jira:cancelIssueSummary', args), + + createIssue: (args: { + siteId?: string + projectId: string + issueTypeId: string + title: string + description?: string + customFields?: Record + }): Promise<{ ok: true; id: string; key: string; url: string } | { ok: false; error: string }> => + ipcRenderer.invoke('jira:createIssue', args), + + updateIssue: (args: { + key: string + updates: unknown + siteId?: string + }): Promise<{ ok: true } | { ok: false; error: string }> => + ipcRenderer.invoke('jira:updateIssue', args), + + addIssueComment: (args: { + key: string + body: string + siteId?: string + }): Promise<{ ok: true; id: string } | { ok: false; error: string }> => + ipcRenderer.invoke('jira:addIssueComment', args), + + issueComments: (args: { key: string; siteId?: string }) => + ipcRenderer.invoke('jira:issueComments', args), + + listProjects: (args?: { siteId?: string }) => ipcRenderer.invoke('jira:listProjects', args), + + listIssueTypes: (args: { projectIdOrKey: string; siteId?: string }) => + ipcRenderer.invoke('jira:listIssueTypes', args), + + listCreateFields: (args: { projectIdOrKey: string; issueTypeId: string; siteId?: string }) => + ipcRenderer.invoke('jira:listCreateFields', args), + + listPriorities: (args?: { siteId?: string }) => ipcRenderer.invoke('jira:listPriorities', args), + + listAssignableUsers: (args: { key: string; query?: string; siteId?: string }) => + ipcRenderer.invoke('jira:listAssignableUsers', args), + searchUsers: (args?: { query?: string; siteId?: string }) => + ipcRenderer.invoke('jira:searchUsers', args), + + listTransitions: (args: { key: string; siteId?: string }) => + ipcRenderer.invoke('jira:listTransitions', args), + getProjectStatusOrder: (args: { + projectKey: string + siteId?: string + }): Promise => ipcRenderer.invoke('jira:getProjectStatusOrder', args) +} satisfies PreloadApi['jira'] diff --git a/src/preload/api/keybindings-bridge.ts b/src/preload/api/keybindings-bridge.ts new file mode 100644 index 00000000000..e91e587313d --- /dev/null +++ b/src/preload/api/keybindings-bridge.ts @@ -0,0 +1,21 @@ +import { ipcRenderer } from 'electron' +import type { KeybindingActionId, KeybindingFileSnapshot } from '../../shared/keybindings' +import type { PreloadApi } from '../api-types' + +export const keybindingsApi = { + get: (): Promise => ipcRenderer.invoke('keybindings:get'), + ensureFile: (): Promise => ipcRenderer.invoke('keybindings:ensureFile'), + setAction: (args: { + actionId: KeybindingActionId + bindings: string[] | null + }): Promise => ipcRenderer.invoke('keybindings:setAction', args), + reload: (): Promise => ipcRenderer.invoke('keybindings:reload'), + openFile: (): Promise => ipcRenderer.invoke('keybindings:openFile'), + revealFile: (): Promise => ipcRenderer.invoke('keybindings:revealFile'), + onChanged: (callback: (snapshot: KeybindingFileSnapshot) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, snapshot: KeybindingFileSnapshot): void => + callback(snapshot) + ipcRenderer.on('keybindings:changed', listener) + return () => ipcRenderer.removeListener('keybindings:changed', listener) + } +} satisfies PreloadApi['keybindings'] diff --git a/src/preload/api/linear-bridge.ts b/src/preload/api/linear-bridge.ts new file mode 100644 index 00000000000..8092a8e70e7 --- /dev/null +++ b/src/preload/api/linear-bridge.ts @@ -0,0 +1,131 @@ +import { ipcRenderer } from 'electron' +import type { LinearProjectDetail } from '../../shared/linear/project-types' +import type { PreloadApi } from '../api-types' + +export const linearApi = { + connect: (args: { apiKey: string }) => ipcRenderer.invoke('linear:connect', args), + + disconnect: (args?: { workspaceId?: string }): Promise => + ipcRenderer.invoke('linear:disconnect', args), + + selectWorkspace: (args: { workspaceId: string }) => + ipcRenderer.invoke('linear:selectWorkspace', args), + + status: () => ipcRenderer.invoke('linear:status'), + + testConnection: (args?: { workspaceId?: string }) => + ipcRenderer.invoke('linear:testConnection', args), + + searchIssues: (args: { query: string; limit?: number; workspaceId?: string }) => + ipcRenderer.invoke('linear:searchIssues', args), + + listIssues: (args?: { + filter?: 'assigned' | 'created' | 'all' | 'completed' + limit?: number + workspaceId?: string + attributeFilter?: unknown + }) => ipcRenderer.invoke('linear:listIssues', args), + + createIssue: (args: { + teamId: string + title: string + description?: string + workspaceId?: string + parentIssueId?: string + projectId?: string | null + stateId?: string + priority?: number + assigneeId?: string | null + labelIds?: string[] + }): Promise< + | { ok: true; id: string; identifier: string; title: string; url: string } + | { ok: false; error: string } + > => ipcRenderer.invoke('linear:createIssue', args), + + getIssue: (args: { id: string; workspaceId?: string }) => + ipcRenderer.invoke('linear:getIssue', args), + + updateIssue: (args: { + id: string + updates: unknown + workspaceId?: string + }): Promise<{ ok: true } | { ok: false; error: string }> => + ipcRenderer.invoke('linear:updateIssue', args), + + addIssueComment: (args: { + issueId: string + body: string + workspaceId?: string + }): Promise<{ ok: true; id: string } | { ok: false; error: string }> => + ipcRenderer.invoke('linear:addIssueComment', args), + + issueComments: (args: { issueId: string; workspaceId?: string }) => + ipcRenderer.invoke('linear:issueComments', args), + + listTeams: (args?: { workspaceId?: string }) => ipcRenderer.invoke('linear:listTeams', args), + + listProjects: (args?: { + query?: string + limit?: number + workspaceId?: string + force?: boolean + }) => ipcRenderer.invoke('linear:listProjects', args), + + createProject: (args: { + name: string + description?: string + content?: string + teamIds: string[] + workspaceId?: string + leadId?: string | null + memberIds?: string[] + labelIds?: string[] + priority?: number + startDate?: string + targetDate?: string + }): Promise<{ ok: true; project: LinearProjectDetail } | { ok: false; error: string }> => + ipcRenderer.invoke('linear:createProject', args), + + getProject: (args: { id: string; workspaceId: string; force?: boolean }) => + ipcRenderer.invoke('linear:getProject', args), + + listProjectIssues: (args: { + projectId: string + limit?: number + workspaceId: string + force?: boolean + }) => ipcRenderer.invoke('linear:listProjectIssues', args), + + listCustomViews: (args: { + model: string + limit?: number + workspaceId?: string + force?: boolean + }) => ipcRenderer.invoke('linear:listCustomViews', args), + + getCustomView: (args: { viewId: string; model: string; workspaceId: string; force?: boolean }) => + ipcRenderer.invoke('linear:getCustomView', args), + + listCustomViewIssues: (args: { + viewId: string + limit?: number + workspaceId: string + force?: boolean + }) => ipcRenderer.invoke('linear:listCustomViewIssues', args), + + listCustomViewProjects: (args: { + viewId: string + limit?: number + workspaceId: string + force?: boolean + }) => ipcRenderer.invoke('linear:listCustomViewProjects', args), + + teamStates: (args: { teamId: string; workspaceId?: string }) => + ipcRenderer.invoke('linear:teamStates', args), + + teamLabels: (args: { teamId: string; workspaceId?: string }) => + ipcRenderer.invoke('linear:teamLabels', args), + + teamMembers: (args: { teamId: string; workspaceId?: string }) => + ipcRenderer.invoke('linear:teamMembers', args) +} satisfies PreloadApi['linear'] diff --git a/src/preload/api/localhost-worktree-labels-bridge.ts b/src/preload/api/localhost-worktree-labels-bridge.ts new file mode 100644 index 00000000000..a44dcffff69 --- /dev/null +++ b/src/preload/api/localhost-worktree-labels-bridge.ts @@ -0,0 +1,11 @@ +import { ipcRenderer } from 'electron' +import type { + LocalhostWorktreeLabelResult, + LocalhostWorktreeLabelRoute +} from '../../shared/localhost-worktree-labels' +import type { PreloadApi } from '../api-types' + +export const localhostWorktreeLabelsApi = { + register: (args: LocalhostWorktreeLabelRoute): Promise => + ipcRenderer.invoke('localhostWorktreeLabels:register', args) +} satisfies PreloadApi['localhostWorktreeLabels'] diff --git a/src/preload/api/macos-tcc-prompts-bridge.ts b/src/preload/api/macos-tcc-prompts-bridge.ts new file mode 100644 index 00000000000..ef24b9e203e --- /dev/null +++ b/src/preload/api/macos-tcc-prompts-bridge.ts @@ -0,0 +1,20 @@ +import { ipcRenderer } from 'electron' +import type { PreloadApi } from '../api-types' + +export const macosTccPromptsApi = { + onThreshold: (callback: (payload: { promptCount: number }) => void) => { + const listener = (_event: Electron.IpcRendererEvent, payload: { promptCount: number }): void => + callback(payload) + ipcRenderer.on('macosTccPrompts:threshold', listener) + return (): void => { + ipcRenderer.removeListener('macosTccPrompts:threshold', listener) + } + }, + consumePending: (): Promise<{ claimId: number; promptCount: number } | null> => + ipcRenderer.invoke('macosTccPrompts:consumePending'), + acknowledgePending: (claimId: number): Promise => + ipcRenderer.invoke('macosTccPrompts:acknowledgePending', claimId), + releasePending: (claimId: number): Promise => + ipcRenderer.invoke('macosTccPrompts:releasePending', claimId), + dismiss: (): Promise => ipcRenderer.invoke('macosTccPrompts:dismiss') +} satisfies PreloadApi['macosTccPrompts'] diff --git a/src/preload/api/memory-bridge.ts b/src/preload/api/memory-bridge.ts new file mode 100644 index 00000000000..c1735f86e31 --- /dev/null +++ b/src/preload/api/memory-bridge.ts @@ -0,0 +1,7 @@ +import { ipcRenderer } from 'electron' +import type { MemorySnapshot } from '../../shared/process-stats-types' +import type { PreloadApi } from '../api-types' + +export const memoryApi = { + getSnapshot: (): Promise => ipcRenderer.invoke('memory:getSnapshot') +} satisfies PreloadApi['memory'] diff --git a/src/preload/api/minimax-credentials-bridge.ts b/src/preload/api/minimax-credentials-bridge.ts new file mode 100644 index 00000000000..e99bd843909 --- /dev/null +++ b/src/preload/api/minimax-credentials-bridge.ts @@ -0,0 +1,11 @@ +import { ipcRenderer } from 'electron' +import type { PreloadApi } from '../api-types' + +export const minimaxCredentialsApi = { + getStatus: (): Promise<{ configured: boolean }> => + ipcRenderer.invoke('minimaxCredentials:getStatus'), + saveCookie: (cookie: string): Promise<{ configured: boolean }> => + ipcRenderer.invoke('minimaxCredentials:saveCookie', cookie), + clearCookie: (): Promise<{ configured: boolean }> => + ipcRenderer.invoke('minimaxCredentials:clearCookie') +} satisfies PreloadApi['minimaxCredentials'] diff --git a/src/preload/api/mobile-bridge.ts b/src/preload/api/mobile-bridge.ts new file mode 100644 index 00000000000..a1ad9a4c716 --- /dev/null +++ b/src/preload/api/mobile-bridge.ts @@ -0,0 +1,96 @@ +import { ipcRenderer } from 'electron' +import type { MobileRelayStatus } from '../../shared/mobile-relay-status' +import type { MobilePairingConnectionMode } from '../../shared/mobile-pairing-connection-mode' +import type { RuntimePairingReach } from '../../shared/runtime-pairing-reach' +import type { MobileRelayMintFailure } from '../../shared/mobile-relay-mint-failure' +import type { PreloadApi } from '../api-types' + +export const mobileApi = { + listNetworkInterfaces: (): Promise<{ + interfaces: { name: string; address: string; hasDefaultRoute?: boolean }[] + }> => ipcRenderer.invoke('mobile:listNetworkInterfaces'), + + getPairingQR: (args?: { + address?: string + connectionMode?: MobilePairingConnectionMode + rotate?: boolean + }): Promise< + | { + available: false + reason?: string + guidance?: string + relayFailure?: MobileRelayMintFailure + } + | { + available: true + qrDataUrl: string | null + /** Natural bitmap width and height in pixels. */ + qrSize: number | null + qrError?: 'encoding_failed' + pairingUrl: string + /** Null when no direct address was advertised — the QR pairs over Relay alone. */ + endpoint: string | null + deviceId: string + connectionMode: MobilePairingConnectionMode + } + > => ipcRenderer.invoke('mobile:getPairingQR', args), + + getWindowsFirewallStatus: (args?: { address?: string }) => + ipcRenderer.invoke('mobile:getWindowsFirewallStatus', args), + + repairWindowsFirewall: () => ipcRenderer.invoke('mobile:repairWindowsFirewall'), + + openWindowsNetworkSettings: () => ipcRenderer.invoke('mobile:openWindowsNetworkSettings'), + + getRuntimePairingUrl: (args?: { + address?: string + rotate?: boolean + // Why: the widen is one-way and host-wide, so main must gate it on the reach the user picked, not + // on how the typed address happens to look (a Custom loopback may front an SSH tunnel). + reach?: RuntimePairingReach + }): Promise< + | { available: false; reason?: 'network_exposure_failed'; guidance?: string } + | { + available: true + pairingUrl: string + webClientUrl: string | null + endpoint: string + deviceId: string + } + > => ipcRenderer.invoke('mobile:getRuntimePairingUrl', args), + + listDevices: (): Promise<{ + devices: { deviceId: string; name: string; pairedAt: number; lastSeenAt: number }[] + }> => ipcRenderer.invoke('mobile:listDevices'), + + revokeDevice: (args: { deviceId: string }): Promise<{ revoked: boolean }> => + ipcRenderer.invoke('mobile:revokeDevice', args), + + listRuntimeAccessGrants: () => ipcRenderer.invoke('mobile:listRuntimeAccessGrants'), + + revokeRuntimeAccess: (args: { deviceId: string }): Promise<{ revoked: boolean }> => + ipcRenderer.invoke('mobile:revokeRuntimeAccess', args), + + isWebSocketReady: (): Promise<{ ready: boolean; endpoint: string | null }> => + ipcRenderer.invoke('mobile:isWebSocketReady'), + + getRelayStatus: (): Promise<{ status: MobileRelayStatus }> => + ipcRenderer.invoke('mobile:getRelayStatus'), + + onRelayStatusChanged: (callback: (status: MobileRelayStatus) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, status: MobileRelayStatus) => + callback(status) + ipcRenderer.on('mobile:relayStatusChanged', listener) + return () => ipcRenderer.removeListener('mobile:relayStatusChanged', listener) + }, + + consumePendingUnpairedDeviceAuthFailure: (): Promise => + ipcRenderer.invoke('mobile:consumePendingUnpairedDeviceAuthFailure'), + + /** Fires (throttled, once per session) when an unpaired phone repeatedly fails direct-transport auth. */ + onUnpairedDeviceAuthFailure: (callback: () => void): (() => void) => { + const listener = () => callback() + ipcRenderer.on('mobile:unpairedDeviceAuthFailure', listener) + return () => ipcRenderer.removeListener('mobile:unpairedDeviceAuthFailure', listener) + } +} satisfies PreloadApi['mobile'] diff --git a/src/preload/api/native-chat-bridge.ts b/src/preload/api/native-chat-bridge.ts new file mode 100644 index 00000000000..3a0a5d9161a --- /dev/null +++ b/src/preload/api/native-chat-bridge.ts @@ -0,0 +1,41 @@ +import { ipcRenderer } from 'electron' +import type { + NativeChatAppendedPayload, + NativeChatReadSessionResult, + NativeChatSubscriptionFrame, + PreloadApi +} from '../api-types' +import type { AgentType } from '../../shared/native-chat-types' + +export const nativeChatApi = { + readSession: ( + agent: AgentType, + sessionId: string, + limit?: number, + transcriptPath?: string + ): Promise => + ipcRenderer.invoke('nativeChat:readSession', { agent, sessionId, limit, transcriptPath }), + /** Start live tailing; onAppended fires with only newly-appended messages. Returns an unsubscribe fn that closes the watcher. */ + subscribe: ( + args: { + subscriptionId: string + agent: AgentType + sessionId: string + transcriptPath?: string + limit?: number + }, + onFrame: (frame: NativeChatSubscriptionFrame) => void + ): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, payload: NativeChatAppendedPayload) => { + if (payload.subscriptionId === args.subscriptionId) { + onFrame(payload.frame) + } + } + ipcRenderer.on('nativeChat:appended', listener) + ipcRenderer.send('nativeChat:subscribe', args) + return () => { + ipcRenderer.removeListener('nativeChat:appended', listener) + ipcRenderer.send('nativeChat:unsubscribe', { subscriptionId: args.subscriptionId }) + } + } +} satisfies PreloadApi['nativeChat'] diff --git a/src/preload/api/notebook-bridge.ts b/src/preload/api/notebook-bridge.ts new file mode 100644 index 00000000000..436726b7783 --- /dev/null +++ b/src/preload/api/notebook-bridge.ts @@ -0,0 +1,12 @@ +import { ipcRenderer } from 'electron' +import type { PreloadApi } from '../api-types' + +export const notebookApi = { + runPythonCell: (args: { + filePath: string + code: string + preamble?: string + connectionId?: string | null + }): Promise<{ stdout: string; stderr: string; exitCode: number | null; error?: string }> => + ipcRenderer.invoke('notebook:runPythonCell', args) +} satisfies PreloadApi['notebook'] diff --git a/src/preload/api/notifications-bridge.ts b/src/preload/api/notifications-bridge.ts new file mode 100644 index 00000000000..70c64d4ce0d --- /dev/null +++ b/src/preload/api/notifications-bridge.ts @@ -0,0 +1,121 @@ +import { ipcRenderer } from 'electron' +import type { + NotificationDeliveryProbeResult, + NotificationDismissResult, + NotificationDispatchResult, + NotificationPermissionStatusResult, + NotificationSoundDataResult, + NotificationSoundPathResult, + NotificationSoundResult +} from '../../shared/notification-settings-types' +import type { PreloadApi } from '../api-types' + +// Why: cache one shared Audio + blob URL per sound path so notifications do not re-read large files. +let cachedNotificationSound: { + path: string + blobUrl: string + audio: HTMLAudioElement +} | null = null +let isNotificationSoundPlaying = false +// Why: audio.play() can reject before ended/error fires; cleanup prevents leaked listeners. +let cleanupNotificationSoundPlayback: (() => void) | null = null + +function clearNotificationSoundPlaybackState(): void { + cleanupNotificationSoundPlayback?.() + cleanupNotificationSoundPlayback = null + isNotificationSoundPlaying = false +} + +function disposeCachedNotificationSound(): void { + if (cachedNotificationSound) { + clearNotificationSoundPlaybackState() + cachedNotificationSound.audio.pause() + cachedNotificationSound.audio.src = '' + URL.revokeObjectURL(cachedNotificationSound.blobUrl) + cachedNotificationSound = null + } +} + +export const notificationsApi = { + dispatch: (args: Record): Promise => + ipcRenderer.invoke('notifications:dispatch', args), + dismiss: (ids: string[]): Promise => + ipcRenderer.invoke('notifications:dismiss', ids), + openSystemSettings: (): Promise => ipcRenderer.invoke('notifications:openSystemSettings'), + getPermissionStatus: (): Promise => + ipcRenderer.invoke('notifications:getPermissionStatus'), + probeDelivery: (args?: { force?: boolean }): Promise => + ipcRenderer.invoke('notifications:probeDelivery', args), + playSound: async (options?: { + force?: boolean + volume?: number + }): Promise => { + try { + // Why: drop replays while still ringing; the test button passes force to always confirm. + if (!options?.force && isNotificationSoundPlaying) { + return { played: false, reason: 'deduped' } + } + + const resolved = (await ipcRenderer.invoke( + 'notifications:resolveSoundPath' + )) as NotificationSoundPathResult + if (!resolved.ok) { + if (cachedNotificationSound) { + disposeCachedNotificationSound() + } + return { played: false, reason: resolved.reason } + } + + let entry = cachedNotificationSound + if (!entry || entry.path !== resolved.path) { + const sound = (await ipcRenderer.invoke( + 'notifications:loadSound' + )) as NotificationSoundDataResult + if (!sound.ok) { + disposeCachedNotificationSound() + return { played: false, reason: sound.reason } + } + const arrayBuffer = new ArrayBuffer(sound.data.byteLength) + new Uint8Array(arrayBuffer).set(sound.data) + const blob = new Blob([arrayBuffer], { type: sound.mimeType }) + disposeCachedNotificationSound() + const blobUrl = URL.createObjectURL(blob) + entry = { path: sound.path, blobUrl, audio: new Audio(blobUrl) } + cachedNotificationSound = entry + } + + const audio = entry.audio + // Why: restart from zero on each play so bursts replay instead of stacking copies (GNOME canberra / VS Code signal service). + audio.currentTime = 0 + if (typeof options?.volume === 'number' && Number.isFinite(options.volume)) { + audio.volume = Math.min(1, Math.max(0, options.volume / 100)) + } + isNotificationSoundPlaying = true + cleanupNotificationSoundPlayback?.() + const release = (): void => { + cleanup() + if (cleanupNotificationSoundPlayback === cleanup) { + cleanupNotificationSoundPlayback = null + } + isNotificationSoundPlaying = false + } + const cleanup = (): void => { + audio.removeEventListener('ended', release) + audio.removeEventListener('error', release) + } + cleanupNotificationSoundPlayback = cleanup + audio.addEventListener('ended', release) + audio.addEventListener('error', release) + try { + await audio.play() + } catch { + release() + return { played: false, reason: 'playback-failed' } + } + return { played: true } + } catch { + clearNotificationSoundPlaybackState() + return { played: false, reason: 'playback-failed' } + } + } +} satisfies PreloadApi['notifications'] diff --git a/src/preload/api/onboarding-bridge.ts b/src/preload/api/onboarding-bridge.ts new file mode 100644 index 00000000000..7939ab64810 --- /dev/null +++ b/src/preload/api/onboarding-bridge.ts @@ -0,0 +1,12 @@ +import { ipcRenderer } from 'electron' +import type { OnboardingState } from '../../shared/onboarding-state-types' +import type { PreloadApi } from '../api-types' + +export const onboardingApi = { + get: (): Promise => ipcRenderer.invoke('onboarding:get'), + update: ( + updates: Partial> & { + checklist?: Partial + } + ): Promise => ipcRenderer.invoke('onboarding:update', updates) +} satisfies PreloadApi['onboarding'] diff --git a/src/preload/api/open-code-usage-bridge.ts b/src/preload/api/open-code-usage-bridge.ts new file mode 100644 index 00000000000..cd3564d2b32 --- /dev/null +++ b/src/preload/api/open-code-usage-bridge.ts @@ -0,0 +1,8 @@ +import { ipcRenderer } from 'electron' +import { createUsageProviderApi } from '../usage-provider-api' +import type { PreloadApi } from '../api-types' + +export const openCodeUsageApi = createUsageProviderApi( + ipcRenderer, + 'openCodeUsage' +) satisfies PreloadApi['openCodeUsage'] diff --git a/src/preload/api/orca-profiles-bridge.ts b/src/preload/api/orca-profiles-bridge.ts new file mode 100644 index 00000000000..0b2897f8ab1 --- /dev/null +++ b/src/preload/api/orca-profiles-bridge.ts @@ -0,0 +1,21 @@ +import { ipcRenderer } from 'electron' +import type { PreloadApi } from '../api-types' + +export const orcaProfilesApi = { + list: () => ipcRenderer.invoke('orcaProfiles:list'), + authStatus: () => ipcRenderer.invoke('orcaProfiles:authStatus'), + createLocal: (args) => ipcRenderer.invoke('orcaProfiles:createLocal', args), + createCloudLinked: (args) => ipcRenderer.invoke('orcaProfiles:createCloudLinked', args), + switchProfile: (args) => ipcRenderer.invoke('orcaProfiles:switch', args), + transferProject: (args) => ipcRenderer.invoke('orcaProfiles:transferProject', args), + findProjectProfiles: (args) => ipcRenderer.invoke('orcaProfiles:findProjectProfiles', args), + connectCurrent: () => ipcRenderer.invoke('orcaProfiles:connectCurrent'), + refreshAuth: () => ipcRenderer.invoke('orcaProfiles:refreshAuth'), + signOutCurrent: () => ipcRenderer.invoke('orcaProfiles:signOutCurrent'), + selectOrg: (args) => ipcRenderer.invoke('orcaProfiles:selectOrg', args), + orgMembersList: (args) => ipcRenderer.invoke('orcaProfiles:orgMembersList', args), + orgMemberInvite: (args) => ipcRenderer.invoke('orcaProfiles:orgMemberInvite', args), + orgInviteRevoke: (args) => ipcRenderer.invoke('orcaProfiles:orgInviteRevoke', args), + orgMemberChangeRole: (args) => ipcRenderer.invoke('orcaProfiles:orgMemberChangeRole', args), + orgMemberRemove: (args) => ipcRenderer.invoke('orcaProfiles:orgMemberRemove', args) +} satisfies PreloadApi['orcaProfiles'] diff --git a/src/preload/api/pet-bridge.ts b/src/preload/api/pet-bridge.ts new file mode 100644 index 00000000000..c51751b3161 --- /dev/null +++ b/src/preload/api/pet-bridge.ts @@ -0,0 +1,12 @@ +import { ipcRenderer } from 'electron' +import type { CustomPet } from '../../shared/pet-types' +import type { PreloadApi } from '../api-types' + +export const petApi = { + import: (): Promise => ipcRenderer.invoke('pet:import'), + importPetBundle: (): Promise => ipcRenderer.invoke('pet:importPetBundle'), + read: (id: string, fileName: string, kind?: 'image' | 'bundle'): Promise => + ipcRenderer.invoke('pet:read', id, fileName, kind), + delete: (id: string, fileName: string, kind?: 'image' | 'bundle'): Promise => + ipcRenderer.invoke('pet:delete', id, fileName, kind) +} satisfies PreloadApi['pet'] diff --git a/src/preload/api/platform-bridge.ts b/src/preload/api/platform-bridge.ts new file mode 100644 index 00000000000..8e47a4386cf --- /dev/null +++ b/src/preload/api/platform-bridge.ts @@ -0,0 +1,17 @@ +import { getLinuxDisplayServer } from '../preload-runtime-support' +import type { PreloadApi } from '../api-types' + +export const platformApi = { + get: () => ({ + platform: process.platform, + // Why: sandboxed preload cannot require node:os; Electron exposes the OS + // version on process.getSystemVersion when available. + osRelease: + (process as NodeJS.Process & { getSystemVersion?: () => string }).getSystemVersion?.() ?? '', + arch: process.arch, + // Why: these identify the default shell without probing user config files. + // process.env is available in the sandboxed preload; node:os is not. + shell: process.env.SHELL?.trim() || process.env.ComSpec?.trim() || '', + displayServer: getLinuxDisplayServer() + }) +} satisfies PreloadApi['platform'] diff --git a/src/preload/api/plugins-bridge.ts b/src/preload/api/plugins-bridge.ts new file mode 100644 index 00000000000..0e529e74d96 --- /dev/null +++ b/src/preload/api/plugins-bridge.ts @@ -0,0 +1,60 @@ +import { ipcRenderer } from 'electron' +import type { + PluginPanelActionOutcome, + PluginPanelEntry +} from '../../shared/plugins/plugin-panel-bridge' +import type { PluginConsentRequest } from '../../shared/plugins/plugin-consent-request' +import type { PluginChangeEvent } from '../../shared/plugins/plugin-change-event' +import type { + PluginHostInstallResult, + PluginHostInstallSource, + PluginHostListEntry, + PluginHostLogLine, + PreloadApi +} from '../api-types' + +export const pluginsApi = { + list: (): Promise => ipcRenderer.invoke('plugins:list'), + listLanguagePacks: () => ipcRenderer.invoke('plugins:listLanguagePacks'), + consent: (args: PluginConsentRequest): Promise => + ipcRenderer.invoke('plugins:consent', args), + setEnabled: (args: { pluginKey: string; enabled: boolean }): Promise => + ipcRenderer.invoke('plugins:setEnabled', args), + readPanelEntry: (args: { + pluginKey: string + panelId: string + }): Promise => ipcRenderer.invoke('plugins:readPanelEntry', args), + invokeCommand: (args: { pluginKey: string; commandId: string; args?: unknown }) => + ipcRenderer.invoke('plugins:invokeCommand', args), + panelAction: (args: { + sessionToken: string + action: string + params?: unknown + }): Promise => ipcRenderer.invoke('plugins:panelAction', args), + install: (source: PluginHostInstallSource): Promise => + ipcRenderer.invoke('plugins:install', source), + listMarketplaces: () => ipcRenderer.invoke('plugins:listMarketplaces'), + addMarketplace: (source) => ipcRenderer.invoke('plugins:addMarketplace', source), + removeMarketplace: (args) => ipcRenderer.invoke('plugins:removeMarketplace', args), + refreshMarketplaces: (args = {}) => ipcRenderer.invoke('plugins:refreshMarketplaces', args), + listMarketplacePlugins: () => ipcRenderer.invoke('plugins:listMarketplacePlugins'), + previewMarketplacePlugin: (args) => ipcRenderer.invoke('plugins:previewMarketplacePlugin', args), + installMarketplacePlugin: (preview) => + ipcRenderer.invoke('plugins:installMarketplacePlugin', preview), + previewMarketplaceUpdate: (args) => ipcRenderer.invoke('plugins:previewMarketplaceUpdate', args), + rollbackMarketplacePlugin: (args) => + ipcRenderer.invoke('plugins:rollbackMarketplacePlugin', args), + remove: (args: { pluginKey: string }): Promise => + ipcRenderer.invoke('plugins:remove', args), + getLogs: (args: { pluginKey: string }): Promise => + ipcRenderer.invoke('plugins:getLogs', args), + refresh: (): Promise => ipcRenderer.invoke('plugins:refresh'), + onChanged: (callback): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, change: PluginChangeEvent): void => + callback(change) + ipcRenderer.on('plugins:changed', listener) + return () => { + ipcRenderer.removeListener('plugins:changed', listener) + } + } +} satisfies PreloadApi['plugins'] diff --git a/src/preload/api/preflight-bridge.ts b/src/preload/api/preflight-bridge.ts new file mode 100644 index 00000000000..64d317d139c --- /dev/null +++ b/src/preload/api/preflight-bridge.ts @@ -0,0 +1,43 @@ +import { ipcRenderer } from 'electron' +import type { PreflightRuntimeContext, PreloadApi, RefreshAgentsResult } from '../api-types' + +export const preflightApi = { + check: (args?: { + force?: boolean + }): Promise<{ + git: { installed: boolean } + gh: { installed: boolean; authenticated: boolean } + glab?: { installed: boolean; authenticated: boolean } + bitbucket?: { configured: boolean; authenticated: boolean; account: string | null } + azureDevOps?: { + configured: boolean + authenticated: boolean + account: string | null + baseUrl: string | null + tokenConfigured: boolean + } + gitea?: { + configured: boolean + authenticated: boolean + account: string | null + baseUrl: string | null + tokenConfigured: boolean + } + linear: { connected: boolean } + }> => ipcRenderer.invoke('preflight:check', args), + detectAgents: (args?: PreflightRuntimeContext): Promise => + ipcRenderer.invoke('preflight:detectAgents', args), + refreshAgents: (args?: PreflightRuntimeContext): Promise => + ipcRenderer.invoke('preflight:refreshAgents', args), + detectRemoteAgents: (args: { connectionId: string }): Promise => + ipcRenderer.invoke('preflight:detectRemoteAgents', args), + detectRemoteWindowsTerminalCapabilities: (args: { + connectionId: string + }): Promise<{ + wslAvailable: boolean + wslDistros: string[] + pwshAvailable: boolean + gitBashAvailable: boolean + hostPlatform: NodeJS.Platform | null + }> => ipcRenderer.invoke('preflight:detectRemoteWindowsTerminalCapabilities', args) +} satisfies PreloadApi['preflight'] diff --git a/src/preload/api/project-groups-bridge.ts b/src/preload/api/project-groups-bridge.ts new file mode 100644 index 00000000000..c14d58b7d00 --- /dev/null +++ b/src/preload/api/project-groups-bridge.ts @@ -0,0 +1,22 @@ +import { ipcRenderer } from 'electron' +import type { NestedRepoScanResult } from '../../shared/project-group-types' +import type { PreloadApi } from '../api-types' + +export const projectGroupsApi = { + list: () => ipcRenderer.invoke('projectGroups:list'), + create: (args) => ipcRenderer.invoke('projectGroups:create', args), + update: (args) => ipcRenderer.invoke('projectGroups:update', args), + delete: (args) => ipcRenderer.invoke('projectGroups:delete', args), + moveProject: (args) => ipcRenderer.invoke('projectGroups:moveProject', args), + scanNested: (args) => ipcRenderer.invoke('projectGroups:scanNested', args), + cancelNestedScan: (args) => ipcRenderer.invoke('projectGroups:cancelNestedScan', args), + onNestedScanProgress: (callback) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { scanId: string; scan: NestedRepoScanResult } + ) => callback(data) + ipcRenderer.on('projectGroups:scanNestedProgress', listener) + return () => ipcRenderer.removeListener('projectGroups:scanNestedProgress', listener) + }, + importNested: (args) => ipcRenderer.invoke('projectGroups:importNested', args) +} satisfies PreloadApi['projectGroups'] diff --git a/src/preload/api/projects-bridge.ts b/src/preload/api/projects-bridge.ts new file mode 100644 index 00000000000..c8114fbdf4a --- /dev/null +++ b/src/preload/api/projects-bridge.ts @@ -0,0 +1,12 @@ +import { ipcRenderer } from 'electron' +import type { PreloadApi } from '../api-types' + +export const projectsApi = { + list: () => ipcRenderer.invoke('projects:list'), + update: (args) => ipcRenderer.invoke('projects:update', args), + listHostSetups: () => ipcRenderer.invoke('projectHostSetups:list'), + createHostSetup: (args) => ipcRenderer.invoke('projectHostSetups:create', args), + setupExistingFolder: (args) => ipcRenderer.invoke('projectHostSetups:setupExistingFolder', args), + updateHostSetup: (args) => ipcRenderer.invoke('projectHostSetups:update', args), + deleteHostSetup: (args) => ipcRenderer.invoke('projectHostSetups:delete', args) +} satisfies PreloadApi['projects'] diff --git a/src/preload/api/pty-bridge-session-control.ts b/src/preload/api/pty-bridge-session-control.ts new file mode 100644 index 00000000000..ef6002e11c8 --- /dev/null +++ b/src/preload/api/pty-bridge-session-control.ts @@ -0,0 +1,208 @@ +import { ipcRenderer } from 'electron' +import type { AgentSessionPtyWriteRefusal } from '../../shared/agent-session-pty-write-admission' +import type { ProjectExecutionRuntimeResolution } from '../../shared/project-execution-runtime' +import type { StartupCommandDelivery } from '../../shared/codex-startup-delivery' +import type { + AgentProviderSessionMetadata, + SleepingAgentLaunchConfig +} from '../../shared/agent-session-resume' +import type { TuiAgent } from '../../shared/tui-agent' +import type { PtyListedSession } from '../../shared/pty-listed-session' +import type { + PtyRendererDeliveryHealthReply, + PtyRendererDeliveryStateReport +} from '../../shared/pty-renderer-delivery-health' +import type { TerminalViewAttributes } from '../../shared/terminal-view-attributes' +import type { PtyMainDeliveryDiagnostics } from '../../shared/pty-delivery-diagnostics' +import type { AgentKind, LaunchSource, RequestKind } from '../../shared/telemetry-events' +import type { PreloadApi } from '../api-types' + +export const ptySessionControlApi = { + spawn: (opts: { + cols: number + rows: number + cwd?: string + cwdFallback?: 'worktree' + env?: Record + envToDelete?: string[] + command?: string + commandDelivery?: 'renderer' | 'provider' + launchConfig?: SleepingAgentLaunchConfig + resumeProviderSession?: AgentProviderSessionMetadata + launchToken?: string + launchAgent?: TuiAgent + startupCommandDelivery?: StartupCommandDelivery + connectionId?: string | null + worktreeId?: string + sessionId?: string + shellOverride?: string + projectRuntime?: ProjectExecutionRuntimeResolution + terminalColorQueryReplies?: { foreground?: string; background?: string } + // Why: marks the PTY hidden before its first byte so the delivery gate + model responder own spawn-time queries (terminal-query-authority.md §races). + initiallyHidden?: boolean + // Why: closes the SIGKILL race (INVESTIGATION.md) — main sync-flushes the (worktreeId, tabId, leafId → ptyId) binding before pty:spawn returns. + tabId?: string + leafId?: string + // Why: loose typing on purpose — renderer owns launch metadata, main owns whether the launch happened and validates (telemetry-plan.md §Agent launch semantics). + telemetry?: { agent_kind: AgentKind; launch_source: LaunchSource; request_kind: RequestKind } + }): Promise<{ + id: string + /** Which lifetime of `id` this reply named; absent when the execution host predates the field. */ + incarnationId?: string + launchConfig?: SleepingAgentLaunchConfig + snapshot?: string + snapshotCols?: number + snapshotRows?: number + snapshotPrefixAnsi?: string + snapshotFrameAnsi?: string + snapshotFrameRestoreAnsi?: string + snapshotKittyKeyboardFlags?: number + snapshotTerminalOwner?: 'shell' + snapshotSeq?: number + isReattach?: boolean + isAlternateScreen?: boolean + replay?: string + sessionExpired?: boolean + coldRestore?: { scrollback: string; cwd: string; cols?: number; rows?: number } + startupCwdFallback?: { kind: 'worktree'; cwd: string } + agentResumeUnavailable?: true + }> => ipcRenderer.invoke('pty:spawn', opts), + write: (id: string, data: string): void => { + ipcRenderer.send('pty:write', { id, data }) + }, + writeAccepted: (id: string, data: string): Promise => + ipcRenderer.invoke('pty:writeAccepted', { id, data }), + onWriteUnavailable: ( + callback: (payload: { + id: string + /** Set only when a durable agent-session lease refused the write; absent otherwise. */ + agentSessionRefusal?: AgentSessionPtyWriteRefusal + }) => void + ): (() => void) => { + const handler = ( + _event: Electron.IpcRendererEvent, + payload: { id: string; agentSessionRefusal?: AgentSessionPtyWriteRefusal } + ): void => callback(payload) + ipcRenderer.on('pty:writeUnavailable', handler) + return () => ipcRenderer.removeListener('pty:writeUnavailable', handler) + }, + resize: (id: string, cols: number, rows: number): void => { + ipcRenderer.send('pty:resize', { id, cols, rows }) + }, + claimViewport: (id: string, cols: number, rows: number): void => { + ipcRenderer.send('pty:claimViewport', { id, cols, rows }) + }, + reportGeometry: (id: string, cols: number, rows: number): void => { + ipcRenderer.send('pty:reportGeometry', { id, cols, rows }) + }, + signal: (id: string, signal: string): void => { + ipcRenderer.send('pty:signal', { id, signal }) + }, + clearBuffer: (id: string): void => { + ipcRenderer.send('pty:clearBuffer', { id }) + }, + ackColdRestore: (id: string): void => { + ipcRenderer.send('pty:ackColdRestore', { id }) + }, + ackData: (id: string, charCount: number, processedChars?: number): void => { + ipcRenderer.send('pty:ackData', { + id, + charCount, + ...(typeof processedChars === 'number' ? { processedChars } : {}) + }) + }, + onDeliveryResyncRequest: (callback: (payload: { requestId: number }) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, payload: { requestId: number }) => + callback(payload) + ipcRenderer.on('pty:requestDeliveryResync', listener) + return () => ipcRenderer.removeListener('pty:requestDeliveryResync', listener) + }, + respondDeliveryResync: (payload: { + requestId: number + processedCharsByPty: Record + }): void => { + ipcRenderer.send('pty:deliveryResyncResponse', payload) + }, + reportRendererDeliveryState: ( + report: PtyRendererDeliveryStateReport + ): Promise => + ipcRenderer.invoke('pty:reportRendererDeliveryState', report), + getPtyDataListenerCount: (): number => ipcRenderer.listenerCount('pty:data'), + rendererDispatcherReady: (): void => { + ipcRenderer.send('pty:rendererDispatcherReady') + }, + setActiveRendererPty: (id: string, active: boolean): void => { + ipcRenderer.send('pty:setActiveRendererPty', { id, active }) + }, + setRendererPtyVisible: (id: string, visible: boolean): void => { + ipcRenderer.send('pty:setRendererPtyVisible', { id, visible }) + }, + setHiddenRendererPty: (id: string, hidden: boolean): void => { + ipcRenderer.send('pty:setHiddenRendererPty', { id, hidden }) + }, + setPtyDeliveryInterest: (id: string, interested: boolean): void => { + ipcRenderer.send('pty:setPtyDeliveryInterest', { id, interested }) + }, + publishTerminalViewAttributes: (attributes: TerminalViewAttributes): void => { + ipcRenderer.send('pty:terminalViewAttributes', attributes) + }, + kill: (id: string, opts?: { keepHistory?: boolean }): Promise => + ipcRenderer.invoke('pty:kill', { id, keepHistory: opts?.keepHistory ?? false }), + listSessions: (): Promise => ipcRenderer.invoke('pty:listSessions'), + getAuthoritativeBufferSnapshotCapabilities: ( + ids: string[] + ): Promise<{ id: string; authoritative: boolean | null }[]> => + ipcRenderer.invoke('pty:getAuthoritativeBufferSnapshotCapabilities', { ids }), + hasPty: (id: string): Promise => ipcRenderer.invoke('pty:hasPty', { id }), + getMainBufferSnapshot: ( + id: string, + opts?: { scrollbackRows?: number } + ): Promise<{ + data: string + frameRestoreAnsi?: string + cols: number + rows: number + cwd?: string | null + seq?: number + pendingDeliveryStartSeq?: number + source?: 'headless' | 'renderer' + alternateScreen?: boolean + scrollbackAnsi?: string + pendingEscapeTailAnsi?: string + kittyKeyboardFlags?: number + terminalOwner?: 'shell' + } | null> => ipcRenderer.invoke('pty:getMainBufferSnapshot', { id, opts }), + getRendererDeliveryDebugSnapshot: (): Promise<{ + pendingPtyCount: number + pendingChars: number + maxPendingCharsByPty: number + rendererInFlightPtyCount: number + rendererInFlightChars: number + maxRendererInFlightCharsByPty: number + activeRendererPtyCount: number + flushScheduled: boolean + peakPendingChars: number + peakMaxPendingCharsByPty: number + peakRendererInFlightChars: number + peakMaxRendererInFlightCharsByPty: number + ackGatedFlushSkipCount: number + hiddenDeliveryGatedPtyCount: number + hiddenDeliveryGatedVisiblePtyCount: number + hiddenDeliveryGatedActivePtyCount: number + deliveryInterestPtyCount: number + hiddenDeliveryDroppedChars: number + hiddenDeliveryDroppedChunks: number + pendingDroppedChars: number + diagnostics: PtyMainDeliveryDiagnostics + rendererLifecycleResetCount: number + lastLifecycleResetClearedChars: number + rendererPtyDispatcherReady: boolean + rendererDispatcherReadyForcedCount: number + }> => ipcRenderer.invoke('pty:getRendererDeliveryDebugSnapshot'), + resetRendererDeliveryDebug: (): Promise => + ipcRenderer.invoke('pty:resetRendererDeliveryDebug'), + hasChildProcesses: (id: string): Promise => + ipcRenderer.invoke('pty:hasChildProcesses', { id }), + getForegroundProcess: (id: string): Promise => + ipcRenderer.invoke('pty:getForegroundProcess', { id }) +} satisfies Partial diff --git a/src/preload/api/pty-bridge-stream-and-serialization.ts b/src/preload/api/pty-bridge-stream-and-serialization.ts new file mode 100644 index 00000000000..f751491c0e0 --- /dev/null +++ b/src/preload/api/pty-bridge-stream-and-serialization.ts @@ -0,0 +1,142 @@ +import { ipcRenderer } from 'electron' +import type { PtyModelRestoreNeededEvent } from '../../shared/pty-model-restore-marker' +import type { TerminalSideEffectBatch } from '../../shared/terminal-side-effect-facts' +import type { PreloadApi } from '../api-types' + +export const ptyStreamAndSerializationApi = { + inspectProcess: ( + id: string + ): Promise<{ + foregroundProcess: string | null + hasChildProcesses: boolean + unavailable?: true + }> => ipcRenderer.invoke('pty:inspectProcess', { id }), + confirmForegroundProcess: (id: string): Promise => + ipcRenderer.invoke('pty:confirmForegroundProcess', { id }), + getCwd: (id: string): Promise => ipcRenderer.invoke('pty:getCwd', { id }), + getSize: (id: string): Promise<{ cols: number; rows: number } | null> => + ipcRenderer.invoke('pty:getSize', { id }), + onData: ( + callback: (data: { + id: string + data: string + seq?: number + rawLength?: number + transformed?: boolean + background?: boolean + droppedOutput?: boolean + }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { + id: string + data: string + seq?: number + rawLength?: number + transformed?: boolean + background?: boolean + droppedOutput?: boolean + } + ) => callback(data) + ipcRenderer.on('pty:data', listener) + return () => ipcRenderer.removeListener('pty:data', listener) + }, + onReplay: (callback: (data: { id: string; data: string }) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, data: { id: string; data: string }) => + callback(data) + ipcRenderer.on('pty:replay', listener) + return () => ipcRenderer.removeListener('pty:replay', listener) + }, + onModelRestoreNeeded: (callback: (event: PtyModelRestoreNeededEvent) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, event: PtyModelRestoreNeededEvent) => + callback(event) + ipcRenderer.on('pty:modelRestoreNeeded', listener) + return () => ipcRenderer.removeListener('pty:modelRestoreNeeded', listener) + }, + onSideEffect: (callback: (batch: TerminalSideEffectBatch) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, batch: TerminalSideEffectBatch) => + callback(batch) + ipcRenderer.on('pty:sideEffect', listener) + return () => ipcRenderer.removeListener('pty:sideEffect', listener) + }, + getSideEffectSnapshot: (id: string): Promise => + ipcRenderer.invoke('pty:sideEffectSnapshot', { id }), + onExit: ( + callback: (data: { + id: string + code: number + preserveRendererBinding?: boolean + /** Which lifetime of `id` died; absent when the execution host predates the field. */ + incarnationId?: string + }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { + id: string + code: number + preserveRendererBinding?: boolean + incarnationId?: string + } + ) => callback(data) + ipcRenderer.on('pty:exit', listener) + return () => ipcRenderer.removeListener('pty:exit', listener) + }, + onSpawned: (callback: (data: { id: string }) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, data: { id: string }) => callback(data) + ipcRenderer.on('pty:spawned', listener) + return () => ipcRenderer.removeListener('pty:spawned', listener) + }, + onSerializeBufferRequest: ( + callback: (data: { + requestId: string + ptyId: string + opts?: { scrollbackRows?: number; altScreenForcesZeroRows?: boolean } + }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { + requestId: string + ptyId: string + opts?: { scrollbackRows?: number; altScreenForcesZeroRows?: boolean } + } + ) => callback(data) + ipcRenderer.on('pty:serializeBuffer:request', listener) + return () => ipcRenderer.removeListener('pty:serializeBuffer:request', listener) + }, + onClearBufferRequest: (callback: (data: { ptyId: string }) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, data: { ptyId: string }) => callback(data) + ipcRenderer.on('pty:clearBuffer:request', listener) + return () => ipcRenderer.removeListener('pty:clearBuffer:request', listener) + }, + sendSerializedBuffer: ( + requestId: string, + snapshot: { + data: string + cols: number + rows: number + seq?: number + lastTitle?: string + kittyKeyboardFlags?: number + } | null + ): void => { + ipcRenderer.send('pty:serializeBuffer:response', { requestId, snapshot }) + }, + declarePendingPaneSerializer: (paneKey: string): Promise => + ipcRenderer.invoke('pty:declarePendingPaneSerializer', { paneKey }), + settlePaneSerializer: (paneKey: string, gen: number): Promise => + ipcRenderer.invoke('pty:settlePaneSerializer', { paneKey, gen }), + clearPendingPaneSerializer: (paneKey: string, gen: number): Promise => + ipcRenderer.invoke('pty:clearPendingPaneSerializer', { paneKey, gen }), + reportRendererSerializerReady: (ptyId: string): Promise => + ipcRenderer.invoke('pty:reportRendererSerializerReady', { ptyId }), + management: { + listSessions: () => ipcRenderer.invoke('pty:management:listSessions'), + killAll: () => ipcRenderer.invoke('pty:management:killAll'), + killOne: (args: { sessionId: string }) => ipcRenderer.invoke('pty:management:killOne', args), + restart: () => ipcRenderer.invoke('pty:management:restart'), + macTccAttribution: () => ipcRenderer.invoke('pty:management:macTccAttribution') + } +} satisfies Partial diff --git a/src/preload/api/pty-bridge.ts b/src/preload/api/pty-bridge.ts new file mode 100644 index 00000000000..18f867e6426 --- /dev/null +++ b/src/preload/api/pty-bridge.ts @@ -0,0 +1,8 @@ +import type { PreloadApi } from '../api-types' +import { ptySessionControlApi } from './pty-bridge-session-control' +import { ptyStreamAndSerializationApi } from './pty-bridge-stream-and-serialization' + +export const ptyApi = { + ...ptySessionControlApi, + ...ptyStreamAndSerializationApi +} satisfies PreloadApi['pty'] diff --git a/src/preload/api/pwsh-bridge.ts b/src/preload/api/pwsh-bridge.ts new file mode 100644 index 00000000000..34ed9880ada --- /dev/null +++ b/src/preload/api/pwsh-bridge.ts @@ -0,0 +1,6 @@ +import { ipcRenderer } from 'electron' +import type { PreloadApi } from '../api-types' + +export const pwshApi = { + isAvailable: (): Promise => ipcRenderer.invoke('pwsh:isAvailable') +} satisfies PreloadApi['pwsh'] diff --git a/src/preload/api/rate-limits-bridge.ts b/src/preload/api/rate-limits-bridge.ts new file mode 100644 index 00000000000..37af13f0006 --- /dev/null +++ b/src/preload/api/rate-limits-bridge.ts @@ -0,0 +1,31 @@ +import { ipcRenderer } from 'electron' +import type { + CodexRateLimitResetResult, + RateLimitRuntimeTarget, + RateLimitState +} from '../../shared/rate-limit-types' +import type { PreloadApi } from '../api-types' + +export const rateLimitsApi = { + get: (): Promise => ipcRenderer.invoke('rateLimits:get'), + refresh: (): Promise => ipcRenderer.invoke('rateLimits:refresh'), + refreshCodexForTarget: (target: RateLimitRuntimeTarget): Promise => + ipcRenderer.invoke('rateLimits:refreshCodexForTarget', target), + consumeCodexResetCredit: (): Promise => + ipcRenderer.invoke('rateLimits:consumeCodexResetCredit'), + refreshClaudeForTarget: (target: RateLimitRuntimeTarget): Promise => + ipcRenderer.invoke('rateLimits:refreshClaudeForTarget', target), + setPollingInterval: (ms: number): Promise => + ipcRenderer.invoke('rateLimits:setPollingInterval', ms), + fetchInactiveClaudeAccounts: (): Promise => + ipcRenderer.invoke('rateLimits:fetchInactiveClaudeAccounts'), + fetchInactiveCodexAccounts: (): Promise => + ipcRenderer.invoke('rateLimits:fetchInactiveCodexAccounts'), + refreshMiniMax: (): Promise => ipcRenderer.invoke('rateLimits:refreshMiniMax'), + refreshGrok: (): Promise => ipcRenderer.invoke('rateLimits:refreshGrok'), + onUpdate: (callback: (state: RateLimitState) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, state: RateLimitState) => callback(state) + ipcRenderer.on('rateLimits:update', listener) + return () => ipcRenderer.removeListener('rateLimits:update', listener) + } +} satisfies PreloadApi['rateLimits'] diff --git a/src/preload/api/remote-workspace-bridge.ts b/src/preload/api/remote-workspace-bridge.ts new file mode 100644 index 00000000000..2a7ca528d21 --- /dev/null +++ b/src/preload/api/remote-workspace-bridge.ts @@ -0,0 +1,19 @@ +import { ipcRenderer } from 'electron' +import type { RemoteWorkspaceChangedEvent } from '../../shared/remote-workspace-types' +import type { PreloadApi } from '../api-types' + +export const remoteWorkspaceApi = { + get: (args) => ipcRenderer.invoke('remoteWorkspace:get', args), + setForConnectedTargets: (args) => + ipcRenderer.invoke('remoteWorkspace:setForConnectedTargets', args), + listEnabledConnectedTargets: () => + ipcRenderer.invoke('remoteWorkspace:listEnabledConnectedTargets'), + listConnectedClients: (args) => ipcRenderer.invoke('remoteWorkspace:listConnectedClients', args), + clientId: () => ipcRenderer.invoke('remoteWorkspace:clientId'), + onChanged: (callback) => { + const listener = (_event: Electron.IpcRendererEvent, data: RemoteWorkspaceChangedEvent) => + callback(data) + ipcRenderer.on('remoteWorkspace:changed', listener) + return () => ipcRenderer.removeListener('remoteWorkspace:changed', listener) + } +} satisfies PreloadApi['remoteWorkspace'] diff --git a/src/preload/api/repos-bridge.ts b/src/preload/api/repos-bridge.ts new file mode 100644 index 00000000000..36859897938 --- /dev/null +++ b/src/preload/api/repos-bridge.ts @@ -0,0 +1,87 @@ +import { ipcRenderer } from 'electron' +import type { + HostRepoCatalogSnapshot, + ListReposForExecutionHostArgs +} from '../../shared/host-repo-catalog-contract' +import type { BaseRefDefaultResult, BaseRefSearchResult } from '../../shared/repo-types' +import type { ExecutionHostId } from '../../shared/execution-host' +import type { PreloadApi } from '../api-types' + +export const reposApi = { + list: () => ipcRenderer.invoke('repos:list'), + + listForExecutionHost: (args: ListReposForExecutionHostArgs): Promise => + ipcRenderer.invoke('repos:listForExecutionHost', args), + + add: (args) => ipcRenderer.invoke('repos:add', args), + + addRemote: (args) => ipcRenderer.invoke('repos:addRemote', args), + + create: (args) => ipcRenderer.invoke('repos:create', args), + + isGitAvailable: (): Promise => ipcRenderer.invoke('repos:isGitAvailable'), + + getDefaultCreateProjectParent: (): Promise => + ipcRenderer.invoke('repos:getDefaultCreateProjectParent'), + + remove: (args) => ipcRenderer.invoke('repos:remove', args), + + removeForHost: (args) => ipcRenderer.invoke('repos:removeForHost', args), + + reorder: (args) => ipcRenderer.invoke('repos:reorder', args), + + reorderForHost: (args) => ipcRenderer.invoke('repos:reorderForHost', args), + + update: (args) => ipcRenderer.invoke('repos:update', args), + + pickFolder: () => ipcRenderer.invoke('repos:pickFolder'), + + pickFolders: () => ipcRenderer.invoke('repos:pickFolders'), + + pickDirectory: () => ipcRenderer.invoke('repos:pickDirectory'), + + clone: (args) => ipcRenderer.invoke('repos:clone', args), + + cloneRemote: (args) => ipcRenderer.invoke('repos:cloneRemote', args), + + createRemote: (args) => ipcRenderer.invoke('repos:createRemote', args), + + cloneAbort: () => ipcRenderer.invoke('repos:cloneAbort'), + + onCloneProgress: (callback: (data: { phase: string; percent: number }) => void): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { phase: string; percent: number } + ) => callback(data) + ipcRenderer.on('repos:clone-progress', listener) + return () => ipcRenderer.removeListener('repos:clone-progress', listener) + }, + + getGitUsername: (args: { repoId: string }): Promise => + ipcRenderer.invoke('repos:getGitUsername', args), + + getBaseRefDefault: (args: { + repoId: string + hostId?: ExecutionHostId + }): Promise => ipcRenderer.invoke('repos:getBaseRefDefault', args), + + searchBaseRefs: (args: { + repoId: string + query: string + limit?: number + hostId?: ExecutionHostId + }): Promise => ipcRenderer.invoke('repos:searchBaseRefs', args), + + searchBaseRefDetails: (args: { + repoId: string + query: string + limit?: number + hostId?: ExecutionHostId + }): Promise => ipcRenderer.invoke('repos:searchBaseRefDetails', args), + + onChanged: (callback: () => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent) => callback() + ipcRenderer.on('repos:changed', listener) + return () => ipcRenderer.removeListener('repos:changed', listener) + } +} satisfies PreloadApi['repos'] diff --git a/src/preload/api/runtime-bridge.ts b/src/preload/api/runtime-bridge.ts new file mode 100644 index 00000000000..8b31e6873f5 --- /dev/null +++ b/src/preload/api/runtime-bridge.ts @@ -0,0 +1,145 @@ +import { ipcRenderer } from 'electron' +import type { ClientHostedBrowserRowsEvent } from '../../shared/client-hosted-browser-rows' +import type { + RuntimeBrowserDriverState, + RuntimeRendererSyncWindowGraph, + RuntimeStatus, + RuntimeSyncWindowGraphResult, + RuntimeTerminalDriverState +} from '../../shared/runtime-types' +import type { RuntimeRpcResponse } from '../../shared/runtime-rpc-envelope' +import type { RuntimeEnvironmentSubscriptionHandle } from '../runtime-environment-subscriptions' +import type { PreloadApi } from '../api-types' + +export const runtimeApi = { + syncWindowGraph: (graph: RuntimeRendererSyncWindowGraph): Promise => + ipcRenderer.invoke('runtime:syncWindowGraph', graph), + getStatus: (): Promise => ipcRenderer.invoke('runtime:getStatus'), + call: (args: { method: string; params?: unknown }): Promise> => + ipcRenderer.invoke('runtime:call', args), + subscribe: async ( + args: { method: string; params?: unknown }, + callback: (response: RuntimeRpcResponse) => void + ): Promise => { + const subscriptionId = `desktop-${crypto.randomUUID()}` + const channel = `runtime:subscription:${subscriptionId}` + const listener = (_event: Electron.IpcRendererEvent, response: RuntimeRpcResponse) => + callback(response) + ipcRenderer.on(channel, listener) + try { + await ipcRenderer.invoke('runtime:subscribe', { subscriptionId, ...args }) + } catch (error) { + ipcRenderer.removeListener(channel, listener) + throw error + } + return { + unsubscribe: () => { + ipcRenderer.removeListener(channel, listener) + ipcRenderer.send('runtime:unsubscribe', { subscriptionId }) + }, + sendBinary: () => { + throw new Error('Local runtime subscriptions do not accept binary input') + } + } + }, + getTerminalFitOverrides: (): Promise< + { ptyId: string; mode: 'mobile-fit' | 'remote-desktop-fit'; cols: number; rows: number }[] + > => ipcRenderer.invoke('runtime:getTerminalFitOverrides'), + getTerminalDrivers: (): Promise< + { + ptyId: string + driver: RuntimeTerminalDriverState + }[] + > => ipcRenderer.invoke('runtime:getTerminalDrivers'), + getBrowserDrivers: (): Promise< + { + browserPageId: string + driver: RuntimeBrowserDriverState + }[] + > => ipcRenderer.invoke('runtime:getBrowserDrivers'), + getBrowserRemoteViewerPages: (): Promise => + ipcRenderer.invoke('runtime:getBrowserRemoteViewerPages'), + getClientHostedBrowserRows: (): Promise => + ipcRenderer.invoke('runtime:getClientHostedBrowserRows'), + restoreTerminalFit: (ptyId: string): Promise<{ restored: boolean }> => + ipcRenderer.invoke('runtime:restoreTerminalFit', { ptyId }), + reclaimBrowserForDesktop: (browserPageId: string): Promise<{ reclaimed: boolean }> => + ipcRenderer.invoke('runtime:reclaimBrowserForDesktop', { browserPageId }), + onTerminalFitOverrideChanged: ( + callback: (event: { + ptyId: string + mode: 'mobile-fit' | 'remote-desktop-fit' | 'desktop-fit' + cols: number + rows: number + }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { + ptyId: string + mode: 'mobile-fit' | 'remote-desktop-fit' | 'desktop-fit' + cols: number + rows: number + } + ) => callback(data) + ipcRenderer.on('runtime:terminalFitOverrideChanged', listener) + return () => ipcRenderer.removeListener('runtime:terminalFitOverrideChanged', listener) + }, + onTerminalDriverChanged: ( + callback: (event: { ptyId: string; driver: RuntimeTerminalDriverState }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { + ptyId: string + driver: RuntimeTerminalDriverState + } + ) => callback(data) + ipcRenderer.on('runtime:terminalDriverChanged', listener) + return () => ipcRenderer.removeListener('runtime:terminalDriverChanged', listener) + }, + onNativeChatLaunchDraftResolved: ( + callback: (event: { tabId: string; text: string; createdAt: number }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { tabId: string; text: string; createdAt: number } + ) => callback(data) + ipcRenderer.on('runtime:nativeChatLaunchDraftResolved', listener) + return () => ipcRenderer.removeListener('runtime:nativeChatLaunchDraftResolved', listener) + }, + onBrowserDriverChanged: ( + callback: (event: { browserPageId: string; driver: RuntimeBrowserDriverState }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { + browserPageId: string + driver: RuntimeBrowserDriverState + } + ) => callback(data) + ipcRenderer.on('runtime:browserDriverChanged', listener) + return () => ipcRenderer.removeListener('runtime:browserDriverChanged', listener) + }, + onBrowserRemoteViewersChanged: ( + callback: (event: { browserPageId: string; hasRemoteViewers: boolean }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { + browserPageId: string + hasRemoteViewers: boolean + } + ) => callback(data) + ipcRenderer.on('runtime:browserRemoteViewersChanged', listener) + return () => ipcRenderer.removeListener('runtime:browserRemoteViewersChanged', listener) + }, + onClientHostedBrowserRowsChanged: ( + callback: (event: ClientHostedBrowserRowsEvent) => void + ): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, data: ClientHostedBrowserRowsEvent) => + callback(data) + ipcRenderer.on('runtime:clientHostedBrowserRowsChanged', listener) + return () => ipcRenderer.removeListener('runtime:clientHostedBrowserRowsChanged', listener) + } +} satisfies PreloadApi['runtime'] diff --git a/src/preload/api/runtime-environments-bridge.ts b/src/preload/api/runtime-environments-bridge.ts new file mode 100644 index 00000000000..ddfa498dc74 --- /dev/null +++ b/src/preload/api/runtime-environments-bridge.ts @@ -0,0 +1,94 @@ +import { ipcRenderer } from 'electron' +import type { VerifyAndAddRuntimeEnvironmentResult } from '../../shared/remote-pairing-verification' +import type { RuntimeStatus } from '../../shared/runtime-types' +import type { RuntimeRpcResponse } from '../../shared/runtime-rpc-envelope' +import type { PublicKnownRuntimeEnvironment } from '../../shared/runtime-environments' +import type { RemoteRuntimeSharedConnectionDiagnostics } from '../../shared/remote-runtime-shared-control-types' +import { RUNTIME_ENVIRONMENT_DIAGNOSTICS_CHANNEL } from '../../shared/runtime-environment-diagnostics' +import { + subscribeRuntimeEnvironmentFromPreload, + type RuntimeEnvironmentSubscriptionHandle +} from '../runtime-environment-subscriptions' +import type { PreloadApi } from '../api-types' + +export const runtimeEnvironmentsApi = { + list: (): Promise => + ipcRenderer.invoke('runtimeEnvironments:list'), + addFromPairingCode: (args: { + name: string + pairingCode: string + }): Promise<{ environment: PublicKnownRuntimeEnvironment }> => + ipcRenderer.invoke('runtimeEnvironments:addFromPairingCode', args), + verifyAndAddFromPairingCode: (args: { + name: string + pairingCode: string + allowLoopback?: boolean + }): Promise => + ipcRenderer.invoke('runtimeEnvironments:verifyAndAddFromPairingCode', args), + resolve: (args: { selector: string }): Promise => + ipcRenderer.invoke('runtimeEnvironments:resolve', args), + remove: (args: { selector: string }): Promise<{ removed: PublicKnownRuntimeEnvironment }> => + ipcRenderer.invoke('runtimeEnvironments:remove', args), + disconnect: (args: { + selector: string + }): Promise<{ disconnected: PublicKnownRuntimeEnvironment }> => + ipcRenderer.invoke('runtimeEnvironments:disconnect', args), + connect: (args: { + selector: string + timeoutMs?: number + }): Promise> => + ipcRenderer.invoke('runtimeEnvironments:connect', args), + getStatus: (args: { + selector: string + timeoutMs?: number + observeOnly?: true + }): Promise> => + ipcRenderer.invoke('runtimeEnvironments:getStatus', args), + retryControlConnection: (args: { selector: string }): Promise => + ipcRenderer.invoke('runtimeEnvironments:retryControlConnection', args), + onSharedControlDiagnostics: ( + callback: (event: { + environmentId: string + transportGeneration: number + diagnostics: RemoteRuntimeSharedConnectionDiagnostics + }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { + environmentId: string + transportGeneration: number + diagnostics: RemoteRuntimeSharedConnectionDiagnostics + } + ): void => callback(data) + ipcRenderer.on(RUNTIME_ENVIRONMENT_DIAGNOSTICS_CHANNEL, listener) + return () => ipcRenderer.removeListener(RUNTIME_ENVIRONMENT_DIAGNOSTICS_CHANNEL, listener) + }, + prepareBrowserClientHostPlacement: (args) => + ipcRenderer.invoke('runtimeEnvironments:prepareBrowserClientHostPlacement', args), + retryConnectionsNow: (): Promise => + ipcRenderer.invoke('runtimeEnvironments:retryConnectionsNow'), + call: (args: { + selector: string + method: string + params?: unknown + timeoutMs?: number + expectedEnvironmentPairingRevision?: number + }): Promise> => ipcRenderer.invoke('runtimeEnvironments:call', args), + subscribe: async ( + args: { + selector: string + method: string + params?: unknown + timeoutMs?: number + expectedEnvironmentPairingRevision?: number + }, + callbacks: { + onResponse: (response: RuntimeRpcResponse) => void + onBinary?: (bytes: Uint8Array) => void + onError?: (error: { code: string; message: string }) => void + onClose?: () => void + } + ): Promise => + subscribeRuntimeEnvironmentFromPreload(ipcRenderer, args, callbacks) +} satisfies PreloadApi['runtimeEnvironments'] diff --git a/src/preload/api/session-bridge.ts b/src/preload/api/session-bridge.ts new file mode 100644 index 00000000000..189dd96c1d1 --- /dev/null +++ b/src/preload/api/session-bridge.ts @@ -0,0 +1,16 @@ +import { ipcRenderer } from 'electron' +import type { PreloadApi } from '../api-types' + +export const sessionApi = { + // hostId is optional; main defaults it to 'local' so existing omitting call sites keep the local session partition. + get: (hostId) => ipcRenderer.invoke('session:get', hostId), + set: (args, hostId) => ipcRenderer.invoke('session:set', args, hostId), + patch: (args, hostId) => ipcRenderer.invoke('session:patch', args, hostId), + flush: () => ipcRenderer.invoke('session:flush'), + readTerminalScrollback: (args) => + ipcRenderer.sendSync('session:read-terminal-scrollback-sync', args), + /** Synchronous session save for beforeunload — blocks until flushed to disk. */ + setSync: (args, hostId) => { + ipcRenderer.sendSync('session:set-sync', args, hostId) + } +} satisfies PreloadApi['session'] diff --git a/src/preload/api/settings-bridge.ts b/src/preload/api/settings-bridge.ts new file mode 100644 index 00000000000..4e7105c00cb --- /dev/null +++ b/src/preload/api/settings-bridge.ts @@ -0,0 +1,37 @@ +import { ipcRenderer } from 'electron' +import type { GhosttyImportPreview } from '../../shared/global-settings-types' +import type { + WarpThemeImportPreview, + WarpThemeImportSource +} from '../../shared/terminal-custom-themes' +import type { PreloadApi } from '../api-types' + +export const settingsApi = { + get: () => ipcRenderer.invoke('settings:get'), + + // Why: blocking read for the few startup decisions (terminal side-effect authority) that can't wait for async hydration. Call sparingly. + getSync: () => ipcRenderer.sendSync('settings:get-sync'), + + set: (args: Record) => ipcRenderer.invoke('settings:set', args), + + setActiveRuntimeEnvironmentPreference: (args: { environmentId: string | null }) => + ipcRenderer.invoke('settings:set-active-runtime-environment-preference', args), + + updatePRBotAuthorOverride: (args: { author: string; isBot: boolean }) => + ipcRenderer.invoke('settings:update-pr-bot-author-override', args), + + listFonts: (): Promise => ipcRenderer.invoke('settings:listFonts'), + + previewGhosttyImport: (): Promise => + ipcRenderer.invoke('settings:previewGhosttyImport'), + + previewWarpThemeImport: (source: WarpThemeImportSource): Promise => + ipcRenderer.invoke('settings:previewWarpThemeImport', source), + + onChanged: (callback: (updates: Record) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, updates: Record): void => + callback(updates) + ipcRenderer.on('settings:changed', listener) + return () => ipcRenderer.removeListener('settings:changed', listener) + } +} satisfies PreloadApi['settings'] diff --git a/src/preload/api/shell-bridge.ts b/src/preload/api/shell-bridge.ts new file mode 100644 index 00000000000..21ccda3fd83 --- /dev/null +++ b/src/preload/api/shell-bridge.ts @@ -0,0 +1,42 @@ +import { ipcRenderer } from 'electron' +import type { + ShellOpenExternalEditorRequest, + ShellOpenExternalEditorResult, + ShellOpenLocalPathResult +} from '../../shared/shell-open-types' +import type { PreloadApi } from '../api-types' + +export const shellApi = { + openPath: (path: string): Promise => ipcRenderer.invoke('shell:openPath', path), + + openInFileManager: (path: string): Promise => + ipcRenderer.invoke('shell:openInFileManager', path), + + openInExternalEditor: ( + request: ShellOpenExternalEditorRequest + ): Promise => + ipcRenderer.invoke('shell:openInExternalEditor', request), + + openUrl: (url: string): Promise => ipcRenderer.invoke('shell:openUrl', url), + + openFilePath: (path: string): Promise => ipcRenderer.invoke('shell:openFilePath', path), + + openFileUri: (uri: string): Promise => ipcRenderer.invoke('shell:openFileUri', uri), + + pathExists: (path: string): Promise => ipcRenderer.invoke('shell:pathExists', path), + + pickAttachment: (): Promise => ipcRenderer.invoke('shell:pickAttachment'), + + pickImage: (): Promise => ipcRenderer.invoke('shell:pickImage'), + + pickRepoIconImage: (): Promise<{ dataUrl: string; fileName: string } | null> => + ipcRenderer.invoke('shell:pickRepoIconImage'), + + pickAudio: (): Promise => ipcRenderer.invoke('shell:pickAudio'), + + pickDirectory: (args: { defaultPath?: string }): Promise => + ipcRenderer.invoke('shell:pickDirectory', args), + + copyFile: (args: { srcPath: string; destPath: string }): Promise => + ipcRenderer.invoke('shell:copyFile', args) +} satisfies PreloadApi['shell'] diff --git a/src/preload/api/skills-bridge.ts b/src/preload/api/skills-bridge.ts new file mode 100644 index 00000000000..4bcde9a613c --- /dev/null +++ b/src/preload/api/skills-bridge.ts @@ -0,0 +1,130 @@ +import { ipcRenderer } from 'electron' +import type { + SkillDeletePlan, + SkillDeleteRequest, + SkillDeleteResult +} from '../../shared/skill-delete-contract' +import type { SkillDiscoveryResult, SkillDiscoveryTarget } from '../../shared/skills' +import type { + SkillCloudOwnedShare, + SkillCloudOperation, + SkillCloudPackageDetails +} from '../../shared/skill-cloud-contract' +import type { + SkillBundleInstallPreviewInput, + SkillBundleInstallPreviewOperation, + SkillBundlePackageVersionInstallInput, + SkillBundleShareInstallInput, + SkillBundleShareInstallOperation, + SkillInstallPreviewInput, + SkillInstallPreviewOperation, + ManagedSkillInstallListOperation, + SkillPackageVersionInstallInput, + SkillRemoveInput, + SkillRemoveOperation, + SkillShareInstallInput, + SkillShareInstallOperation, + SkillInstallCancelInput, + SkillInstallProgress, + SkillSharePreview, + SkillShareProgress, + SkillSharePublishInput, + SkillSharePublishOperation, + SkillShareResolvedOperation +} from '../../shared/skill-sharing-contract' +import type { + SkillFreshnessInventory, + SkillUpdateRun, + SkillUpdateStartResult +} from '../../shared/skill-freshness' +import type { PreloadApi } from '../api-types' + +export const skillsApi = { + discover: (target?: SkillDiscoveryTarget): Promise => + ipcRenderer.invoke('skills:discover', target), + freshnessInventory: (): Promise => + ipcRenderer.invoke('skills:freshnessInventory'), + startUpdateRun: (names: string[]): Promise => + ipcRenderer.invoke('skills:startUpdateRun', names), + cancelUpdateRun: (): Promise => ipcRenderer.invoke('skills:cancelUpdateRun'), + acknowledgeUpdateRun: (): Promise => ipcRenderer.invoke('skills:acknowledgeUpdateRun'), + getUpdateRun: (): Promise => ipcRenderer.invoke('skills:getUpdateRun'), + prepareShare: (input: { + skillIds: string[] + bundleName: string + target?: SkillDiscoveryTarget + packageId?: string + }): Promise => ipcRenderer.invoke('skills:prepareShare', input), + publishShare: (input: SkillSharePublishInput): Promise => + ipcRenderer.invoke('skills:publishShare', input), + cancelShare: (preparationId: string): Promise => + ipcRenderer.invoke('skills:cancelShare', preparationId), + releaseShare: (preparationId: string): Promise => + ipcRenderer.invoke('skills:releaseShare', preparationId), + resolveShare: (shareId: string): Promise => + ipcRenderer.invoke('skills:resolveShare', shareId), + installShare: (input: SkillShareInstallInput): Promise => + ipcRenderer.invoke('skills:installShare', input), + installBundleShare: ( + input: SkillBundleShareInstallInput + ): Promise => + ipcRenderer.invoke('skills:installBundleShare', input), + installBundlePackageVersion: ( + input: SkillBundlePackageVersionInstallInput + ): Promise => + ipcRenderer.invoke('skills:installBundlePackageVersion', input), + installPackageVersion: ( + input: SkillPackageVersionInstallInput + ): Promise => + ipcRenderer.invoke('skills:installPackageVersion', input), + cancelInstall: (input: SkillInstallCancelInput): Promise<{ cancelled: boolean }> => + ipcRenderer.invoke('skills:cancelInstall', input), + previewInstall: (input: SkillInstallPreviewInput): Promise => + ipcRenderer.invoke('skills:previewInstall', input), + previewBundleInstall: ( + input: SkillBundleInstallPreviewInput + ): Promise => + ipcRenderer.invoke('skills:previewBundleInstall', input), + removeInstall: (input: SkillRemoveInput): Promise => + ipcRenderer.invoke('skills:removeInstall', input), + // Desktop always registers the delete IPC handlers in its own main process. + deleteSupported: (): Promise => Promise.resolve(true), + previewDelete: (request: SkillDeleteRequest): Promise => + ipcRenderer.invoke('skills:previewDelete', request), + delete: (request: SkillDeleteRequest): Promise => + ipcRenderer.invoke('skills:delete', request), + listManagedInstalls: (environmentId?: string): Promise => + ipcRenderer.invoke('skills:listManagedInstalls', environmentId), + getPackage: (packageId: string): Promise> => + ipcRenderer.invoke('skills:getPackage', packageId), + listOwnedShares: (): Promise> => + ipcRenderer.invoke('skills:listOwnedShares'), + revokeShare: (shareId: string): Promise> => + ipcRenderer.invoke('skills:revokeShare', shareId), + deletePackageVersion: (input: { + packageId: string + versionId: string + }): Promise> => + ipcRenderer.invoke('skills:deletePackageVersion', input), + deletePackage: (packageId: string): Promise> => + ipcRenderer.invoke('skills:deletePackage', packageId), + listWslDistros: (environmentId?: string): Promise => + ipcRenderer.invoke('skills:listWslDistros', environmentId), + onInstallProgress: (callback: (progress: SkillInstallProgress) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, progress: SkillInstallProgress): void => + callback(progress) + ipcRenderer.on('skills:installProgress', listener) + return () => ipcRenderer.removeListener('skills:installProgress', listener) + }, + onShareProgress: (callback: (progress: SkillShareProgress) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, progress: SkillShareProgress): void => + callback(progress) + ipcRenderer.on('skills:shareProgress', listener) + return () => ipcRenderer.removeListener('skills:shareProgress', listener) + }, + onUpdateRun: (callback: (run: SkillUpdateRun) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, run: SkillUpdateRun): void => callback(run) + ipcRenderer.on('skills:updateRun', listener) + return () => ipcRenderer.removeListener('skills:updateRun', listener) + } +} satisfies PreloadApi['skills'] diff --git a/src/preload/api/sparse-presets-bridge.ts b/src/preload/api/sparse-presets-bridge.ts new file mode 100644 index 00000000000..91676740e6e --- /dev/null +++ b/src/preload/api/sparse-presets-bridge.ts @@ -0,0 +1,16 @@ +import { ipcRenderer } from 'electron' +import type { PreloadApi } from '../api-types' + +export const sparsePresetsApi = { + list: (args) => ipcRenderer.invoke('sparsePresets:list', args), + + save: (args) => ipcRenderer.invoke('sparsePresets:save', args), + + remove: (args) => ipcRenderer.invoke('sparsePresets:remove', args), + + onChanged: (callback: (data: { repoId: string }) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, data: { repoId: string }) => callback(data) + ipcRenderer.on('sparsePresets:changed', listener) + return () => ipcRenderer.removeListener('sparsePresets:changed', listener) + } +} satisfies PreloadApi['sparsePresets'] diff --git a/src/preload/api/speech-bridge.ts b/src/preload/api/speech-bridge.ts new file mode 100644 index 00000000000..dbd7e0d26ab --- /dev/null +++ b/src/preload/api/speech-bridge.ts @@ -0,0 +1,82 @@ +import { ipcRenderer } from 'electron' +import type { + SpeechErrorEvent, + SpeechLifecycleEvent, + SpeechModelManifest, + SpeechModelState, + SpeechTranscriptEvent +} from '../../shared/speech-types' +import type { PreloadApi } from '../api-types' + +export const speechApi = { + getCatalog: (): Promise => ipcRenderer.invoke('speech:getCatalog'), + getModelStates: (): Promise => ipcRenderer.invoke('speech:getModelStates'), + getOpenAiApiKeyStatus: (): Promise<{ configured: boolean }> => + ipcRenderer.invoke('speech:getOpenAiApiKeyStatus'), + saveOpenAiApiKey: (apiKey: string): Promise<{ configured: boolean }> => + ipcRenderer.invoke('speech:saveOpenAiApiKey', apiKey), + clearOpenAiApiKey: (): Promise<{ configured: boolean }> => + ipcRenderer.invoke('speech:clearOpenAiApiKey'), + downloadModel: (modelId: string): Promise => + ipcRenderer.invoke('speech:downloadModel', modelId), + cancelDownload: (modelId: string): Promise => + ipcRenderer.invoke('speech:cancelDownload', modelId), + deleteModel: (modelId: string): Promise => + ipcRenderer.invoke('speech:deleteModel', modelId), + startDictation: ( + modelId: string, + hotwords: string[] | undefined, + sessionId: string + ): Promise => ipcRenderer.invoke('speech:startDictation', modelId, hotwords, sessionId), + feedAudio: (samples: Float32Array, sampleRate: number, sessionId = 'desktop'): Promise => + // Why: Float32Array is zeroed crossing the contextBridge/IPC boundary; wrap in a Buffer to preserve bytes. + ipcRenderer.invoke( + 'speech:feedAudio', + Buffer.from(samples.buffer, samples.byteOffset, samples.byteLength), + sampleRate, + sessionId + ), + stopDictation: (sessionId = 'desktop'): Promise => + ipcRenderer.invoke('speech:stopDictation', sessionId), + + onPartialTranscript: (callback: (data: SpeechTranscriptEvent) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, data: SpeechTranscriptEvent): void => + callback(data) + ipcRenderer.on('speech:partial', listener) + return () => ipcRenderer.removeListener('speech:partial', listener) + }, + onFinalTranscript: (callback: (data: SpeechTranscriptEvent) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, data: SpeechTranscriptEvent): void => + callback(data) + ipcRenderer.on('speech:final', listener) + return () => ipcRenderer.removeListener('speech:final', listener) + }, + onDownloadProgress: ( + callback: (data: { modelId: string; progress: number }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { modelId: string; progress: number } + ): void => callback(data) + ipcRenderer.on('speech:downloadProgress', listener) + return () => ipcRenderer.removeListener('speech:downloadProgress', listener) + }, + onReady: (callback: (data: SpeechLifecycleEvent) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, data: SpeechLifecycleEvent): void => + callback(data) + ipcRenderer.on('speech:ready', listener) + return () => ipcRenderer.removeListener('speech:ready', listener) + }, + onStopped: (callback: (data: SpeechLifecycleEvent) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, data: SpeechLifecycleEvent): void => + callback(data) + ipcRenderer.on('speech:stopped', listener) + return () => ipcRenderer.removeListener('speech:stopped', listener) + }, + onError: (callback: (data: SpeechErrorEvent) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, data: SpeechErrorEvent): void => + callback(data) + ipcRenderer.on('speech:error', listener) + return () => ipcRenderer.removeListener('speech:error', listener) + } +} satisfies PreloadApi['speech'] diff --git a/src/preload/api/ssh-bridge.ts b/src/preload/api/ssh-bridge.ts new file mode 100644 index 00000000000..b0f7b89ec3d --- /dev/null +++ b/src/preload/api/ssh-bridge.ts @@ -0,0 +1,184 @@ +import { ipcRenderer } from 'electron' +import type { + SshConnectionState, + SshConfigHostListArgs, + SshConfigHostListResult, + SshConfigHostResolution, + SshConfigImportResult, + SshTargetAddResult, + SshTargetCreateInput, + SshTarget, + SshTargetUpdateInput, + PortForwardEntry, + EnrichedDetectedPort +} from '../../shared/ssh-types' +import { + admitSshConnectionStateForAuthorityReconciliation, + admitSshDetectedPorts +} from '../../shared/ssh-retained-payload-admission' +import type { FilesystemPathFlavor } from '../../shared/filesystem-entry-types' +import type { PreloadApi } from '../api-types' + +export const sshApi = { + listTargets: (): Promise => ipcRenderer.invoke('ssh:listTargets'), + + listRemovedTargetLabels: (): Promise> => + ipcRenderer.invoke('ssh:listRemovedTargetLabels'), + + addTarget: (args: { target: SshTargetCreateInput }): Promise => + ipcRenderer.invoke('ssh:addTarget', args), + + updateTarget: (args: { id: string; updates: SshTargetUpdateInput }): Promise => + ipcRenderer.invoke('ssh:updateTarget', args), + + removeTarget: (args: { id: string }): Promise => + ipcRenderer.invoke('ssh:removeTarget', args), + + importConfig: (args?: { reAdopt?: boolean }): Promise => + ipcRenderer.invoke('ssh:importConfig', args), + + listConfigHosts: (args?: SshConfigHostListArgs): Promise => + ipcRenderer.invoke('ssh:listConfigHosts', args), + + resolveConfigHost: (args: { alias: string }): Promise => + ipcRenderer.invoke('ssh:resolveConfigHost', args), + + connect: async (args: { targetId: string }): Promise => { + const state: unknown = await ipcRenderer.invoke('ssh:connect', args) + return state ? admitSshConnectionStateForAuthorityReconciliation(state, args.targetId) : null + }, + + disconnect: (args: { targetId: string }): Promise => + ipcRenderer.invoke('ssh:disconnect', args), + + terminateSessions: (args: { targetId: string }): Promise => + ipcRenderer.invoke('ssh:terminateSessions', args), + + resetRelay: (args: { targetId: string }): Promise => + ipcRenderer.invoke('ssh:resetRelay', args), + + getState: async (args: { targetId: string }): Promise => { + const state: unknown = await ipcRenderer.invoke('ssh:getState', args) + return state ? admitSshConnectionStateForAuthorityReconciliation(state, args.targetId) : null + }, + + needsPassphrasePrompt: (args: { targetId: string }): Promise => + ipcRenderer.invoke('ssh:needsPassphrasePrompt', args), + + testConnection: async (args: { + targetId: string + }): Promise<{ success: boolean; error?: string; state?: SshConnectionState }> => { + const result: { success: boolean; error?: string; state?: unknown } = await ipcRenderer.invoke( + 'ssh:testConnection', + args + ) + const state = result.state + ? admitSshConnectionStateForAuthorityReconciliation(result.state, args.targetId) + : null + return { ...result, ...(state ? { state } : { state: undefined }) } + }, + + onStateChanged: ( + callback: (data: { targetId: string; state: SshConnectionState }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { targetId: string; state: unknown } + ): void => { + const state = admitSshConnectionStateForAuthorityReconciliation(data.state, data.targetId) + if (state) { + callback({ targetId: data.targetId, state }) + } + } + ipcRenderer.on('ssh:state-changed', listener) + return () => ipcRenderer.removeListener('ssh:state-changed', listener) + }, + + addPortForward: (args: { + targetId: string + localPort: number + remoteHost: string + remotePort: number + label?: string + }): Promise => ipcRenderer.invoke('ssh:addPortForward', args), + + updatePortForward: (args: { + id: string + targetId: string + localPort: number + remoteHost: string + remotePort: number + label?: string + }): Promise => ipcRenderer.invoke('ssh:updatePortForward', args), + + removePortForward: (args: { id: string }): Promise => + ipcRenderer.invoke('ssh:removePortForward', args), + + listPortForwards: (args?: { targetId?: string }): Promise => + ipcRenderer.invoke('ssh:listPortForwards', args), + + listDetectedPorts: async (args: { targetId: string }): Promise => + admitSshDetectedPorts(await ipcRenderer.invoke('ssh:listDetectedPorts', args)), + + onPortForwardsChanged: ( + callback: (data: { targetId: string; forwards: PortForwardEntry[] }) => void + ): (() => void) => { + const handler = ( + _event: Electron.IpcRendererEvent, + data: { targetId: string; forwards: PortForwardEntry[] } + ) => callback(data) + ipcRenderer.on('ssh:port-forwards-changed', handler) + return () => ipcRenderer.removeListener('ssh:port-forwards-changed', handler) + }, + + onDetectedPortsChanged: ( + callback: (data: { targetId: string; ports: EnrichedDetectedPort[] }) => void + ): (() => void) => { + const handler = ( + _event: Electron.IpcRendererEvent, + data: { targetId: string; ports: unknown } + ) => callback({ targetId: data.targetId, ports: admitSshDetectedPorts(data.ports) }) + ipcRenderer.on('ssh:detected-ports-changed', handler) + return () => ipcRenderer.removeListener('ssh:detected-ports-changed', handler) + }, + + browseDir: (args: { + targetId: string + dirPath: string + }): Promise<{ + entries: { name: string; isDirectory: boolean }[] + resolvedPath: string + pathFlavor: FilesystemPathFlavor + }> => ipcRenderer.invoke('ssh:browseDir', args), + + onCredentialRequest: ( + callback: (data: { + requestId: string + targetId: string + kind: 'passphrase' | 'password' | 'keyboard-interactive' + detail: string + }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { + requestId: string + targetId: string + kind: 'passphrase' | 'password' | 'keyboard-interactive' + detail: string + } + ) => callback(data) + ipcRenderer.on('ssh:credential-request', listener) + return () => ipcRenderer.removeListener('ssh:credential-request', listener) + }, + + onCredentialResolved: (callback: (data: { requestId: string }) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, data: { requestId: string }) => + callback(data) + ipcRenderer.on('ssh:credential-resolved', listener) + return () => ipcRenderer.removeListener('ssh:credential-resolved', listener) + }, + + submitCredential: (args: { requestId: string; value: string | null }): Promise => + ipcRenderer.invoke('ssh:submitCredential', args) +} satisfies PreloadApi['ssh'] diff --git a/src/preload/api/star-nag-bridge.ts b/src/preload/api/star-nag-bridge.ts new file mode 100644 index 00000000000..49e56e4aa91 --- /dev/null +++ b/src/preload/api/star-nag-bridge.ts @@ -0,0 +1,31 @@ +import { ipcRenderer } from 'electron' +import type { PreloadApi } from '../api-types' + +export const starNagApi = { + onShow: ( + callback: (payload?: { mode?: 'gh' | 'web'; surface?: 'card' | 'toast' }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + payload?: { mode?: 'gh' | 'web'; surface?: 'card' | 'toast' } + ): void => callback(payload) + ipcRenderer.on('star-nag:show', listener) + return () => ipcRenderer.removeListener('star-nag:show', listener) + }, + onHide: (callback: () => void): (() => void) => { + const listener = (): void => callback() + ipcRenderer.on('star-nag:hide', listener) + return () => ipcRenderer.removeListener('star-nag:hide', listener) + }, + dismiss: (): Promise => ipcRenderer.invoke('star-nag:dismiss'), + later: (): Promise => ipcRenderer.invoke('star-nag:later'), + complete: (): Promise => ipcRenderer.invoke('star-nag:complete'), + disable: (): Promise => ipcRenderer.invoke('star-nag:disable'), + openWeb: (): Promise => ipcRenderer.invoke('star-nag:openWeb'), + starOrca: (): Promise => ipcRenderer.invoke('star-nag:starOrca'), + forceShow: (): Promise => ipcRenderer.invoke('star-nag:forceShow'), + agentValueMoment: (): Promise<{ status: 'ready'; mode: 'gh' | 'web' } | { status: 'skipped' }> => + ipcRenderer.invoke('star-nag:agentValueMoment'), + showAgentValueMoment: (): Promise => ipcRenderer.invoke('star-nag:showAgentValueMoment'), + onboardingCompleted: (): Promise => ipcRenderer.invoke('star-nag:onboardingCompleted') +} satisfies PreloadApi['starNag'] diff --git a/src/preload/api/stats-bridge.ts b/src/preload/api/stats-bridge.ts new file mode 100644 index 00000000000..f435b9980f5 --- /dev/null +++ b/src/preload/api/stats-bridge.ts @@ -0,0 +1,11 @@ +import { ipcRenderer } from 'electron' +import type { PreloadApi } from '../api-types' + +export const statsApi = { + getSummary: (): Promise<{ + totalAgentsSpawned: number + totalPRsCreated: number + totalAgentTimeMs: number + firstEventAt: number | null + }> => ipcRenderer.invoke('stats:summary') +} satisfies PreloadApi['stats'] diff --git a/src/preload/api/terminal-preview-bridge.ts b/src/preload/api/terminal-preview-bridge.ts new file mode 100644 index 00000000000..3bd94d4998b --- /dev/null +++ b/src/preload/api/terminal-preview-bridge.ts @@ -0,0 +1,34 @@ +import { ipcRenderer } from 'electron' +import type { + TerminalPreviewConnectResult, + TerminalPreviewDataPayload +} from '../../shared/terminal-preview' +import type { PreloadApi } from '../api-types' + +export const terminalPreviewApi = { + connect: ( + ptyId: string, + opts?: { scrollbackRows?: number } + ): Promise => + ipcRenderer.invoke('terminalPreview:connect', { ptyId, opts }), + input: (ptyId: string, data: string): Promise => + ipcRenderer.invoke('terminalPreview:input', { ptyId, data }), + fit: ( + ptyId: string, + cols: number, + rows: number + ): Promise<{ cols: number; rows: number } | null> => + ipcRenderer.invoke('terminalPreview:fit', { ptyId, cols, rows }), + ack: (ptyId: string, bytes: number): Promise => + ipcRenderer.invoke('terminalPreview:ack', { ptyId, bytes }), + unsubscribe: (ptyId: string): Promise => + ipcRenderer.invoke('terminalPreview:unsubscribe', { ptyId }), + onData: (callback: (payload: TerminalPreviewDataPayload) => void): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + payload: TerminalPreviewDataPayload + ): void => callback(payload) + ipcRenderer.on('terminalPreview:data', listener) + return () => ipcRenderer.removeListener('terminalPreview:data', listener) + } +} satisfies PreloadApi['terminalPreview'] diff --git a/src/preload/api/ui-bridge-clipboard-and-window-controls.ts b/src/preload/api/ui-bridge-clipboard-and-window-controls.ts new file mode 100644 index 00000000000..fdad19c2944 --- /dev/null +++ b/src/preload/api/ui-bridge-clipboard-and-window-controls.ts @@ -0,0 +1,199 @@ +import { ipcRenderer, webFrame } from 'electron' +import type { + RuntimeMobileMarkdownRequest, + RuntimeMobileMarkdownResponse +} from '../../shared/mobile-markdown-document' +import { + richMarkdownContextMenuCommandChannel, + richMarkdownContextMenuTargetChannel, + type RichMarkdownContextMenuCommandPayload, + type RichMarkdownContextMenuTableTarget +} from '../../shared/rich-markdown-context-menu' +import type { NativeFileDropPayload } from '../../shared/native-file-drop' +import type { ReadClipboardTextOptions } from '../../shared/clipboard-text' +import { subscribeNativeFileDrop } from '../preload-runtime-support' +import type { PreloadApi } from '../api-types' + +export const uiClipboardAndWindowControlsApi = { + onOpenDiffFromMobile: ( + callback: (data: { + worktreeId: string + filePath: string + relativePath: string + staged: boolean + runtimeEnvironmentId?: string + }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { + worktreeId: string + filePath: string + relativePath: string + staged: boolean + runtimeEnvironmentId?: string + } + ) => callback(data) + ipcRenderer.on('ui:openDiffFromMobile', listener) + return () => ipcRenderer.removeListener('ui:openDiffFromMobile', listener) + }, + onMobileMarkdownRequest: ( + callback: (request: RuntimeMobileMarkdownRequest) => void + ): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, request: RuntimeMobileMarkdownRequest) => + callback(request) + ipcRenderer.on('ui:mobileMarkdownRequest', listener) + return () => ipcRenderer.removeListener('ui:mobileMarkdownRequest', listener) + }, + respondMobileMarkdownRequest: (response: RuntimeMobileMarkdownResponse): void => { + ipcRenderer.send('ui:mobileMarkdownResponse', response) + }, + onCloseTerminal: ( + callback: (data: { tabId: string; paneRuntimeId?: number }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { tabId: string; paneRuntimeId?: number } + ) => callback(data) + ipcRenderer.on('ui:closeTerminal', listener) + return () => ipcRenderer.removeListener('ui:closeTerminal', listener) + }, + onTerminalTabCloseRequest: (callback) => { + const listener = (_event: Electron.IpcRendererEvent, request: Parameters[0]) => + callback(request) + ipcRenderer.on('ui:terminalTabCloseRequest', listener) + return () => ipcRenderer.removeListener('ui:terminalTabCloseRequest', listener) + }, + respondTerminalTabClose: (response) => { + ipcRenderer.send('ui:terminalTabCloseResponse', response) + }, + onSleepWorktree: (callback: (data: { worktreeId: string }) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, data: { worktreeId: string }) => + callback(data) + ipcRenderer.on('ui:sleepWorktree', listener) + return () => ipcRenderer.removeListener('ui:sleepWorktree', listener) + }, + onResumeSleepingAgents: (callback: (data: { worktreeId: string }) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, data: { worktreeId: string }) => + callback(data) + ipcRenderer.on('ui:resumeSleepingAgents', listener) + return () => ipcRenderer.removeListener('ui:resumeSleepingAgents', listener) + }, + onTerminalZoom: (callback: (direction: 'in' | 'out' | 'reset') => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, direction: 'in' | 'out' | 'reset') => + callback(direction) + ipcRenderer.on('terminal:zoom', listener) + return () => ipcRenderer.removeListener('terminal:zoom', listener) + }, + readClipboardText: (options?: ReadClipboardTextOptions): Promise => + ipcRenderer.invoke('clipboard:readText', options), + readSelectionClipboardText: (options?: ReadClipboardTextOptions): Promise => + ipcRenderer.invoke('clipboard:readSelectionText', options), + saveClipboardImageAsTempFile: (args?: { + connectionId?: string | null + runtimeEnvironmentId?: string | null + }): Promise => ipcRenderer.invoke('clipboard:saveImageAsTempFile', args), + writeClipboardText: (text: string): Promise => + ipcRenderer.invoke('clipboard:writeText', text), + writeTerminalClipboardText: (text: string): Promise => + ipcRenderer.invoke('clipboard:writeTerminalText', text), + writeSelectionClipboardText: (text: string): Promise => + ipcRenderer.invoke('clipboard:writeSelectionText', text), + writeClipboardImage: (dataUrl: string): Promise => + ipcRenderer.invoke('clipboard:writeImage', dataUrl), + performNativePaste: (options?: { mode?: 'paste' | 'paste-and-match-style' }): void => { + ipcRenderer.send('ui:performNativePaste', { + mode: options?.mode === 'paste-and-match-style' ? 'paste-and-match-style' : 'paste' + }) + }, + performNativeSelectionAction: (action: 'copy' | 'select-all'): void => { + ipcRenderer.send('ui:performNativeSelectionAction', action) + }, + writeClipboardFile: ( + args: + | { + filePath: string + connectionId?: string | null + } + | string + ): Promise<{ ok: boolean; reason?: string }> => ipcRenderer.invoke('clipboard:writeFile', args), + onFileDrop: (callback: (data: NativeFileDropPayload) => void): (() => void) => + subscribeNativeFileDrop(callback), + getZoomLevel: (): number => webFrame.getZoomLevel(), + setZoomLevel: (level: number): void => webFrame.setZoomLevel(level), + syncTrafficLights: (zoomFactor: number): void => + ipcRenderer.send('ui:sync-traffic-lights', zoomFactor), + setMarkdownEditorFocused: (focused: boolean): void => { + ipcRenderer.send('ui:setMarkdownEditorFocused', focused) + }, + setRichMarkdownContextMenuTarget: (target: RichMarkdownContextMenuTableTarget | null): void => { + ipcRenderer.send(richMarkdownContextMenuTargetChannel, target) + }, + setTerminalInputFocused: (focused: boolean): void => { + ipcRenderer.send('ui:setTerminalInputFocused', focused) + }, + setFloatingFocus: (state: { panelFocused: boolean; terminalFocused: boolean }): void => { + ipcRenderer.send('ui:setFloatingFocus', state) + }, + setShortcutRecorderFocused: (focused: boolean): void => { + ipcRenderer.send('ui:setShortcutRecorderFocused', focused) + }, + onRichMarkdownContextCommand: ( + callback: (payload: RichMarkdownContextMenuCommandPayload) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + payload: RichMarkdownContextMenuCommandPayload + ) => callback(payload) + ipcRenderer.on(richMarkdownContextMenuCommandChannel, listener) + return () => ipcRenderer.removeListener(richMarkdownContextMenuCommandChannel, listener) + }, + onFullscreenChanged: (callback: (isFullScreen: boolean) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, isFullScreen: boolean) => + callback(isFullScreen) + ipcRenderer.on('window:fullscreen-changed', listener) + return () => ipcRenderer.removeListener('window:fullscreen-changed', listener) + }, + onSystemResumed: (callback: () => void): (() => void) => { + const listener = () => callback() + ipcRenderer.on('system:resumed', listener) + return () => ipcRenderer.removeListener('system:resumed', listener) + }, + minimize: (): void => { + ipcRenderer.send('window:minimize') + }, + maximize: (): void => { + ipcRenderer.send('window:maximize') + }, + isMaximized: (): Promise => ipcRenderer.invoke('window:isMaximized'), + onMaximizeChanged: (callback: (isMaximized: boolean) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, isMaximized: boolean) => + callback(isMaximized) + ipcRenderer.on('window:maximize-changed', listener) + return () => ipcRenderer.removeListener('window:maximize-changed', listener) + }, + requestClose: (): void => { + ipcRenderer.send('window:request-close') + }, + popupMenu: (): void => { + ipcRenderer.send('menu:popup') + }, + onWindowCloseRequested: (callback: (data: { isQuitting: boolean }) => void): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { isQuitting: boolean; requestId?: number } + ): void => { + // Why: main cannot reach will-quit while a frozen renderer owns the window close handshake. + ipcRenderer.send('window:close-request-received', data?.requestId) + callback({ isQuitting: data?.isQuitting ?? false }) + } + ipcRenderer.on('window:close-requested', listener) + return () => ipcRenderer.removeListener('window:close-requested', listener) + }, + confirmWindowClose: (): void => { + ipcRenderer.send('window:confirm-close') + }, + notifyWindowRevealed: (): void => { + ipcRenderer.send('ui:window-revealed') + } +} satisfies Partial diff --git a/src/preload/api/ui-bridge-state-and-menu-commands.ts b/src/preload/api/ui-bridge-state-and-menu-commands.ts new file mode 100644 index 00000000000..246cebb1613 --- /dev/null +++ b/src/preload/api/ui-bridge-state-and-menu-commands.ts @@ -0,0 +1,177 @@ +import type { MarkdownDocument } from '../../shared/filesystem-entry-types' +import { ipcRenderer } from 'electron' +import type { PersistedUIState } from '../../shared/persisted-ui-state-types' +import type { KeybindingActionId } from '../../shared/keybindings' +import type { PreloadApi } from '../api-types' + +export const uiStateAndMenuCommandsApi = { + get: () => ipcRenderer.invoke('ui:get'), + set: (args) => ipcRenderer.invoke('ui:set', args), + setWithAck: (args) => ipcRenderer.invoke('ui:set', args), + recordFeatureInteraction: (id) => ipcRenderer.invoke('ui:recordFeatureInteraction', id), + onStateChanged: (callback: (ui: PersistedUIState) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, ui: PersistedUIState): void => callback(ui) + ipcRenderer.on('ui:stateChanged', listener) + return () => ipcRenderer.removeListener('ui:stateChanged', listener) + }, + onOpenSettings: (callback: () => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent) => callback() + ipcRenderer.on('ui:openSettings', listener) + return () => ipcRenderer.removeListener('ui:openSettings', listener) + }, + consumePendingOpenSettings: (): Promise => + ipcRenderer.invoke('ui:consumePendingOpenSettings'), + onOpenSkillShare: (callback: (shareId: string) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, shareId: string): void => callback(shareId) + ipcRenderer.on('ui:openSkillShare', listener) + return () => ipcRenderer.removeListener('ui:openSkillShare', listener) + }, + consumePendingSkillShare: (): Promise => + ipcRenderer.invoke('ui:consumePendingSkillShare'), + onOpenMarkdownFiles: (callback: (documents: MarkdownDocument[]) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, documents: MarkdownDocument[]): void => + callback(documents) + ipcRenderer.on('ui:openMarkdownFiles', listener) + return () => ipcRenderer.removeListener('ui:openMarkdownFiles', listener) + }, + consumePendingMarkdownFileOpens: (): Promise => + ipcRenderer.invoke('ui:consumePendingMarkdownFileOpens'), + onOpenSetupGuide: (callback: () => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent) => callback() + ipcRenderer.on('ui:openSetupGuide', listener) + return () => ipcRenderer.removeListener('ui:openSetupGuide', listener) + }, + onOpenFeatureTour: (callback: () => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent) => callback() + ipcRenderer.on('ui:openFeatureTour', listener) + return () => ipcRenderer.removeListener('ui:openFeatureTour', listener) + }, + onOpenCrashReport: (callback: () => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent) => callback() + ipcRenderer.on('ui:openCrashReport', listener) + return () => ipcRenderer.removeListener('ui:openCrashReport', listener) + }, + onToggleLeftSidebar: (callback: () => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent) => callback() + ipcRenderer.on('ui:toggleLeftSidebar', listener) + return () => ipcRenderer.removeListener('ui:toggleLeftSidebar', listener) + }, + onToggleRightSidebar: (callback: () => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent) => callback() + ipcRenderer.on('ui:toggleRightSidebar', listener) + return () => ipcRenderer.removeListener('ui:toggleRightSidebar', listener) + }, + onToggleWorktreePalette: (callback: () => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent) => callback() + ipcRenderer.on('ui:toggleWorktreePalette', listener) + return () => ipcRenderer.removeListener('ui:toggleWorktreePalette', listener) + }, + onToggleFloatingTerminal: (callback: () => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent) => callback() + ipcRenderer.on('ui:toggleFloatingTerminal', listener) + return () => ipcRenderer.removeListener('ui:toggleFloatingTerminal', listener) + }, + onTerminalShortcutCaptured: ( + callback: (data: { actionId: KeybindingActionId }) => void + ): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, data: { actionId: KeybindingActionId }) => + callback(data) + ipcRenderer.on('ui:terminalShortcutCaptured', listener) + return () => ipcRenderer.removeListener('ui:terminalShortcutCaptured', listener) + }, + onOpenQuickOpen: (callback: () => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent) => callback() + ipcRenderer.on('ui:openQuickOpen', listener) + return () => ipcRenderer.removeListener('ui:openQuickOpen', listener) + }, + onToggleQuickCommandsMenu: (callback: () => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent) => callback() + ipcRenderer.on('ui:toggleQuickCommandsMenu', listener) + return () => ipcRenderer.removeListener('ui:toggleQuickCommandsMenu', listener) + }, + onOpenNewWorkspace: (callback: () => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent) => callback() + ipcRenderer.on('ui:openNewWorkspace', listener) + return () => ipcRenderer.removeListener('ui:openNewWorkspace', listener) + }, + onDeleteCurrentWorkspace: (callback: () => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent) => callback() + ipcRenderer.on('ui:deleteCurrentWorkspace', listener) + return () => ipcRenderer.removeListener('ui:deleteCurrentWorkspace', listener) + }, + onOpenWorkspaceBoard: (callback: () => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent) => callback() + ipcRenderer.on('ui:openWorkspaceBoard', listener) + return () => ipcRenderer.removeListener('ui:openWorkspaceBoard', listener) + }, + onOpenTasks: (callback: () => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent) => callback() + ipcRenderer.on('ui:openTasks', listener) + return () => ipcRenderer.removeListener('ui:openTasks', listener) + }, + onToggleAgentDashboard: (callback: () => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent) => callback() + ipcRenderer.on('ui:toggleAgentDashboard', listener) + return () => ipcRenderer.removeListener('ui:toggleAgentDashboard', listener) + }, + onJumpToWorktreeIndex: (callback: (index: number) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, index: number) => callback(index) + ipcRenderer.on('ui:jumpToWorktreeIndex', listener) + return () => ipcRenderer.removeListener('ui:jumpToWorktreeIndex', listener) + }, + onJumpToTabIndex: (callback: (index: number) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, index: number) => callback(index) + ipcRenderer.on('ui:jumpToTabIndex', listener) + return () => ipcRenderer.removeListener('ui:jumpToTabIndex', listener) + }, + onWorktreeHistoryNavigate: (callback: (direction: 'back' | 'forward') => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, direction: 'back' | 'forward') => + callback(direction) + ipcRenderer.on('ui:worktreeHistoryNavigate', listener) + return () => ipcRenderer.removeListener('ui:worktreeHistoryNavigate', listener) + }, + onNewBrowserTab: (callback: () => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent) => callback() + ipcRenderer.on('ui:newBrowserTab', listener) + return () => ipcRenderer.removeListener('ui:newBrowserTab', listener) + }, + onNewMarkdownTab: (callback: () => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent) => callback() + ipcRenderer.on('ui:newMarkdownTab', listener) + return () => ipcRenderer.removeListener('ui:newMarkdownTab', listener) + }, + onNewSimulatorTab: (callback: () => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent) => callback() + ipcRenderer.on('ui:newSimulatorTab', listener) + return () => ipcRenderer.removeListener('ui:newSimulatorTab', listener) + }, + onRequestTabCreate: ( + callback: (data: { + requestId: string + url: string + worktreeId?: string + browserPageId?: string + sessionProfileId?: string | null + sessionPartition?: string + activate?: boolean + }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { + requestId: string + url: string + worktreeId?: string + browserPageId?: string + sessionProfileId?: string | null + sessionPartition?: string + activate?: boolean + } + ) => callback(data) + ipcRenderer.on('browser:requestTabCreate', listener) + return () => ipcRenderer.removeListener('browser:requestTabCreate', listener) + }, + replyTabCreate: (reply: { requestId: string; browserPageId?: string; error?: string }): void => { + ipcRenderer.send('browser:tabCreateReply', reply) + } +} satisfies Partial diff --git a/src/preload/api/ui-bridge-tab-and-browser-commands.ts b/src/preload/api/ui-bridge-tab-and-browser-commands.ts new file mode 100644 index 00000000000..d275367b398 --- /dev/null +++ b/src/preload/api/ui-bridge-tab-and-browser-commands.ts @@ -0,0 +1,204 @@ +import { ipcRenderer } from 'electron' +import { admitCloseActiveTabPayload } from '../close-active-tab-payload-admission' +import type { CloseActiveTabPayload } from '../api/ui-command-event-api' +import type { + WorktreeDefaultTabsLaunch, + WorktreeSetupLaunch +} from '../../shared/worktree/launch-types' +import { browserFindSubscriptions } from '../preload-runtime-support' +import type { PreloadApi } from '../api-types' + +export const uiTabAndBrowserCommandsApi = { + onRequestTabSetProfile: ( + callback: (data: { + requestId: string + browserPageId: string + profileId: string + sessionPartition?: string + }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { + requestId: string + browserPageId: string + profileId: string + sessionPartition?: string + } + ) => callback(data) + ipcRenderer.on('browser:requestTabSetProfile', listener) + return () => ipcRenderer.removeListener('browser:requestTabSetProfile', listener) + }, + replyTabSetProfile: (reply: { requestId: string; error?: string }): void => { + ipcRenderer.send('browser:tabSetProfileReply', reply) + }, + onRequestTabClose: ( + callback: (data: { requestId: string; tabId: string | null; worktreeId?: string }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { requestId: string; tabId: string | null; worktreeId?: string } + ) => callback(data) + ipcRenderer.on('browser:requestTabClose', listener) + return () => ipcRenderer.removeListener('browser:requestTabClose', listener) + }, + replyTabClose: (reply: { + requestId: string + error?: string + code?: 'browser_tab_not_found' + }): void => { + ipcRenderer.send('browser:tabCloseReply', reply) + }, + onNewTerminalTab: (callback: () => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent) => callback() + ipcRenderer.on('ui:newTerminalTab', listener) + return () => ipcRenderer.removeListener('ui:newTerminalTab', listener) + }, + onFocusBrowserAddressBar: (callback: () => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent) => callback() + ipcRenderer.on('ui:focusBrowserAddressBar', listener) + return () => ipcRenderer.removeListener('ui:focusBrowserAddressBar', listener) + }, + onFindInBrowserPage: browserFindSubscriptions.subscribe, + onReloadBrowserPage: (callback: () => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent) => callback() + ipcRenderer.on('ui:reloadBrowserPage', listener) + return () => ipcRenderer.removeListener('ui:reloadBrowserPage', listener) + }, + onBrowserHistoryNavigate: (callback: (direction: 'back' | 'forward') => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, direction: 'back' | 'forward'): void => + callback(direction) + ipcRenderer.on('ui:browserHistoryNavigate', listener) + return () => ipcRenderer.removeListener('ui:browserHistoryNavigate', listener) + }, + onZoomBrowserPage: (callback: (direction: 'in' | 'out' | 'reset') => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, direction: 'in' | 'out' | 'reset') => + callback(direction) + ipcRenderer.on('ui:zoomBrowserPage', listener) + return () => ipcRenderer.removeListener('ui:zoomBrowserPage', listener) + }, + onScrollBrowserPage: ( + callback: (event: { browserPageId: string; deltaX: number; deltaY: number }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + payload: { browserPageId: string; deltaX: number; deltaY: number } + ) => callback(payload) + ipcRenderer.on('ui:scrollBrowserPage', listener) + return () => ipcRenderer.removeListener('ui:scrollBrowserPage', listener) + }, + onHardReloadBrowserPage: (callback: () => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent) => callback() + ipcRenderer.on('ui:hardReloadBrowserPage', listener) + return () => ipcRenderer.removeListener('ui:hardReloadBrowserPage', listener) + }, + onCloseActiveTab: (callback: (payload?: CloseActiveTabPayload) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, payload?: unknown): void => { + const admitted = admitCloseActiveTabPayload(payload) + if (admitted.kind === 'legacy') { + callback() + } else if (admitted.kind === 'source') { + callback(admitted.payload) + } + } + ipcRenderer.on('ui:closeActiveTab', listener) + return () => ipcRenderer.removeListener('ui:closeActiveTab', listener) + }, + onCloseFloatingItem: (callback: (payload: { sourceId: string }) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, payload: { sourceId: string }) => + callback(payload) + ipcRenderer.on('ui:closeFloatingItem', listener) + return () => ipcRenderer.removeListener('ui:closeFloatingItem', listener) + }, + onSelectFloatingIndex: (callback: (payload: { index: number }) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, payload: { index: number }) => + callback(payload) + ipcRenderer.on('ui:selectFloatingIndex', listener) + return () => ipcRenderer.removeListener('ui:selectFloatingIndex', listener) + }, + onSwitchTab: (callback: (direction: 1 | -1) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, direction: 1 | -1) => callback(direction) + ipcRenderer.on('ui:switchTab', listener) + return () => ipcRenderer.removeListener('ui:switchTab', listener) + }, + onSwitchTabAcrossAllTypes: (callback: (direction: 1 | -1) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, direction: 1 | -1) => callback(direction) + ipcRenderer.on('ui:switchTabAcrossAllTypes', listener) + return () => ipcRenderer.removeListener('ui:switchTabAcrossAllTypes', listener) + }, + onSwitchRecentTab: (callback: () => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent) => callback() + ipcRenderer.on('ui:switchRecentTab', listener) + return () => ipcRenderer.removeListener('ui:switchRecentTab', listener) + }, + onSwitchTerminalTab: (callback: (direction: 1 | -1) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, direction: 1 | -1) => callback(direction) + ipcRenderer.on('ui:switchTerminalTab', listener) + return () => ipcRenderer.removeListener('ui:switchTerminalTab', listener) + }, + onCtrlTabKeyDown: (callback: (data: { shiftKey: boolean }) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, data: { shiftKey: boolean }) => + callback(data) + ipcRenderer.on('ui:ctrlTabKeyDown', listener) + return () => ipcRenderer.removeListener('ui:ctrlTabKeyDown', listener) + }, + onCtrlTabKeyUp: (callback: () => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent) => callback() + ipcRenderer.on('ui:ctrlTabKeyUp', listener) + return () => ipcRenderer.removeListener('ui:ctrlTabKeyUp', listener) + }, + onToggleStatusBar: (callback: () => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent) => callback() + ipcRenderer.on('ui:toggleStatusBar', listener) + return () => ipcRenderer.removeListener('ui:toggleStatusBar', listener) + }, + onExportPdfRequested: (callback: () => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent) => callback() + ipcRenderer.on('export:requestPdf', listener) + return () => ipcRenderer.removeListener('export:requestPdf', listener) + }, + onAppMenuPaste: (callback: () => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent) => callback() + ipcRenderer.on('ui:appMenuPaste', listener) + return () => ipcRenderer.removeListener('ui:appMenuPaste', listener) + }, + onAppMenuSelectionAction: (callback: (action: 'copy' | 'select-all') => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, action: 'copy' | 'select-all'): void => + callback(action) + ipcRenderer.on('ui:appMenuSelectionAction', listener) + return () => ipcRenderer.removeListener('ui:appMenuSelectionAction', listener) + }, + onEditableContextPaste: (callback: (data: { plainTextOnly: boolean }) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, data: { plainTextOnly: boolean }): void => + callback({ plainTextOnly: data?.plainTextOnly === true }) + ipcRenderer.on('ui:editableContextPaste', listener) + return () => ipcRenderer.removeListener('ui:editableContextPaste', listener) + }, + onDictationKeyDown: (callback: () => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent) => callback() + ipcRenderer.on('ui:dictationKeyDown', listener) + return () => ipcRenderer.removeListener('ui:dictationKeyDown', listener) + }, + onActivateWorktree: ( + callback: (data: { + repoId: string + worktreeId: string + setup?: WorktreeSetupLaunch + startup?: { command: string; env?: Record } + defaultTabs?: WorktreeDefaultTabsLaunch + }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { + repoId: string + worktreeId: string + setup?: WorktreeSetupLaunch + startup?: { command: string; env?: Record } + defaultTabs?: WorktreeDefaultTabsLaunch + } + ) => callback(data) + ipcRenderer.on('ui:activateWorktree', listener) + return () => ipcRenderer.removeListener('ui:activateWorktree', listener) + } +} satisfies Partial diff --git a/src/preload/api/ui-bridge-terminal-and-session-tabs.ts b/src/preload/api/ui-bridge-terminal-and-session-tabs.ts new file mode 100644 index 00000000000..eaff9e8847a --- /dev/null +++ b/src/preload/api/ui-bridge-terminal-and-session-tabs.ts @@ -0,0 +1,215 @@ +import { ipcRenderer } from 'electron' +import type { TerminalPaneSplitSource } from '../../shared/feature-education-telemetry' +import type { TerminalTabCreateReply } from '../../shared/terminal-reveal-identity' +import type { + AgentProviderSessionMetadata, + SleepingAgentLaunchConfig +} from '../../shared/agent-session-resume' +import type { TuiAgent } from '../../shared/tui-agent' +import type { + RuntimeMobileSessionTabMove, + RuntimeTerminalCreateRequestPayload, + RuntimeTerminalPresentation +} from '../../shared/runtime-types' +import type { PreloadApi } from '../api-types' + +export const uiTerminalAndSessionTabsApi = { + onCreateTerminal: ( + callback: (data: { + requestId?: string + worktreeId: string + command?: string + cwd?: string + env?: Record + launchConfig?: SleepingAgentLaunchConfig + resumeProviderSession?: AgentProviderSessionMetadata + launchToken?: string + launchAgent?: TuiAgent + viewMode?: 'terminal' | 'chat' + title?: string + ptyId?: string + activate?: boolean + focus?: boolean + presentation?: RuntimeTerminalPresentation + surfaceOwner?: false + tabId?: string + leafId?: string + splitFromLeafId?: string + splitDirection?: 'horizontal' | 'vertical' + splitTelemetrySource?: TerminalPaneSplitSource + }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { + requestId?: string + worktreeId: string + command?: string + cwd?: string + env?: Record + launchConfig?: SleepingAgentLaunchConfig + resumeProviderSession?: AgentProviderSessionMetadata + launchToken?: string + launchAgent?: TuiAgent + viewMode?: 'terminal' | 'chat' + title?: string + ptyId?: string + activate?: boolean + focus?: boolean + presentation?: RuntimeTerminalPresentation + surfaceOwner?: false + tabId?: string + leafId?: string + splitFromLeafId?: string + splitDirection?: 'horizontal' | 'vertical' + splitTelemetrySource?: TerminalPaneSplitSource + } + ) => callback(data) + ipcRenderer.on('ui:createTerminal', listener) + return () => ipcRenderer.removeListener('ui:createTerminal', listener) + }, + onRequestTerminalCreate: ( + callback: (data: RuntimeTerminalCreateRequestPayload) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: RuntimeTerminalCreateRequestPayload + ) => callback(data) + ipcRenderer.on('terminal:requestTabCreate', listener) + return () => ipcRenderer.removeListener('terminal:requestTabCreate', listener) + }, + onRequestTerminalTabMount: ( + callback: (data: { worktreeId: string; tabId?: string; ptyId?: string }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { worktreeId: string; tabId?: string; ptyId?: string } + ) => callback(data) + ipcRenderer.on('terminal:requestTabMount', listener) + return () => ipcRenderer.removeListener('terminal:requestTabMount', listener) + }, + replyTerminalCreate: (reply: TerminalTabCreateReply): void => { + ipcRenderer.send('terminal:tabCreateReply', reply) + }, + onSplitTerminal: ( + callback: (data: { + tabId: string + paneRuntimeId: number + direction: 'horizontal' | 'vertical' + command?: string + worktreeId?: string + sourceLeafId?: string + telemetrySource?: TerminalPaneSplitSource + newLeafId?: string + }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { + tabId: string + paneRuntimeId: number + direction: 'horizontal' | 'vertical' + command?: string + worktreeId?: string + sourceLeafId?: string + telemetrySource?: TerminalPaneSplitSource + newLeafId?: string + } + ) => callback(data) + ipcRenderer.on('ui:splitTerminal', listener) + return () => ipcRenderer.removeListener('ui:splitTerminal', listener) + }, + onRenameTerminal: ( + callback: (data: { tabId: string; title: string | null }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { tabId: string; title: string | null } + ) => callback(data) + ipcRenderer.on('ui:renameTerminal', listener) + return () => ipcRenderer.removeListener('ui:renameTerminal', listener) + }, + onFocusTerminal: ( + callback: (data: { + tabId: string + worktreeId: string + leafId?: string | null + ackPaneKeyOnSuccess?: string + flashFocusedPane?: boolean + scrollToBottomIfOutputSinceLastView?: boolean + }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { + tabId: string + worktreeId: string + leafId?: string | null + ackPaneKeyOnSuccess?: string + flashFocusedPane?: boolean + scrollToBottomIfOutputSinceLastView?: boolean + } + ) => callback(data) + ipcRenderer.on('ui:focusTerminal', listener) + return () => ipcRenderer.removeListener('ui:focusTerminal', listener) + }, + onFocusEditorTab: ( + callback: (data: { tabId: string; worktreeId: string }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { tabId: string; worktreeId: string } + ) => callback(data) + ipcRenderer.on('ui:focusEditorTab', listener) + return () => ipcRenderer.removeListener('ui:focusEditorTab', listener) + }, + onCloseSessionTab: ( + callback: (data: { tabId: string; worktreeId: string }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { tabId: string; worktreeId: string } + ) => callback(data) + ipcRenderer.on('ui:closeSessionTab', listener) + return () => ipcRenderer.removeListener('ui:closeSessionTab', listener) + }, + onSessionTabCloseRequest: (callback) => { + const listener = (_event: Electron.IpcRendererEvent, request: Parameters[0]) => + callback(request) + ipcRenderer.on('ui:sessionTabCloseRequest', listener) + return () => ipcRenderer.removeListener('ui:sessionTabCloseRequest', listener) + }, + respondSessionTabClose: (response) => { + ipcRenderer.send('ui:sessionTabCloseResponse', response) + }, + onMoveSessionTab: ( + callback: (data: { worktreeId: string } & RuntimeMobileSessionTabMove) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { worktreeId: string } & RuntimeMobileSessionTabMove + ) => callback(data) + ipcRenderer.on('ui:moveSessionTab', listener) + return () => ipcRenderer.removeListener('ui:moveSessionTab', listener) + }, + onOpenFileFromMobile: ( + callback: (data: { + worktreeId: string + filePath: string + relativePath: string + runtimeEnvironmentId?: string + }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { + worktreeId: string + filePath: string + relativePath: string + runtimeEnvironmentId?: string + } + ) => callback(data) + ipcRenderer.on('ui:openFileFromMobile', listener) + return () => ipcRenderer.removeListener('ui:openFileFromMobile', listener) + } +} satisfies Partial diff --git a/src/preload/api/ui-bridge.ts b/src/preload/api/ui-bridge.ts new file mode 100644 index 00000000000..200d49ba1c1 --- /dev/null +++ b/src/preload/api/ui-bridge.ts @@ -0,0 +1,12 @@ +import type { PreloadApi } from '../api-types' +import { uiStateAndMenuCommandsApi } from './ui-bridge-state-and-menu-commands' +import { uiTabAndBrowserCommandsApi } from './ui-bridge-tab-and-browser-commands' +import { uiTerminalAndSessionTabsApi } from './ui-bridge-terminal-and-session-tabs' +import { uiClipboardAndWindowControlsApi } from './ui-bridge-clipboard-and-window-controls' + +export const uiApi = { + ...uiStateAndMenuCommandsApi, + ...uiTabAndBrowserCommandsApi, + ...uiTerminalAndSessionTabsApi, + ...uiClipboardAndWindowControlsApi +} satisfies PreloadApi['ui'] diff --git a/src/preload/api/ui-command-event-api.ts b/src/preload/api/ui-command-event-api.ts index e63034b5233..0876104e471 100644 --- a/src/preload/api/ui-command-event-api.ts +++ b/src/preload/api/ui-command-event-api.ts @@ -1,3 +1,4 @@ +import type { MarkdownDocument } from '../../shared/filesystem-entry-types' import type { PersistedUIState } from '../../shared/persisted-ui-state-types' import type { TuiAgent } from '../../shared/tui-agent' import type { @@ -48,6 +49,10 @@ export type UiCommandEventApi = { consumePendingOpenSettings: () => Promise onOpenSkillShare: (callback: (shareId: string) => void) => () => void consumePendingSkillShare: () => Promise + /** OS "Open With" markdown paths pushed while a renderer is already listening. */ + onOpenMarkdownFiles: (callback: (documents: MarkdownDocument[]) => void) => () => void + /** Drains the "Open With" paths queued before this renderer's listener attached. */ + consumePendingMarkdownFileOpens: () => Promise onOpenSetupGuide: (callback: () => void) => () => void onOpenFeatureTour: (callback: () => void) => () => void onOpenCrashReport: (callback: () => void) => () => void diff --git a/src/preload/api/updater-bridge.ts b/src/preload/api/updater-bridge.ts new file mode 100644 index 00000000000..2b40e42b8be --- /dev/null +++ b/src/preload/api/updater-bridge.ts @@ -0,0 +1,36 @@ +import { ipcRenderer } from 'electron' +import type { UpdateStatus } from '../../shared/update-status-types' +import { prepareAndInvokeUpdaterInstall } from '../renderer-restart-wiring' +import { awaitBeforeUnloadCheckpoint, updaterQuitAbortRelay } from '../preload-runtime-support' +import type { PreloadApi } from '../api-types' + +export const updaterApi = { + getStatus: () => ipcRenderer.invoke('updater:getStatus'), + getVersion: () => ipcRenderer.invoke('updater:getVersion'), + check: (options) => ipcRenderer.invoke('updater:check', options), + download: () => ipcRenderer.invoke('updater:download'), + dismissNudge: () => ipcRenderer.invoke('updater:dismissNudge'), + dismissAvailableUpdate: () => ipcRenderer.invoke('updater:dismissAvailableUpdate'), + getLinuxPackageInstallInstructions: () => + ipcRenderer.invoke('updater:getLinuxPackageInstallInstructions'), + showLinuxPackage: () => ipcRenderer.invoke('updater:showLinuxPackage'), + listBuilds: (channel) => ipcRenderer.invoke('updater:listBuilds', channel), + quitAndInstall: (): Promise => + prepareAndInvokeUpdaterInstall( + window, + updaterQuitAbortRelay, + () => ipcRenderer.invoke('updater:quitAndInstall'), + awaitBeforeUnloadCheckpoint + ), + + onStatus: (callback) => { + const listener = (_event: Electron.IpcRendererEvent, status: UpdateStatus) => callback(status) + ipcRenderer.on('updater:status', listener) + return () => ipcRenderer.removeListener('updater:status', listener) + }, + onClearDismissal: (callback) => { + const listener = (_event: Electron.IpcRendererEvent) => callback() + ipcRenderer.on('updater:clearDismissal', listener) + return () => ipcRenderer.removeListener('updater:clearDismissal', listener) + } +} satisfies PreloadApi['updater'] diff --git a/src/preload/api/workspace-cleanup-bridge.ts b/src/preload/api/workspace-cleanup-bridge.ts new file mode 100644 index 00000000000..e9e4ae227cb --- /dev/null +++ b/src/preload/api/workspace-cleanup-bridge.ts @@ -0,0 +1,36 @@ +import { ipcRenderer } from 'electron' +import type { WorkspaceCleanupScanProgress } from '../../shared/workspace-cleanup' +import type { PreloadApi } from '../api-types' + +export const workspaceCleanupApi = { + scan: (args, onProgress) => { + if (!onProgress) { + return ipcRenderer.invoke('workspaceCleanup:scan', args) + } + const scanId = args?.scanId ?? crypto.randomUUID() + const listener = ( + _event: Electron.IpcRendererEvent, + progress: WorkspaceCleanupScanProgress + ): void => { + if (progress.scanId === scanId) { + onProgress(progress) + } + } + ipcRenderer.on('workspaceCleanup:scanProgress', listener) + return ipcRenderer + .invoke('workspaceCleanup:scan', { ...args, scanId }) + .finally(() => ipcRenderer.removeListener('workspaceCleanup:scanProgress', listener)) + }, + cancelScan: (scanId) => ipcRenderer.invoke('workspaceCleanup:cancelScan', scanId), + getCachedScan: () => ipcRenderer.invoke('workspaceCleanup:getCachedScan'), + dismiss: (args) => ipcRenderer.invoke('workspaceCleanup:dismiss', args), + clearDismissals: () => ipcRenderer.invoke('workspaceCleanup:clearDismissals'), + hasKillableLocalProcesses: (args) => + ipcRenderer.invoke('workspaceCleanup:hasKillableLocalProcesses', args), + beginRemovalSnapshotPruneBatch: (args) => + ipcRenderer.invoke('workspaceCleanup:beginRemovalSnapshotPruneBatch', args), + recordRemovalSnapshotPrune: (args) => + ipcRenderer.invoke('workspaceCleanup:recordRemovalSnapshotPrune', args), + finishRemovalSnapshotPruneBatch: (args) => + ipcRenderer.invoke('workspaceCleanup:finishRemovalSnapshotPruneBatch', args) +} satisfies PreloadApi['workspaceCleanup'] diff --git a/src/preload/api/workspace-ports-bridge.ts b/src/preload/api/workspace-ports-bridge.ts new file mode 100644 index 00000000000..6cb4c859ed3 --- /dev/null +++ b/src/preload/api/workspace-ports-bridge.ts @@ -0,0 +1,16 @@ +import { ipcRenderer } from 'electron' +import type { WorkspacePortAdvertisedUrlChangedEvent } from '../../shared/workspace-ports' +import type { PreloadApi } from '../api-types' + +export const workspacePortsApi = { + scan: (args) => ipcRenderer.invoke('workspacePorts:scan', args), + kill: (args) => ipcRenderer.invoke('workspacePorts:kill', args), + onAdvertisedUrlChanged: (callback) => { + const listener = ( + _event: Electron.IpcRendererEvent, + event: WorkspacePortAdvertisedUrlChangedEvent + ): void => callback(event) + ipcRenderer.on('workspacePorts:advertised-url-changed', listener) + return () => ipcRenderer.removeListener('workspacePorts:advertised-url-changed', listener) + } +} satisfies PreloadApi['workspacePorts'] diff --git a/src/preload/api/workspace-space-bridge.ts b/src/preload/api/workspace-space-bridge.ts new file mode 100644 index 00000000000..ec9bc74e12a --- /dev/null +++ b/src/preload/api/workspace-space-bridge.ts @@ -0,0 +1,17 @@ +import { ipcRenderer } from 'electron' +import type { WorkspaceSpaceScanProgress } from '../../shared/workspace-space-types' +import type { PreloadApi } from '../api-types' + +export const workspaceSpaceApi = { + analyze: () => ipcRenderer.invoke('workspaceSpace:analyze'), + getCachedAnalysis: () => ipcRenderer.invoke('workspaceSpace:getCachedAnalysis'), + cancel: () => ipcRenderer.invoke('workspaceSpace:cancel'), + onProgress: (callback) => { + const listener = ( + _event: Electron.IpcRendererEvent, + progress: WorkspaceSpaceScanProgress + ): void => callback(progress) + ipcRenderer.on('workspaceSpace:progress', listener) + return () => ipcRenderer.removeListener('workspaceSpace:progress', listener) + } +} satisfies PreloadApi['workspaceSpace'] diff --git a/src/preload/api/worktrees-bridge.ts b/src/preload/api/worktrees-bridge.ts new file mode 100644 index 00000000000..8fb8d5728ac --- /dev/null +++ b/src/preload/api/worktrees-bridge.ts @@ -0,0 +1,117 @@ +import { ipcRenderer } from 'electron' +import type { + HostLineageSnapshot, + ListDesktopLineageForHostArgs +} from '../../shared/host-lineage-contract' +import type { + WorktreeBaseStatusEvent, + WorktreeRemoteBranchConflictEvent +} from '../../shared/worktree/base-ref-drift-types' +import type { WorktreeHeadIdentity } from '../../shared/worktree/types' +import type { PreloadApi } from '../api-types' + +export const worktreesApi = { + list: (args) => ipcRenderer.invoke('worktrees:list', args), + listRetiredNames: (args) => ipcRenderer.invoke('worktrees:listRetiredNames', args), + + listDetected: (args) => ipcRenderer.invoke('worktrees:listDetected', args), + + listKnownForExecutionHost: (args) => + ipcRenderer.invoke('worktrees:listKnownForExecutionHost', args), + + forgetRemovedForExecutionHost: (args) => + ipcRenderer.invoke('worktrees:forgetRemovedForExecutionHost', args), + + cancelListDetected: (args) => ipcRenderer.invoke('worktrees:cancelListDetected', args), + + listAll: () => ipcRenderer.invoke('worktrees:listAll'), + + create: (args) => ipcRenderer.invoke('worktrees:create', args), + + adoptProvisionedRoot: (args) => ipcRenderer.invoke('worktrees:adoptProvisionedRoot', args), + + onCreateProgress: ( + callback: (data: { creationId?: string; phase: 'fetching' | 'creating' }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { creationId?: string; phase: 'fetching' | 'creating' } + ) => callback(data) + ipcRenderer.on('createWorktree:progress', listener) + return () => ipcRenderer.removeListener('createWorktree:progress', listener) + }, + + prefetchCreateBase: (args) => ipcRenderer.invoke('worktrees:prefetchCreateBase', args), + + resolvePrBase: (args) => ipcRenderer.invoke('worktrees:resolvePrBase', args), + + resolveMrBase: (args) => ipcRenderer.invoke('worktrees:resolveMrBase', args), + + remove: (args) => ipcRenderer.invoke('worktrees:remove', args), + + forgetLocal: (args) => ipcRenderer.invoke('worktrees:forgetLocal', args), + + forceDeletePreservedBranch: (args) => + ipcRenderer.invoke('worktrees:forceDeletePreservedBranch', args), + + updateMeta: (args) => ipcRenderer.invoke('worktrees:updateMeta', args), + + listLineage: () => ipcRenderer.invoke('worktrees:listLineage'), + + listLineageForHost: (args: ListDesktopLineageForHostArgs): Promise => + ipcRenderer.invoke('worktrees:listLineageForHost', args), + + updateLineage: (args) => ipcRenderer.invoke('worktrees:updateLineage', args), + + persistSortOrder: (args) => ipcRenderer.invoke('worktrees:persistSortOrder', args), + + getBranchRenameFailureOutput: (args) => + ipcRenderer.invoke('worktrees:getBranchRenameFailureOutput', args), + + onChanged: ( + callback: (data: { + repoId: string + renamed?: { oldWorktreeId: string; newWorktreeId: string } + }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { repoId: string; renamed?: { oldWorktreeId: string; newWorktreeId: string } } + ) => callback(data) + ipcRenderer.on('worktrees:changed', listener) + return () => ipcRenderer.removeListener('worktrees:changed', listener) + }, + + onGitStatusMetadataChanged: (callback: (data: { repoId: string }) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, data: { repoId: string }) => callback(data) + ipcRenderer.on('worktrees:gitStatusMetadataChanged', listener) + return () => ipcRenderer.removeListener('worktrees:gitStatusMetadataChanged', listener) + }, + + onHeadIdentitiesChanged: ( + callback: (data: { repoId: string; identities: WorktreeHeadIdentity[] }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { repoId: string; identities: WorktreeHeadIdentity[] } + ) => callback(data) + ipcRenderer.on('worktrees:headIdentitiesChanged', listener) + return () => ipcRenderer.removeListener('worktrees:headIdentitiesChanged', listener) + }, + + onBaseStatus: (callback: (data: WorktreeBaseStatusEvent) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, data: WorktreeBaseStatusEvent) => + callback(data) + ipcRenderer.on('worktree:baseStatus', listener) + return () => ipcRenderer.removeListener('worktree:baseStatus', listener) + }, + + onRemoteBranchConflict: ( + callback: (data: WorktreeRemoteBranchConflictEvent) => void + ): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, data: WorktreeRemoteBranchConflictEvent) => + callback(data) + ipcRenderer.on('worktree:remoteBranchConflict', listener) + return () => ipcRenderer.removeListener('worktree:remoteBranchConflict', listener) + } +} satisfies PreloadApi['worktrees'] diff --git a/src/preload/api/wsl-bridge.ts b/src/preload/api/wsl-bridge.ts new file mode 100644 index 00000000000..bc7869000e4 --- /dev/null +++ b/src/preload/api/wsl-bridge.ts @@ -0,0 +1,7 @@ +import { ipcRenderer } from 'electron' +import type { PreloadApi } from '../api-types' + +export const wslApi = { + isAvailable: (): Promise => ipcRenderer.invoke('wsl:isAvailable'), + listDistros: (): Promise => ipcRenderer.invoke('wsl:listDistros') +} satisfies PreloadApi['wsl'] diff --git a/src/preload/app-restart-checkpoint-routing.test.ts b/src/preload/app-restart-checkpoint-routing.test.ts index 19eb6948c9a..d794a86b9ab 100644 --- a/src/preload/app-restart-checkpoint-routing.test.ts +++ b/src/preload/app-restart-checkpoint-routing.test.ts @@ -92,6 +92,20 @@ describe('native preload destructive app actions', () => { }) } + it('exposes the durable checkpoint join the lazy-chunk recovery reload depends on', async () => { + const api = await loadApi() + invoke.mockResolvedValue({ ok: true }) + + await expect(api.app.awaitBeforeUnloadCheckpoint()).resolves.toBeUndefined() + expect(invoke).toHaveBeenCalledWith('app:await-before-unload-checkpoint') + + invoke.mockResolvedValue({ ok: false }) + + await expect(api.app.awaitBeforeUnloadCheckpoint()).rejects.toThrow( + 'Failed to persist renderer state before unload.' + ) + }) + it('preserves both macOS keyboard preload adapters', async () => { const api = await loadApi() invoke.mockResolvedValue(undefined) diff --git a/src/preload/gitlab.ts b/src/preload/gitlab.ts index d6f12367db0..154e139e4c4 100644 --- a/src/preload/gitlab.ts +++ b/src/preload/gitlab.ts @@ -12,23 +12,21 @@ type GitLabRepoSelectorArgs = { } export const glApi = { - viewer: (): Promise => ipcRenderer.invoke('gitlab:viewer'), - diagnoseAuth: (): Promise => ipcRenderer.invoke('gitlab:diagnoseAuth'), - rateLimit: (args?: { force?: boolean; host?: string | null }): Promise => + viewer: () => ipcRenderer.invoke('gitlab:viewer'), + diagnoseAuth: () => ipcRenderer.invoke('gitlab:diagnoseAuth'), + rateLimit: (args?: { force?: boolean; host?: string | null }) => ipcRenderer.invoke('gitlab:rateLimit', args), - projectSlug: (args: GitLabRepoSelectorArgs): Promise => - ipcRenderer.invoke('gitlab:projectSlug', args), + projectSlug: (args: GitLabRepoSelectorArgs) => ipcRenderer.invoke('gitlab:projectSlug', args), mrForBranch: ( args: GitLabRepoSelectorArgs & { branch: string linkedMRIid?: number | null } - ): Promise => ipcRenderer.invoke('gitlab:mrForBranch', args), + ) => ipcRenderer.invoke('gitlab:mrForBranch', args), - mr: (args: GitLabRepoSelectorArgs & { iid: number }): Promise => - ipcRenderer.invoke('gitlab:mr', args), + mr: (args: GitLabRepoSelectorArgs & { iid: number }) => ipcRenderer.invoke('gitlab:mr', args), listMRs: ( args: GitLabRepoSelectorArgs & { @@ -37,7 +35,7 @@ export const glApi = { perPage?: number query?: string } - ): Promise => ipcRenderer.invoke('gitlab:listMRs', args), + ) => ipcRenderer.invoke('gitlab:listMRs', args), listWorkItems: ( args: GitLabRepoSelectorArgs & { @@ -46,9 +44,9 @@ export const glApi = { perPage?: number query?: string } - ): Promise => ipcRenderer.invoke('gitlab:listWorkItems', args), + ) => ipcRenderer.invoke('gitlab:listWorkItems', args), - issue: (args: GitLabRepoSelectorArgs & { number: number }): Promise => + issue: (args: GitLabRepoSelectorArgs & { number: number }) => ipcRenderer.invoke('gitlab:issue', args), listIssues: ( @@ -58,8 +56,7 @@ export const glApi = { limit?: number page?: number } - ): Promise<{ items: unknown[]; totalPages?: number; error?: unknown }> => - ipcRenderer.invoke('gitlab:listIssues', args), + ) => ipcRenderer.invoke('gitlab:listIssues', args), createIssue: ( args: GitLabRepoSelectorArgs & { @@ -77,25 +74,23 @@ export const glApi = { ): Promise<{ ok: true } | { ok: false; error: string }> => ipcRenderer.invoke('gitlab:updateIssue', args), - addIssueComment: ( - args: GitLabRepoSelectorArgs & { number: number; body: string } - ): Promise => ipcRenderer.invoke('gitlab:addIssueComment', args), + addIssueComment: (args: GitLabRepoSelectorArgs & { number: number; body: string }) => + ipcRenderer.invoke('gitlab:addIssueComment', args), listLabels: (args: GitLabRepoSelectorArgs): Promise => ipcRenderer.invoke('gitlab:listLabels', args), - listAssignableUsers: (args: GitLabRepoSelectorArgs): Promise => + listAssignableUsers: (args: GitLabRepoSelectorArgs) => ipcRenderer.invoke('gitlab:listAssignableUsers', args), - todos: (args: GitLabRepoSelectorArgs): Promise => - ipcRenderer.invoke('gitlab:todos', args), + todos: (args: GitLabRepoSelectorArgs) => ipcRenderer.invoke('gitlab:todos', args), workItemDetails: ( args: GitLabRepoSelectorArgs & { iid: number type: 'issue' | 'mr' } - ): Promise => ipcRenderer.invoke('gitlab:workItemDetails', args), + ) => ipcRenderer.invoke('gitlab:workItemDetails', args), closeMR: ( args: GitLabRepoSelectorArgs & { @@ -133,9 +128,9 @@ export const glApi = { reviewerIds: number[] projectRef?: unknown } - ): Promise => ipcRenderer.invoke('gitlab:updateMRReviewers', args), + ) => ipcRenderer.invoke('gitlab:updateMRReviewers', args), - addMRComment: (args: GitLabRepoSelectorArgs & { iid: number; body: string }): Promise => + addMRComment: (args: GitLabRepoSelectorArgs & { iid: number; body: string }) => ipcRenderer.invoke('gitlab:addMRComment', args), addMRInlineComment: ( @@ -144,7 +139,7 @@ export const glApi = { input: unknown projectRef?: unknown } - ): Promise => ipcRenderer.invoke('gitlab:addMRInlineComment', args), + ) => ipcRenderer.invoke('gitlab:addMRInlineComment', args), resolveMRDiscussion: ( args: GitLabRepoSelectorArgs & { @@ -152,15 +147,14 @@ export const glApi = { discussionId: string resolved: boolean } - ): Promise => ipcRenderer.invoke('gitlab:resolveMRDiscussion', args), + ) => ipcRenderer.invoke('gitlab:resolveMRDiscussion', args), jobTrace: ( args: GitLabRepoSelectorArgs & { jobId: number; projectRef?: unknown; logExcerpt?: boolean } - ): Promise => ipcRenderer.invoke('gitlab:jobTrace', args), + ) => ipcRenderer.invoke('gitlab:jobTrace', args), - retryJob: ( - args: GitLabRepoSelectorArgs & { jobId: number; projectRef?: unknown } - ): Promise => ipcRenderer.invoke('gitlab:retryJob', args), + retryJob: (args: GitLabRepoSelectorArgs & { jobId: number; projectRef?: unknown }) => + ipcRenderer.invoke('gitlab:retryJob', args), workItemByPath: ( args: GitLabRepoSelectorArgs & { @@ -169,5 +163,5 @@ export const glApi = { iid: number type: 'issue' | 'mr' } - ): Promise => ipcRenderer.invoke('gitlab:workItemByPath', args) + ) => ipcRenderer.invoke('gitlab:workItemByPath', args) } diff --git a/src/preload/index.ts b/src/preload/index.ts index d1c2c358cc2..27d3ca8e062 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1,5351 +1,189 @@ -/* eslint-disable max-lines -- Why: preload is the audited renderer/Electron IPC contract; co-locating the surface eases security and type-drift review. */ -import { contextBridge, ipcRenderer, webFrame, webUtils } from 'electron' +import { contextBridge, ipcRenderer } from 'electron' import { electronAPI } from '@electron-toolkit/preload' -import { preloadE2EConfig } from './e2e-config' -import { glApi } from './gitlab' -import { admitCloseActiveTabPayload } from './close-active-tab-payload-admission' -import type { CloseActiveTabPayload } from './api/ui-command-event-api' -import type { - SkillDeletePlan, - SkillDeleteRequest, - SkillDeleteResult -} from '../shared/skill-delete-contract' +import type { PreloadApi } from './api-types' import { - DOC_PREVIEW_EXTERNAL_LINK_CHANNEL, - DOC_PREVIEW_LOAD_FAILURE_CHANNEL, - DOC_PREVIEW_AUTHORIZE_DIRECTORY_CHANNEL, - DOC_PREVIEW_MINT_GRANT_CHANNEL, - DOC_PREVIEW_REVOKE_GRANT_CHANNEL, - type DocPreviewFailure -} from '../shared/doc-preview-scheme' -import type { DocPreviewGrantRequest } from './api/doc-preview-api' -import type { AppIdentity } from '../shared/app-identity' -import type { MacCapturedDigitRowChord } from '../shared/macos-symbolic-hotkeys' -import type { ComputerAwakeStatus } from '../shared/computer-awake-mode' -import type { - DashboardRevealAgentArgs, - DashboardSleepWorkspaceArgs, - DashboardSnapshot, - DashboardSpawnAgentArgs -} from '../shared/dashboard-snapshot' -import type { - TerminalPreviewConnectResult, - TerminalPreviewDataPayload -} from '../shared/terminal-preview' -import type { AgentSessionPtyWriteRefusal } from '../shared/agent-session-pty-write-admission' -import type { CliInstallStatus } from '../shared/cli-install-types' -import type { CodexConfigSyncStatus } from '../shared/codex-config-sync-types' -import type { TerminalPaneSplitSource } from '../shared/feature-education-telemetry' -import type { TerminalTabCreateReply } from '../shared/terminal-reveal-identity' -import type { ProjectExecutionRuntimeResolution } from '../shared/project-execution-runtime' -import type { StartupCommandDelivery } from '../shared/codex-startup-delivery' -import type { - AgentProviderSessionMetadata, - SleepingAgentLaunchConfig -} from '../shared/agent-session-resume' -import type { MobileRelayStatus } from '../shared/mobile-relay-status' -import type { MobilePairingConnectionMode } from '../shared/mobile-pairing-connection-mode' -import type { RuntimePairingReach } from '../shared/runtime-pairing-reach' -import type { MobileRelayMintFailure } from '../shared/mobile-relay-mint-failure' -import type { VerifyAndAddRuntimeEnvironmentResult } from '../shared/remote-pairing-verification' -import type { - SshMutationExpectation, - SshConnectionState, - SshConfigHostListArgs, - SshConfigHostListResult, - SshConfigHostResolution, - SshConfigImportResult, - SshTargetAddResult, - SshTargetCreateInput, - SshTarget, - SshTargetUpdateInput, - PortForwardEntry, - EnrichedDetectedPort -} from '../shared/ssh-types' -import { - admitSshConnectionStateForAuthorityReconciliation, - admitSshDetectedPorts -} from '../shared/ssh-retained-payload-admission' -import type { - HostRepoCatalogSnapshot, - ListReposForExecutionHostArgs -} from '../shared/host-repo-catalog-contract' -import type { - HostLineageSnapshot, - ListDesktopLineageForHostArgs -} from '../shared/host-lineage-contract' -import type { - PluginPanelActionOutcome, - PluginPanelEntry -} from '../shared/plugins/plugin-panel-bridge' -import type { PluginConsentRequest } from '../shared/plugins/plugin-consent-request' -import type { PluginChangeEvent } from '../shared/plugins/plugin-change-event' -import type { BrowserViewportOverride } from '../shared/browser-workspace-types' -import type { - BrowserWebAuthnAccountRequest, - BrowserWebAuthnAccountResponse -} from '../shared/browser-webauthn-account' -import type { SearchResult } from '../shared/code-search-types' -import type { - FilesystemPathFlavor, - FsChangedPayload, - MarkdownDocument -} from '../shared/filesystem-entry-types' -import type { GitForkSyncExpectedUpstream, GitForkSyncResult } from '../shared/git-fork-sync' -import type { GitStagingArea, GitUpstreamStatus } from '../shared/git-status-types' -import type { GitHubCommentResult, GitHubReactionContent } from '../shared/github/comment-types' -import type { - GitHubPRRefreshCandidate, - GitHubPRRefreshEvent, - GitHubPRRefreshReason -} from '../shared/github/pull-request-refresh-types' -import type { GitHubAssignableUser, GitHubOwnerRepo } from '../shared/github/pull-request-types' -import type { GetRateLimitResult } from '../shared/github/rate-limit-types' -import type { GitHubWorkItem, ListWorkItemsResult } from '../shared/github/work-item-types' -import type { GhosttyImportPreview } from '../shared/global-settings-types' -import type { GitHubCreateIssueResult } from '../shared/issue-mutation-types' -import type { JiraProjectStatusOrder } from '../shared/jira-types' -import type { LinearProjectDetail } from '../shared/linear/project-types' -import type { - NotificationDeliveryProbeResult, - NotificationDismissResult, - NotificationDispatchResult, - NotificationPermissionStatusResult, - NotificationSoundDataResult, - NotificationSoundPathResult, - NotificationSoundResult -} from '../shared/notification-settings-types' -import type { OnboardingState } from '../shared/onboarding-state-types' -import type { PersistedUIState } from '../shared/persisted-ui-state-types' -import type { CustomPet } from '../shared/pet-types' -import type { MemorySnapshot } from '../shared/process-stats-types' -import type { NestedRepoScanResult } from '../shared/project-group-types' -import type { BaseRefDefaultResult, BaseRefSearchResult } from '../shared/repo-types' -import type { TuiAgent } from '../shared/tui-agent' -import type { FloatingTerminalCwdRequest } from '../shared/ui-chrome-types' -import type { UpdateStatus } from '../shared/update-status-types' -import type { - WorktreeBaseStatusEvent, - WorktreeRemoteBranchConflictEvent -} from '../shared/worktree/base-ref-drift-types' -import type { - WorktreeDefaultTabsLaunch, - WorktreeSetupLaunch -} from '../shared/worktree/launch-types' -import type { GitPushTarget, WorktreeHeadIdentity } from '../shared/worktree/types' -import type { PtyModelRestoreNeededEvent } from '../shared/pty-model-restore-marker' -import type { PtyListedSession } from '../shared/pty-listed-session' -import type { - PtyRendererDeliveryHealthReply, - PtyRendererDeliveryStateReport -} from '../shared/pty-renderer-delivery-health' -import type { TerminalViewAttributes } from '../shared/terminal-view-attributes' -import type { WriteTerminalRenderDesyncEvidenceArgs } from '../shared/terminal-render-desync-evidence' -import type { PtyMainDeliveryDiagnostics } from '../shared/pty-delivery-diagnostics' -import type { - WarpThemeImportPreview, - WarpThemeImportSource -} from '../shared/terminal-custom-themes' -import type { GitHistoryOptions, GitHistoryResult } from '../shared/git-history' -import type { - ShellOpenExternalEditorRequest, - ShellOpenExternalEditorResult, - ShellOpenLocalPathResult -} from '../shared/shell-open-types' -import type { SkillDiscoveryResult, SkillDiscoveryTarget } from '../shared/skills' -import type { - SkillCloudOwnedShare, - SkillCloudOperation, - SkillCloudPackageDetails -} from '../shared/skill-cloud-contract' -import type { - SkillBundleInstallPreviewInput, - SkillBundleInstallPreviewOperation, - SkillBundlePackageVersionInstallInput, - SkillBundleShareInstallInput, - SkillBundleShareInstallOperation, - SkillInstallPreviewInput, - SkillInstallPreviewOperation, - ManagedSkillInstallListOperation, - SkillPackageVersionInstallInput, - SkillRemoveInput, - SkillRemoveOperation, - SkillShareInstallInput, - SkillShareInstallOperation, - SkillInstallCancelInput, - SkillInstallProgress, - SkillSharePreview, - SkillShareProgress, - SkillSharePublishInput, - SkillSharePublishOperation, - SkillShareResolvedOperation -} from '../shared/skill-sharing-contract' -import type { - SkillFreshnessInventory, - SkillUpdateRun, - SkillUpdateStartResult -} from '../shared/skill-freshness' -import type { ClientHostedBrowserRowsEvent } from '../shared/client-hosted-browser-rows' -import type { - RuntimeBrowserDriverState, - RuntimeMobileSessionTabMove, - RuntimeRendererSyncWindowGraph, - RuntimeStatus, - RuntimeSyncWindowGraphResult, - RuntimeTerminalCreateRequestPayload, - RuntimeTerminalDriverState, - RuntimeTerminalPresentation -} from '../shared/runtime-types' -import type { RuntimeRpcResponse } from '../shared/runtime-rpc-envelope' -import type { RemoteRuntimeSharedConnectionDiagnostics } from '../shared/remote-runtime-shared-control-types' -import { RUNTIME_ENVIRONMENT_DIAGNOSTICS_CHANNEL } from '../shared/runtime-environment-diagnostics' -import type { PublicKnownRuntimeEnvironment } from '../shared/runtime-environments' -import type { RemoteWorkspaceChangedEvent } from '../shared/remote-workspace-types' -import type { - RuntimeMobileMarkdownRequest, - RuntimeMobileMarkdownResponse -} from '../shared/mobile-markdown-document' -import type { - CodexRateLimitResetResult, - GrokAccountStatus, - RateLimitRuntimeTarget, - RateLimitState -} from '../shared/rate-limit-types' -import type { WorkspaceSpaceScanProgress } from '../shared/workspace-space-types' -import type { WorkspaceCleanupScanProgress } from '../shared/workspace-cleanup' -import type { WorkspacePortAdvertisedUrlChangedEvent } from '../shared/workspace-ports' -import type { GhAuthDiagnostic } from '../shared/github/auth-types' -import type { TaskSourceContext } from '../shared/task-source-context' -import type { - GetProjectViewTableResult, - GitHubProjectCommentMutationResult, - GitHubProjectMutationResult, - ListAccessibleProjectsResult, - ListAssignableUsersBySlugResult, - ListIssueTypesBySlugResult, - ListLabelsBySlugResult, - ListProjectViewsResult, - ProjectWorkItemDetailsBySlugResult, - ResolveProjectRefResult -} from '../shared/github/project-result-types' -import type { - AddIssueCommentBySlugArgs, - ClearProjectItemFieldArgs, - DeleteIssueCommentBySlugArgs, - GetProjectViewTableArgs, - ListAccessibleProjectsArgs, - ListAssignableUsersBySlugArgs, - ListIssueTypesBySlugArgs, - ListLabelsBySlugArgs, - ListProjectViewsArgs, - ProjectWorkItemDetailsBySlugArgs, - ResolveProjectRefArgs, - UpdateIssueBySlugArgs, - UpdateIssueCommentBySlugArgs, - UpdateIssueTypeBySlugArgs, - UpdatePullRequestBySlugArgs, - UpdateProjectItemFieldArgs -} from '../shared/github/project-request-types' -import { - richMarkdownContextMenuCommandChannel, - richMarkdownContextMenuTargetChannel, - type RichMarkdownContextMenuCommandPayload, - type RichMarkdownContextMenuTableTarget -} from '../shared/rich-markdown-context-menu' -import type { - AgentStatusClearIpcPayload, - AgentStatusIpcPayload, - MigrationUnsupportedPtyEntry -} from '../shared/agent-status-types' -import type { AgentInterruptInferenceRequest } from '../shared/agent-interrupt-intent' -import type { AgentQuestionAnsweredInferenceRequest } from '../shared/agent-question-answered-intent' -import type { TerminalSideEffectBatch } from '../shared/terminal-side-effect-facts' -import type { - SpeechErrorEvent, - SpeechLifecycleEvent, - SpeechModelManifest, - SpeechModelState, - SpeechTranscriptEvent -} from '../shared/speech-types' -import type { TelemetryConsentState } from '../shared/telemetry-consent-types' -import type { - PreflightRuntimeContext, - RefreshAgentsResult, - NativeChatAppendedPayload, - NativeChatReadSessionResult, - NativeChatSubscriptionFrame, - PluginHostInstallResult, - PluginHostInstallSource, - PluginHostListEntry, - PluginHostLogLine, - ExternalAutomationManagerResult, - PreloadApi -} from './api-types' -import type { AgentKind, LaunchSource, RequestKind } from '../shared/telemetry-events' -import { - KEYBOARD_LAYOUT_CHANGED_CHANNEL, - type KeyboardLayoutChangeEvent -} from '../shared/keyboard-layout-events' -import { createBrowserFindSubscriptions } from './browser-find-subscriptions' -import { createBrowserClientPageRendererRequests } from './browser-client-page-renderer-requests' -import { readBrowserClientHostIdArgument } from '../shared/browser-client-host-id-argument' -import { createUsageProviderApi } from './usage-provider-api' -import type { AppStarSource } from '../shared/gh-star-source' -import type { ExecutionHostId } from '../shared/execution-host' -import type { - AutomationDispatchRequest, - AutomationDispatchResult, - ExternalAutomationRunsPage, - AutomationRun, - AutomationPrecheckResult -} from '../shared/automations-types' -import type { AutomationOwnerRef } from '../shared/automation-owner-ref' -import type { - ScopedExternalManagerActionRequest, - ScopedExternalManagerCreateRequest, - ScopedExternalManagerListRequest, - ScopedExternalManagerRunsRequest, - ScopedExternalManagerUpdateRequest -} from '../shared/external-automation-scope' -import type { AutomationsChangedPayload } from '../shared/runtime-client-events' -import type { KeybindingActionId, KeybindingFileSnapshot } from '../shared/keybindings' -import type { - AiVaultDeleteSessionArgs, - AiVaultDeleteSessionResult -} from '../shared/ai-vault-session-deletion' -import type { - AiVaultFirstUserPromptArgs, - AiVaultListArgs, - AiVaultSubagentListArgs -} from '../shared/ai-vault-types' -import type { AiVaultSessionTitlesArgs } from '../shared/ai-vault-session-title' -import type { AiVaultPrepareSessionResumeArgs } from '../shared/ai-vault-resume-preparation' -import type { AgentType } from '../shared/native-chat-types' -import { ORCA_UPDATER_QUIT_AND_INSTALL_ABORTED_EVENT } from '../shared/updater-renderer-events' -import { - ORCA_INTERNAL_FILE_DRAG_TYPE, - createNativeFileDropPayload, - createRejectedNativeFileDropPayload, - hasNativeFileDragTypes, - NATIVE_FILE_DROP_MAX_PATHS, - resolveNativeFileDropPath, - type NativeDropResolution, - type NativeFileDropPayload, - type NativeFileDropPathEntry -} from '../shared/native-file-drop' -import type { - LocalLogTailChangedPayload, - LocalLogTailReadArgs, - LocalLogTailReadResult, - LocalLogTailWatchArgs -} from '../shared/local-log-tail-types' -import { subscribeRuntimeEnvironmentFromPreload } from './runtime-environment-subscriptions' -import type { RuntimeEnvironmentSubscriptionHandle } from './runtime-environment-subscriptions' -import type { HostedReviewForBranchArgs } from '../shared/hosted-review' -import type { ReadClipboardTextOptions } from '../shared/clipboard-text' -import type { - LocalhostWorktreeLabelResult, - LocalhostWorktreeLabelRoute -} from '../shared/localhost-worktree-labels' -import type { - CrashReportBreadcrumbData, - CrashReportCopyDiagnosticsArgs, - CrashReportSubmitArgs, - CrashReportSubmitResult, - ReactErrorBoundaryReportArgs, - ReactErrorBoundaryReportResult -} from '../shared/crash-reporting' -import type { RendererHeapStatistics } from '../shared/renderer-heap-statistics' -import type { RendererProcessMemory } from '../shared/renderer-process-memory' -import { readRendererHeapStatistics } from './renderer-heap-statistics-reader' -import { readRendererProcessMemory } from './renderer-process-memory-reader' -import { createUpdaterQuitAbortRelay } from '../shared/renderer-restart-preparation' -import { - prepareAndInvokeAppRestart, - prepareAndInvokeUpdaterInstall, - registerRendererRestartIpcRelays -} from './renderer-restart-wiring' + installBrowserFindListener, + installNativeFileDropHandlers +} from './preload-runtime-support' +import { appApi } from './api/app-bridge' +import { orcaProfilesApi } from './api/orca-profiles-bridge' +import { platformApi } from './api/platform-bridge' +import { wslApi } from './api/wsl-bridge' +import { pwshApi } from './api/pwsh-bridge' +import { gitBashApi } from './api/git-bash-bridge' +import { pluginsApi } from './api/plugins-bridge' +import { reposApi } from './api/repos-bridge' +import { projectsApi } from './api/projects-bridge' +import { projectGroupsApi } from './api/project-groups-bridge' +import { folderWorkspacesApi } from './api/folder-workspaces-bridge' +import { sparsePresetsApi } from './api/sparse-presets-bridge' +import { worktreesApi } from './api/worktrees-bridge' +import { workspaceCleanupApi } from './api/workspace-cleanup-bridge' +import { workspaceSpaceApi } from './api/workspace-space-bridge' +import { workspacePortsApi } from './api/workspace-ports-bridge' +import { ptyApi } from './api/pty-bridge' +import { feedbackApi } from './api/feedback-bridge' +import { crashReportsApi } from './api/crash-reports-bridge' +import { exportApi } from './api/export-bridge' +import { ghApi } from './api/gh-bridge' +import { hostedReviewApi } from './api/hosted-review-bridge' +import { glApiBridge } from './api/gl-bridge' +import { bitbucketApi } from './api/bitbucket-bridge' +import { linearApi } from './api/linear-bridge' +import { jiraApi } from './api/jira-bridge' +import { starNagApi } from './api/star-nag-bridge' +import { diagnosticsApi } from './api/diagnostics-bridge' +import { settingsApi } from './api/settings-bridge' +import { agentAwakeApi } from './api/agent-awake-bridge' +import { localhostWorktreeLabelsApi } from './api/localhost-worktree-labels-bridge' +import { keybindingsApi } from './api/keybindings-bridge' +import { codexAccountsApi } from './api/codex-accounts-bridge' +import { claudeAccountsApi } from './api/claude-accounts-bridge' +import { cliApi } from './api/cli-bridge' +import { codexConfigSyncApi } from './api/codex-config-sync-bridge' +import { agentTrustApi } from './api/agent-trust-bridge' +import { preflightApi } from './api/preflight-bridge' +import { notificationsApi } from './api/notifications-bridge' +import { onboardingApi } from './api/onboarding-bridge' +import { dashboardApi } from './api/dashboard-bridge' +import { terminalPreviewApi } from './api/terminal-preview-bridge' +import { macosTccPromptsApi } from './api/macos-tcc-prompts-bridge' +import { developerPermissionsApi } from './api/developer-permissions-bridge' +import { computerUsePermissionsApi } from './api/computer-use-permissions-bridge' +import { shellApi } from './api/shell-bridge' +import { skillsApi } from './api/skills-bridge' +import { petApi } from './api/pet-bridge' +import { browserApi } from './api/browser-bridge' +import { emulatorApi } from './api/emulator-bridge' +import { hooksApi } from './api/hooks-bridge' +import { ephemeralVmApi } from './api/ephemeral-vm-bridge' +import { cacheApi } from './api/cache-bridge' +import { sessionApi } from './api/session-bridge' +import { remoteWorkspaceApi } from './api/remote-workspace-bridge' +import { updaterApi } from './api/updater-bridge' +import { docPreviewApi } from './api/doc-preview-bridge' +import { notebookApi } from './api/notebook-bridge' +import { fsApi } from './api/fs-bridge' +import { gitApi } from './api/git-bridge' +import { uiApi } from './api/ui-bridge' +import { statsApi } from './api/stats-bridge' +import { memoryApi } from './api/memory-bridge' +import { claudeUsageApi } from './api/claude-usage-bridge' +import { codexUsageApi } from './api/codex-usage-bridge' +import { openCodeUsageApi } from './api/open-code-usage-bridge' +import { aiVaultApi } from './api/ai-vault-bridge' +import { nativeChatApi } from './api/native-chat-bridge' +import { runtimeApi } from './api/runtime-bridge' +import { runtimeEnvironmentsApi } from './api/runtime-environments-bridge' +import { rateLimitsApi } from './api/rate-limits-bridge' +import { minimaxCredentialsApi } from './api/minimax-credentials-bridge' +import { grokAccountsApi } from './api/grok-accounts-bridge' +import { sshApi } from './api/ssh-bridge' +import { automationsApi } from './api/automations-bridge' +import { e2eApi } from './api/e2e-bridge' +import { mobileApi } from './api/mobile-bridge' +import { agentStatusApi } from './api/agent-status-bridge' +import { speechApi } from './api/speech-bridge' -// Why: the sync checkpoint only stages; this joins its durable write so a -// navigating path can abort instead of losing the staged session. -async function awaitBeforeUnloadCheckpoint(): Promise { - const result = (await ipcRenderer.invoke('app:await-before-unload-checkpoint')) as { - ok?: unknown - } - if (result?.ok !== true) { - throw new Error('Failed to persist renderer state before unload.') - } -} +installNativeFileDropHandlers() +installBrowserFindListener() -type NativeFileDropCallback = (data: NativeFileDropPayload) => void +// Custom APIs for renderer. Each domain bridge owns its IPC contract. +const telemetryTrackApi: PreloadApi['telemetryTrack'] = (name, props) => + ipcRenderer.invoke('telemetry:track', name, props) +const telemetrySetOptInApi: PreloadApi['telemetrySetOptIn'] = (optedIn) => + ipcRenderer.invoke('telemetry:setOptIn', optedIn) +const telemetryAcknowledgeBannerApi: PreloadApi['telemetryAcknowledgeBanner'] = () => + ipcRenderer.invoke('telemetry:acknowledgeBanner') +const telemetryGetConsentStateApi: PreloadApi['telemetryGetConsentState'] = () => + ipcRenderer.invoke('telemetry:getConsentState') -const nativeFileDropCallbacks: NativeFileDropCallback[] = [] -let nativeFileDropListenerRegistered = false -const updaterQuitAbortRelay = createUpdaterQuitAbortRelay( - window, - ORCA_UPDATER_QUIT_AND_INSTALL_ABORTED_EVENT -) - -registerRendererRestartIpcRelays(ipcRenderer, window, updaterQuitAbortRelay) - -function getLinuxDisplayServer(): 'wayland' | 'x11' | null { - if (process.platform !== 'linux') { - return null - } - if ( - process.env.WAYLAND_DISPLAY || - process.env.XDG_SESSION_TYPE?.toLowerCase() === 'wayland' || - process.env.ELECTRON_OZONE_PLATFORM_HINT?.toLowerCase() === 'wayland' - ) { - return 'wayland' - } - return process.env.DISPLAY ? 'x11' : null -} - -const onNativeFileDrop = (_event: Electron.IpcRendererEvent, data: NativeFileDropPayload): void => { - for (const callback of Array.from(nativeFileDropCallbacks)) { - callback(data) - } -} - -function subscribeNativeFileDrop(callback: NativeFileDropCallback): () => void { - nativeFileDropCallbacks.push(callback) - if (!nativeFileDropListenerRegistered) { - // Why: keep one real IPC listener and fan out locally — panes subscribe per split group, which would otherwise trip listener warnings. - ipcRenderer.on('terminal:file-drop', onNativeFileDrop) - nativeFileDropListenerRegistered = true - } - return () => { - const callbackIndex = nativeFileDropCallbacks.indexOf(callback) - if (callbackIndex !== -1) { - nativeFileDropCallbacks.splice(callbackIndex, 1) - } - if (nativeFileDropCallbacks.length === 0 && nativeFileDropListenerRegistered) { - ipcRenderer.removeListener('terminal:file-drop', onNativeFileDrop) - nativeFileDropListenerRegistered = false - } - } -} - -// Why: cache one shared Audio + blob URL per sound path so we don't re-read 10MB from disk and re-transfer over IPC on every notification. -let cachedNotificationSound: { - path: string - blobUrl: string - audio: HTMLAudioElement -} | null = null -let isNotificationSoundPlaying = false -// Why: audio.play() can reject before ended/error fires — cleanup hook prevents leaked listeners on the cached Audio. -let cleanupNotificationSoundPlayback: (() => void) | null = null - -function clearNotificationSoundPlaybackState(): void { - cleanupNotificationSoundPlayback?.() - cleanupNotificationSoundPlayback = null - isNotificationSoundPlaying = false -} - -function disposeCachedNotificationSound(): void { - if (cachedNotificationSound) { - clearNotificationSoundPlaybackState() - cachedNotificationSound.audio.pause() - cachedNotificationSound.audio.src = '' - URL.revokeObjectURL(cachedNotificationSound.blobUrl) - cachedNotificationSound = null - } -} - -/** - * Classify which UI surface the native OS drop landed on, and for file-explorer drops - * extract the destination directory from `data-native-file-drop-dir`. - * - * Why: preload consumes the native `drop` before React can read paths, so it must capture - * the destination dir now — otherwise the renderer can't tell "root" from "inside this folder". - */ -function resolveNativeFileDrop(event: DragEvent): NativeDropResolution | null { - const pathEntries: NativeFileDropPathEntry[] = [] - for (const entry of event.composedPath()) { - if (entry instanceof HTMLElement) { - pathEntries.push({ - nativeFileDropTarget: entry.dataset.nativeFileDropTarget, - nativeFileDropDir: entry.dataset.nativeFileDropDir, - terminalTabId: entry.dataset.terminalTabId, - terminalPaneLeafId: entry.dataset.terminalPaneLeafId ?? entry.dataset.leafId - }) - } - } - return resolveNativeFileDropPath(pathEntries) -} - -// File drag-and-drop lives in preload because webUtils (File→path) is only available in the preload/main world, not the renderer's isolated world. -document.addEventListener( - 'dragover', - (e) => { - // Let in-app drags through to React handlers (their own dropEffect); only override for native OS file drops. - if (e.dataTransfer && !hasNativeFileDragTypes(e.dataTransfer.types)) { - return - } - e.preventDefault() - if (e.dataTransfer) { - e.dataTransfer.dropEffect = 'copy' - } - }, - true -) - -document.addEventListener( - 'drop', - (e) => { - // Let in-app drags (e.g. file explorer → terminal) through to React handlers - if (e.dataTransfer?.types.includes(ORCA_INTERNAL_FILE_DRAG_TYPE)) { - return - } - - e.preventDefault() - e.stopPropagation() - const files = e.dataTransfer?.files - if (!files || files.length === 0) { - return - } - const resolution = resolveNativeFileDrop(e) - - // Why: reject oversized gestures by count before resolving every File object (path resolution is synchronous here). - if (files.length > NATIVE_FILE_DROP_MAX_PATHS) { - ipcRenderer.send( - 'terminal:file-dropped-from-preload', - createRejectedNativeFileDropPayload({ - byteLength: 0, - pathCount: files.length, - reason: 'too-many-paths', - status: 'rejected' - }) - ) - return - } - - const paths: string[] = [] - for (let i = 0; i < files.length; i++) { - // webUtils.getPathForFile is the Electron 28+ replacement for File.path - const filePath = webUtils.getPathForFile(files[i]) - if (filePath) { - paths.push(filePath) - } - } - - if (paths.length === 0) { - return - } - - // Why: explorer marker present but no destination dir resolved → reject entirely, no editor fallback (fail-closed, design §7.1). - if (resolution?.target === 'rejected') { - return - } - - const payload = createNativeFileDropPayload(resolution, paths) - if (!payload) { - return - } - // Why: emit exactly one native-drop event per gesture (the shared planner rejects oversized payloads without leaking path contents). - ipcRenderer.send('terminal:file-dropped-from-preload', payload) - }, - true -) - -const startupDiagnosticsEnabled = process.env.ORCA_STARTUP_DIAGNOSTICS === '1' -const browserFindSubscriptions = createBrowserFindSubscriptions() -const browserClientPageRendererRequests = createBrowserClientPageRendererRequests({ - ipc: ipcRenderer, - isTopFrame: () => window.top === window -}) - -ipcRenderer.on('ui:findInBrowserPage', (_event, source: unknown) => { - browserFindSubscriptions.dispatch(source) -}) - -// Custom APIs for renderer const api = { - app: { - getIdentity: (): Promise => ipcRenderer.invoke('app:getIdentity'), - getFeatureWallAssetBaseUrl: (): Promise => - ipcRenderer.invoke('app:getFeatureWallAssetBaseUrl'), - relaunch: (): Promise => - prepareAndInvokeAppRestart( - window, - () => ipcRenderer.invoke('app:relaunch'), - awaitBeforeUnloadCheckpoint - ), - restart: (): Promise => - prepareAndInvokeAppRestart( - window, - () => ipcRenderer.invoke('app:restart'), - awaitBeforeUnloadCheckpoint - ), - reload: (): Promise => - prepareAndInvokeAppRestart( - window, - () => ipcRenderer.invoke('app:reload'), - awaitBeforeUnloadCheckpoint - ), - stageBeforeUnloadSync: (args: Parameters[0]) => { - const result = ipcRenderer.sendSync('app:stage-before-unload-sync', args) as { - ok?: unknown - } - if (result?.ok !== true) { - throw new Error('Failed to stage renderer state before unload.') - } - }, - awaitFirstWindowStartupServices: (): Promise => - ipcRenderer.invoke('app:awaitFirstWindowStartupServices'), - prepareTerminalStartupRestoration: (): Promise => - ipcRenderer.invoke('app:prepareTerminalStartupRestoration'), - recoverLegacyWorkerTerminalsForRendererStartup: (): Promise => - ipcRenderer.invoke('app:recoverLegacyWorkerTerminalsForRendererStartup'), - startupDiagnostic: (event: string, details?: Record): Promise => - startupDiagnosticsEnabled - ? ipcRenderer.invoke('app:startupDiagnostic', event, details) - : Promise.resolve(), - // Why: macOS input mode (or layout ID) so keyboard workarounds can tell CJK/compose layouts from US QWERTY (issue #1205); null on non-Darwin or read failure. - getKeyboardInputSourceId: (): Promise => - ipcRenderer.invoke('app:getKeyboardInputSourceId'), - getMacCapturedDigitRowChords: (): Promise => - ipcRenderer.invoke('app:getMacCapturedDigitRowChords'), - getKeyboardLayoutSnapshot: () => ipcRenderer.invoke('app:getKeyboardLayoutSnapshot'), - onKeyboardLayoutChanged: ( - callback: (event: KeyboardLayoutChangeEvent) => void - ): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - event: KeyboardLayoutChangeEvent - ): void => callback(event) - ipcRenderer.on(KEYBOARD_LAYOUT_CHANGED_CHANNEL, listener) - return () => ipcRenderer.removeListener(KEYBOARD_LAYOUT_CHANGED_CHANNEL, listener) - }, - setUnreadDockBadgeCount: (count: number): Promise => - ipcRenderer.invoke('app:setUnreadDockBadgeCount', count), - getFloatingTerminalCwd: (args?: FloatingTerminalCwdRequest): Promise => - ipcRenderer.invoke('app:getFloatingTerminalCwd', args), - getFloatingMarkdownDirectory: (): Promise => - ipcRenderer.invoke('app:getFloatingMarkdownDirectory'), - pickFloatingMarkdownDocument: (): Promise => - ipcRenderer.invoke('app:pickFloatingMarkdownDocument'), - pickFloatingWorkspaceDirectory: (): Promise => - ipcRenderer.invoke('app:pickFloatingWorkspaceDirectory'), - writeTerminalRenderDesyncEvidence: (args: WriteTerminalRenderDesyncEvidenceArgs) => - ipcRenderer.invoke('terminal:writeRenderDesyncEvidence', args) - }, + app: appApi, + orcaProfiles: orcaProfilesApi, + platform: platformApi, + wsl: wslApi, + pwsh: pwshApi, + gitBash: gitBashApi, + plugins: pluginsApi, + repos: reposApi, + projects: projectsApi, + projectGroups: projectGroupsApi, + folderWorkspaces: folderWorkspacesApi, + sparsePresets: sparsePresetsApi, + worktrees: worktreesApi, + workspaceCleanup: workspaceCleanupApi, + workspaceSpace: workspaceSpaceApi, + workspacePorts: workspacePortsApi, + pty: ptyApi, + feedback: feedbackApi, + crashReports: crashReportsApi, + export: exportApi, + gh: ghApi, + hostedReview: hostedReviewApi, + gl: glApiBridge, + bitbucket: bitbucketApi, + linear: linearApi, + jira: jiraApi, + starNag: starNagApi, + telemetryTrack: telemetryTrackApi, + telemetrySetOptIn: telemetrySetOptInApi, + telemetryAcknowledgeBanner: telemetryAcknowledgeBannerApi, + telemetryGetConsentState: telemetryGetConsentStateApi, + diagnostics: diagnosticsApi, + settings: settingsApi, + agentAwake: agentAwakeApi, + localhostWorktreeLabels: localhostWorktreeLabelsApi, + keybindings: keybindingsApi, + codexAccounts: codexAccountsApi, + claudeAccounts: claudeAccountsApi, + cli: cliApi, + codexConfigSync: codexConfigSyncApi, + agentTrust: agentTrustApi, + preflight: preflightApi, + notifications: notificationsApi, + onboarding: onboardingApi, + dashboard: dashboardApi, + terminalPreview: terminalPreviewApi, + macosTccPrompts: macosTccPromptsApi, + developerPermissions: developerPermissionsApi, + computerUsePermissions: computerUsePermissionsApi, + shell: shellApi, + skills: skillsApi, + pet: petApi, + browser: browserApi, + emulator: emulatorApi, + hooks: hooksApi, + ephemeralVm: ephemeralVmApi, + cache: cacheApi, + session: sessionApi, + remoteWorkspace: remoteWorkspaceApi, + updater: updaterApi, + docPreview: docPreviewApi, + notebook: notebookApi, + fs: fsApi, + git: gitApi, + ui: uiApi, + stats: statsApi, + memory: memoryApi, + claudeUsage: claudeUsageApi, + codexUsage: codexUsageApi, + openCodeUsage: openCodeUsageApi, + aiVault: aiVaultApi, + nativeChat: nativeChatApi, + runtime: runtimeApi, + runtimeEnvironments: runtimeEnvironmentsApi, + rateLimits: rateLimitsApi, + minimaxCredentials: minimaxCredentialsApi, + grokAccounts: grokAccountsApi, + ssh: sshApi, + automations: automationsApi, + e2e: e2eApi, + mobile: mobileApi, + agentStatus: agentStatusApi, + speech: speechApi +} satisfies PreloadApi - orcaProfiles: { - list: () => ipcRenderer.invoke('orcaProfiles:list'), - authStatus: () => ipcRenderer.invoke('orcaProfiles:authStatus'), - createLocal: (args) => ipcRenderer.invoke('orcaProfiles:createLocal', args), - createCloudLinked: (args) => ipcRenderer.invoke('orcaProfiles:createCloudLinked', args), - switchProfile: (args) => ipcRenderer.invoke('orcaProfiles:switch', args), - transferProject: (args) => ipcRenderer.invoke('orcaProfiles:transferProject', args), - findProjectProfiles: (args) => ipcRenderer.invoke('orcaProfiles:findProjectProfiles', args), - connectCurrent: () => ipcRenderer.invoke('orcaProfiles:connectCurrent'), - refreshAuth: () => ipcRenderer.invoke('orcaProfiles:refreshAuth'), - signOutCurrent: () => ipcRenderer.invoke('orcaProfiles:signOutCurrent'), - selectOrg: (args) => ipcRenderer.invoke('orcaProfiles:selectOrg', args), - orgMembersList: (args) => ipcRenderer.invoke('orcaProfiles:orgMembersList', args), - orgMemberInvite: (args) => ipcRenderer.invoke('orcaProfiles:orgMemberInvite', args), - orgInviteRevoke: (args) => ipcRenderer.invoke('orcaProfiles:orgInviteRevoke', args), - orgMemberChangeRole: (args) => ipcRenderer.invoke('orcaProfiles:orgMemberChangeRole', args), - orgMemberRemove: (args) => ipcRenderer.invoke('orcaProfiles:orgMemberRemove', args) - } satisfies PreloadApi['orcaProfiles'], - - platform: { - get: () => ({ - platform: process.platform, - // Why: sandboxed preload cannot require node:os; Electron exposes the OS - // version on process.getSystemVersion when available. - osRelease: - (process as NodeJS.Process & { getSystemVersion?: () => string }).getSystemVersion?.() ?? - '', - arch: process.arch, - // Why: these identify the default shell without probing user config files. - // process.env is available in the sandboxed preload; node:os is not. - shell: process.env.SHELL?.trim() || process.env.ComSpec?.trim() || '', - displayServer: getLinuxDisplayServer() - }) - } satisfies PreloadApi['platform'], - - wsl: { - isAvailable: (): Promise => ipcRenderer.invoke('wsl:isAvailable'), - listDistros: (): Promise => ipcRenderer.invoke('wsl:listDistros') - }, - - pwsh: { - isAvailable: (): Promise => ipcRenderer.invoke('pwsh:isAvailable') - }, - - gitBash: { - isAvailable: (): Promise => ipcRenderer.invoke('gitBash:isAvailable') - }, - - plugins: { - list: (): Promise => ipcRenderer.invoke('plugins:list'), - listLanguagePacks: () => ipcRenderer.invoke('plugins:listLanguagePacks'), - consent: (args: PluginConsentRequest): Promise => - ipcRenderer.invoke('plugins:consent', args), - setEnabled: (args: { pluginKey: string; enabled: boolean }): Promise => - ipcRenderer.invoke('plugins:setEnabled', args), - readPanelEntry: (args: { - pluginKey: string - panelId: string - }): Promise => ipcRenderer.invoke('plugins:readPanelEntry', args), - invokeCommand: (args: { - pluginKey: string - commandId: string - args?: unknown - }): Promise => ipcRenderer.invoke('plugins:invokeCommand', args), - panelAction: (args: { - sessionToken: string - action: string - params?: unknown - }): Promise => ipcRenderer.invoke('plugins:panelAction', args), - install: (source: PluginHostInstallSource): Promise => - ipcRenderer.invoke('plugins:install', source), - listMarketplaces: () => ipcRenderer.invoke('plugins:listMarketplaces'), - addMarketplace: (source) => ipcRenderer.invoke('plugins:addMarketplace', source), - removeMarketplace: (args) => ipcRenderer.invoke('plugins:removeMarketplace', args), - refreshMarketplaces: (args = {}) => ipcRenderer.invoke('plugins:refreshMarketplaces', args), - listMarketplacePlugins: () => ipcRenderer.invoke('plugins:listMarketplacePlugins'), - previewMarketplacePlugin: (args) => - ipcRenderer.invoke('plugins:previewMarketplacePlugin', args), - installMarketplacePlugin: (preview) => - ipcRenderer.invoke('plugins:installMarketplacePlugin', preview), - previewMarketplaceUpdate: (args) => - ipcRenderer.invoke('plugins:previewMarketplaceUpdate', args), - rollbackMarketplacePlugin: (args) => - ipcRenderer.invoke('plugins:rollbackMarketplacePlugin', args), - remove: (args: { pluginKey: string }): Promise => - ipcRenderer.invoke('plugins:remove', args), - getLogs: (args: { pluginKey: string }): Promise => - ipcRenderer.invoke('plugins:getLogs', args), - refresh: (): Promise => ipcRenderer.invoke('plugins:refresh'), - onChanged: (callback): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, change: PluginChangeEvent): void => - callback(change) - ipcRenderer.on('plugins:changed', listener) - return () => { - ipcRenderer.removeListener('plugins:changed', listener) - } - } - } satisfies PreloadApi['plugins'], - - repos: { - list: () => ipcRenderer.invoke('repos:list'), - - listForExecutionHost: (args: ListReposForExecutionHostArgs): Promise => - ipcRenderer.invoke('repos:listForExecutionHost', args), - - add: (args) => ipcRenderer.invoke('repos:add', args), - - addRemote: (args) => ipcRenderer.invoke('repos:addRemote', args), - - create: (args) => ipcRenderer.invoke('repos:create', args), - - isGitAvailable: (): Promise => ipcRenderer.invoke('repos:isGitAvailable'), - - getDefaultCreateProjectParent: (): Promise => - ipcRenderer.invoke('repos:getDefaultCreateProjectParent'), - - remove: (args) => ipcRenderer.invoke('repos:remove', args), - - removeForHost: (args) => ipcRenderer.invoke('repos:removeForHost', args), - - reorder: (args) => ipcRenderer.invoke('repos:reorder', args), - - reorderForHost: (args) => ipcRenderer.invoke('repos:reorderForHost', args), - - update: (args) => ipcRenderer.invoke('repos:update', args), - - pickFolder: () => ipcRenderer.invoke('repos:pickFolder'), - - pickFolders: () => ipcRenderer.invoke('repos:pickFolders'), - - pickDirectory: () => ipcRenderer.invoke('repos:pickDirectory'), - - clone: (args) => ipcRenderer.invoke('repos:clone', args), - - cloneRemote: (args) => ipcRenderer.invoke('repos:cloneRemote', args), - - createRemote: (args) => ipcRenderer.invoke('repos:createRemote', args), - - cloneAbort: () => ipcRenderer.invoke('repos:cloneAbort'), - - onCloneProgress: ( - callback: (data: { phase: string; percent: number }) => void - ): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - data: { phase: string; percent: number } - ) => callback(data) - ipcRenderer.on('repos:clone-progress', listener) - return () => ipcRenderer.removeListener('repos:clone-progress', listener) - }, - - getGitUsername: (args: { repoId: string }): Promise => - ipcRenderer.invoke('repos:getGitUsername', args), - - getBaseRefDefault: (args: { - repoId: string - hostId?: ExecutionHostId - }): Promise => ipcRenderer.invoke('repos:getBaseRefDefault', args), - - searchBaseRefs: (args: { - repoId: string - query: string - limit?: number - hostId?: ExecutionHostId - }): Promise => ipcRenderer.invoke('repos:searchBaseRefs', args), - - searchBaseRefDetails: (args: { - repoId: string - query: string - limit?: number - hostId?: ExecutionHostId - }): Promise => ipcRenderer.invoke('repos:searchBaseRefDetails', args), - - onChanged: (callback: () => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent) => callback() - ipcRenderer.on('repos:changed', listener) - return () => ipcRenderer.removeListener('repos:changed', listener) - } - } satisfies PreloadApi['repos'], - - projects: { - list: () => ipcRenderer.invoke('projects:list'), - update: (args) => ipcRenderer.invoke('projects:update', args), - listHostSetups: () => ipcRenderer.invoke('projectHostSetups:list'), - createHostSetup: (args) => ipcRenderer.invoke('projectHostSetups:create', args), - setupExistingFolder: (args) => - ipcRenderer.invoke('projectHostSetups:setupExistingFolder', args), - updateHostSetup: (args) => ipcRenderer.invoke('projectHostSetups:update', args), - deleteHostSetup: (args) => ipcRenderer.invoke('projectHostSetups:delete', args) - } satisfies PreloadApi['projects'], - - projectGroups: { - list: () => ipcRenderer.invoke('projectGroups:list'), - create: (args) => ipcRenderer.invoke('projectGroups:create', args), - update: (args) => ipcRenderer.invoke('projectGroups:update', args), - delete: (args) => ipcRenderer.invoke('projectGroups:delete', args), - moveProject: (args) => ipcRenderer.invoke('projectGroups:moveProject', args), - scanNested: (args) => ipcRenderer.invoke('projectGroups:scanNested', args), - cancelNestedScan: (args) => ipcRenderer.invoke('projectGroups:cancelNestedScan', args), - onNestedScanProgress: (callback) => { - const listener = ( - _event: Electron.IpcRendererEvent, - data: { scanId: string; scan: NestedRepoScanResult } - ) => callback(data) - ipcRenderer.on('projectGroups:scanNestedProgress', listener) - return () => ipcRenderer.removeListener('projectGroups:scanNestedProgress', listener) - }, - importNested: (args) => ipcRenderer.invoke('projectGroups:importNested', args) - } satisfies PreloadApi['projectGroups'], - - folderWorkspaces: { - list: () => ipcRenderer.invoke('folderWorkspaces:list'), - getPathStatus: (args) => ipcRenderer.invoke('folderWorkspaces:getPathStatus', args), - create: (args) => ipcRenderer.invoke('folderWorkspaces:create', args), - update: (args) => ipcRenderer.invoke('folderWorkspaces:update', args), - delete: (args) => ipcRenderer.invoke('folderWorkspaces:delete', args) - } satisfies PreloadApi['folderWorkspaces'], - - sparsePresets: { - list: (args) => ipcRenderer.invoke('sparsePresets:list', args), - - save: (args) => ipcRenderer.invoke('sparsePresets:save', args), - - remove: (args) => ipcRenderer.invoke('sparsePresets:remove', args), - - onChanged: (callback: (data: { repoId: string }) => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, data: { repoId: string }) => - callback(data) - ipcRenderer.on('sparsePresets:changed', listener) - return () => ipcRenderer.removeListener('sparsePresets:changed', listener) - } - } satisfies PreloadApi['sparsePresets'], - - worktrees: { - list: (args) => ipcRenderer.invoke('worktrees:list', args), - listRetiredNames: (args) => ipcRenderer.invoke('worktrees:listRetiredNames', args), - - listDetected: (args) => ipcRenderer.invoke('worktrees:listDetected', args), - - listKnownForExecutionHost: (args) => - ipcRenderer.invoke('worktrees:listKnownForExecutionHost', args), - - forgetRemovedForExecutionHost: (args) => - ipcRenderer.invoke('worktrees:forgetRemovedForExecutionHost', args), - - cancelListDetected: (args) => ipcRenderer.invoke('worktrees:cancelListDetected', args), - - listAll: () => ipcRenderer.invoke('worktrees:listAll'), - - create: (args) => ipcRenderer.invoke('worktrees:create', args), - - adoptProvisionedRoot: (args) => ipcRenderer.invoke('worktrees:adoptProvisionedRoot', args), - - onCreateProgress: ( - callback: (data: { creationId?: string; phase: 'fetching' | 'creating' }) => void - ): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - data: { creationId?: string; phase: 'fetching' | 'creating' } - ) => callback(data) - ipcRenderer.on('createWorktree:progress', listener) - return () => ipcRenderer.removeListener('createWorktree:progress', listener) - }, - - prefetchCreateBase: (args) => ipcRenderer.invoke('worktrees:prefetchCreateBase', args), - - resolvePrBase: (args) => ipcRenderer.invoke('worktrees:resolvePrBase', args), - - resolveMrBase: (args) => ipcRenderer.invoke('worktrees:resolveMrBase', args), - - remove: (args) => ipcRenderer.invoke('worktrees:remove', args), - - forgetLocal: (args) => ipcRenderer.invoke('worktrees:forgetLocal', args), - - forceDeletePreservedBranch: (args) => - ipcRenderer.invoke('worktrees:forceDeletePreservedBranch', args), - - updateMeta: (args) => ipcRenderer.invoke('worktrees:updateMeta', args), - - listLineage: () => ipcRenderer.invoke('worktrees:listLineage'), - - listLineageForHost: (args: ListDesktopLineageForHostArgs): Promise => - ipcRenderer.invoke('worktrees:listLineageForHost', args), - - updateLineage: (args) => ipcRenderer.invoke('worktrees:updateLineage', args), - - persistSortOrder: (args) => ipcRenderer.invoke('worktrees:persistSortOrder', args), - - getBranchRenameFailureOutput: (args) => - ipcRenderer.invoke('worktrees:getBranchRenameFailureOutput', args), - - onChanged: ( - callback: (data: { - repoId: string - renamed?: { oldWorktreeId: string; newWorktreeId: string } - }) => void - ): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - data: { repoId: string; renamed?: { oldWorktreeId: string; newWorktreeId: string } } - ) => callback(data) - ipcRenderer.on('worktrees:changed', listener) - return () => ipcRenderer.removeListener('worktrees:changed', listener) - }, - - onGitStatusMetadataChanged: (callback: (data: { repoId: string }) => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, data: { repoId: string }) => - callback(data) - ipcRenderer.on('worktrees:gitStatusMetadataChanged', listener) - return () => ipcRenderer.removeListener('worktrees:gitStatusMetadataChanged', listener) - }, - - onHeadIdentitiesChanged: ( - callback: (data: { repoId: string; identities: WorktreeHeadIdentity[] }) => void - ): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - data: { repoId: string; identities: WorktreeHeadIdentity[] } - ) => callback(data) - ipcRenderer.on('worktrees:headIdentitiesChanged', listener) - return () => ipcRenderer.removeListener('worktrees:headIdentitiesChanged', listener) - }, - - onBaseStatus: (callback: (data: WorktreeBaseStatusEvent) => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, data: WorktreeBaseStatusEvent) => - callback(data) - ipcRenderer.on('worktree:baseStatus', listener) - return () => ipcRenderer.removeListener('worktree:baseStatus', listener) - }, - - onRemoteBranchConflict: ( - callback: (data: WorktreeRemoteBranchConflictEvent) => void - ): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - data: WorktreeRemoteBranchConflictEvent - ) => callback(data) - ipcRenderer.on('worktree:remoteBranchConflict', listener) - return () => ipcRenderer.removeListener('worktree:remoteBranchConflict', listener) - } - } satisfies PreloadApi['worktrees'], - - workspaceCleanup: { - scan: (args, onProgress) => { - if (!onProgress) { - return ipcRenderer.invoke('workspaceCleanup:scan', args) - } - const scanId = args?.scanId ?? crypto.randomUUID() - const listener = ( - _event: Electron.IpcRendererEvent, - progress: WorkspaceCleanupScanProgress - ): void => { - if (progress.scanId === scanId) { - onProgress(progress) - } - } - ipcRenderer.on('workspaceCleanup:scanProgress', listener) - return ipcRenderer - .invoke('workspaceCleanup:scan', { ...args, scanId }) - .finally(() => ipcRenderer.removeListener('workspaceCleanup:scanProgress', listener)) - }, - cancelScan: (scanId) => ipcRenderer.invoke('workspaceCleanup:cancelScan', scanId), - getCachedScan: () => ipcRenderer.invoke('workspaceCleanup:getCachedScan'), - dismiss: (args) => ipcRenderer.invoke('workspaceCleanup:dismiss', args), - clearDismissals: () => ipcRenderer.invoke('workspaceCleanup:clearDismissals'), - hasKillableLocalProcesses: (args) => - ipcRenderer.invoke('workspaceCleanup:hasKillableLocalProcesses', args), - beginRemovalSnapshotPruneBatch: (args) => - ipcRenderer.invoke('workspaceCleanup:beginRemovalSnapshotPruneBatch', args), - recordRemovalSnapshotPrune: (args) => - ipcRenderer.invoke('workspaceCleanup:recordRemovalSnapshotPrune', args), - finishRemovalSnapshotPruneBatch: (args) => - ipcRenderer.invoke('workspaceCleanup:finishRemovalSnapshotPruneBatch', args) - } satisfies PreloadApi['workspaceCleanup'], - - workspaceSpace: { - analyze: () => ipcRenderer.invoke('workspaceSpace:analyze'), - getCachedAnalysis: () => ipcRenderer.invoke('workspaceSpace:getCachedAnalysis'), - cancel: () => ipcRenderer.invoke('workspaceSpace:cancel'), - onProgress: (callback) => { - const listener = ( - _event: Electron.IpcRendererEvent, - progress: WorkspaceSpaceScanProgress - ): void => callback(progress) - ipcRenderer.on('workspaceSpace:progress', listener) - return () => ipcRenderer.removeListener('workspaceSpace:progress', listener) - } - } satisfies PreloadApi['workspaceSpace'], - - workspacePorts: { - scan: (args) => ipcRenderer.invoke('workspacePorts:scan', args), - kill: (args) => ipcRenderer.invoke('workspacePorts:kill', args), - onAdvertisedUrlChanged: (callback) => { - const listener = ( - _event: Electron.IpcRendererEvent, - event: WorkspacePortAdvertisedUrlChangedEvent - ): void => callback(event) - ipcRenderer.on('workspacePorts:advertised-url-changed', listener) - return () => ipcRenderer.removeListener('workspacePorts:advertised-url-changed', listener) - } - } satisfies PreloadApi['workspacePorts'], - - pty: { - spawn: (opts: { - cols: number - rows: number - cwd?: string - cwdFallback?: 'worktree' - env?: Record - envToDelete?: string[] - command?: string - commandDelivery?: 'renderer' | 'provider' - launchConfig?: SleepingAgentLaunchConfig - resumeProviderSession?: AgentProviderSessionMetadata - launchToken?: string - launchAgent?: TuiAgent - startupCommandDelivery?: StartupCommandDelivery - connectionId?: string | null - worktreeId?: string - sessionId?: string - shellOverride?: string - projectRuntime?: ProjectExecutionRuntimeResolution - terminalColorQueryReplies?: { foreground?: string; background?: string } - // Why: marks the PTY hidden before its first byte so the delivery gate + model responder own spawn-time queries (terminal-query-authority.md §races). - initiallyHidden?: boolean - // Why: closes the SIGKILL race (INVESTIGATION.md) — main sync-flushes the (worktreeId, tabId, leafId → ptyId) binding before pty:spawn returns. - tabId?: string - leafId?: string - // Why: loose typing on purpose — renderer owns launch metadata, main owns whether the launch happened and validates (telemetry-plan.md §Agent launch semantics). - telemetry?: { agent_kind: AgentKind; launch_source: LaunchSource; request_kind: RequestKind } - }): Promise<{ - id: string - /** Which lifetime of `id` this reply named; absent when the execution host predates the field. */ - incarnationId?: string - launchConfig?: SleepingAgentLaunchConfig - snapshot?: string - snapshotCols?: number - snapshotRows?: number - snapshotPrefixAnsi?: string - snapshotFrameAnsi?: string - snapshotFrameRestoreAnsi?: string - snapshotKittyKeyboardFlags?: number - snapshotTerminalOwner?: 'shell' - snapshotSeq?: number - isReattach?: boolean - isAlternateScreen?: boolean - replay?: string - sessionExpired?: boolean - coldRestore?: { scrollback: string; cwd: string; cols?: number; rows?: number } - startupCwdFallback?: { kind: 'worktree'; cwd: string } - agentResumeUnavailable?: true - }> => ipcRenderer.invoke('pty:spawn', opts), - - write: (id: string, data: string): void => { - ipcRenderer.send('pty:write', { id, data }) - }, - writeAccepted: (id: string, data: string): Promise => - ipcRenderer.invoke('pty:writeAccepted', { id, data }), - onWriteUnavailable: ( - callback: (payload: { - id: string - /** Set only when a durable agent-session lease refused the write; absent otherwise. */ - agentSessionRefusal?: AgentSessionPtyWriteRefusal - }) => void - ): (() => void) => { - const handler = ( - _event: Electron.IpcRendererEvent, - payload: { id: string; agentSessionRefusal?: AgentSessionPtyWriteRefusal } - ): void => callback(payload) - ipcRenderer.on('pty:writeUnavailable', handler) - return () => ipcRenderer.removeListener('pty:writeUnavailable', handler) - }, - - resize: (id: string, cols: number, rows: number): void => { - ipcRenderer.send('pty:resize', { id, cols, rows }) - }, - claimViewport: (id: string, cols: number, rows: number): void => { - ipcRenderer.send('pty:claimViewport', { id, cols, rows }) - }, - - /** Why: measurement-only sibling of resize — keeps the runtime's restore-target baseline fresh while a mobile-fit override blocks pty:resize. Never resizes the PTY. See docs/mobile-fit-hold.md. */ - reportGeometry: (id: string, cols: number, rows: number): void => { - ipcRenderer.send('pty:reportGeometry', { id, cols, rows }) - }, - - signal: (id: string, signal: string): void => { - ipcRenderer.send('pty:signal', { id, signal }) - }, - - /** Why: Cmd/Ctrl+K clears the renderer xterm, but the PTY host keeps its own screen state and would repaint the next prompt at the stale cursor row. */ - clearBuffer: (id: string): void => { - ipcRenderer.send('pty:clearBuffer', { id }) - }, - - ackColdRestore: (id: string): void => { - ipcRenderer.send('pty:ackColdRestore', { id }) - }, - /** charCount is the legacy per-chunk delta; processedChars is the cumulative per-pty total (self-heals under lost ACKs). */ - ackData: (id: string, charCount: number, processedChars?: number): void => { - ipcRenderer.send('pty:ackData', { - id, - charCount, - ...(typeof processedChars === 'number' ? { processedChars } : {}) - }) - }, - /** Main requests the renderer's cumulative processed totals when delivery looks stuck on lost ACKs. */ - onDeliveryResyncRequest: (callback: (payload: { requestId: number }) => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, payload: { requestId: number }) => - callback(payload) - ipcRenderer.on('pty:requestDeliveryResync', listener) - return () => ipcRenderer.removeListener('pty:requestDeliveryResync', listener) - }, - respondDeliveryResync: (payload: { - requestId: number - processedCharsByPty: Record - }): void => { - ipcRenderer.send('pty:deliveryResyncResponse', payload) - }, - /** Renderer-initiated delivery health/heal lane — rides invoke because the field wedge (v1.4.121-rc.0) kills main→renderer push while invoke stays alive. */ - reportRendererDeliveryState: ( - report: PtyRendererDeliveryStateReport - ): Promise => - ipcRenderer.invoke('pty:reportRendererDeliveryState', report), - /** Live pty:data listener count — the watchdog's "listener detached" vs "channel dead" discriminator. */ - getPtyDataListenerCount: (): number => ipcRenderer.listenerCount('pty:data'), - rendererDispatcherReady: (): void => { - ipcRenderer.send('pty:rendererDispatcherReady') - }, - setActiveRendererPty: (id: string, active: boolean): void => { - ipcRenderer.send('pty:setActiveRendererPty', { id, active }) - }, - setRendererPtyVisible: (id: string, visible: boolean): void => { - ipcRenderer.send('pty:setRendererPtyVisible', { id, visible }) - }, - /** Hidden-delivery gate: hidden=true lets main DROP renderer byte delivery after model ingestion; reveal restores from the model snapshot. Fire-and-forget. */ - setHiddenRendererPty: (id: string, hidden: boolean): void => { - ipcRenderer.send('pty:setHiddenRendererPty', { id, hidden }) - }, - /** Delivery-interest signal: a renderer party needing raw bytes suppresses the hidden-delivery gate for that PTY while registered. */ - setPtyDeliveryInterest: (id: string, interested: boolean): void => { - ipcRenderer.send('pty:setPtyDeliveryInterest', { id, interested }) - }, - /** Push composed terminal appearance so main's model responder can answer OSC 4/10/11/12 and DSR ?996n for hidden-gated PTYs with renderer-true values. */ - publishTerminalViewAttributes: (attributes: TerminalViewAttributes): void => { - ipcRenderer.send('pty:terminalViewAttributes', attributes) - }, - - kill: (id: string, opts?: { keepHistory?: boolean }): Promise => - ipcRenderer.invoke('pty:kill', { id, keepHistory: opts?.keepHistory ?? false }), - - listSessions: (): Promise => ipcRenderer.invoke('pty:listSessions'), - getAuthoritativeBufferSnapshotCapabilities: ( - ids: string[] - ): Promise<{ id: string; authoritative: boolean | null }[]> => - ipcRenderer.invoke('pty:getAuthoritativeBufferSnapshotCapabilities', { ids }), - hasPty: (id: string): Promise => ipcRenderer.invoke('pty:hasPty', { id }), - - getMainBufferSnapshot: ( - id: string, - opts?: { scrollbackRows?: number } - ): Promise<{ - data: string - frameRestoreAnsi?: string - cols: number - rows: number - cwd?: string | null - seq?: number - pendingDeliveryStartSeq?: number - source?: 'headless' | 'renderer' - alternateScreen?: boolean - scrollbackAnsi?: string - pendingEscapeTailAnsi?: string - kittyKeyboardFlags?: number - terminalOwner?: 'shell' - } | null> => ipcRenderer.invoke('pty:getMainBufferSnapshot', { id, opts }), - - getRendererDeliveryDebugSnapshot: (): Promise<{ - pendingPtyCount: number - pendingChars: number - maxPendingCharsByPty: number - rendererInFlightPtyCount: number - rendererInFlightChars: number - maxRendererInFlightCharsByPty: number - activeRendererPtyCount: number - flushScheduled: boolean - peakPendingChars: number - peakMaxPendingCharsByPty: number - peakRendererInFlightChars: number - peakMaxRendererInFlightCharsByPty: number - ackGatedFlushSkipCount: number - hiddenDeliveryGatedPtyCount: number - hiddenDeliveryGatedVisiblePtyCount: number - hiddenDeliveryGatedActivePtyCount: number - deliveryInterestPtyCount: number - hiddenDeliveryDroppedChars: number - hiddenDeliveryDroppedChunks: number - pendingDroppedChars: number - diagnostics: PtyMainDeliveryDiagnostics - rendererLifecycleResetCount: number - lastLifecycleResetClearedChars: number - rendererPtyDispatcherReady: boolean - rendererDispatcherReadyForcedCount: number - }> => ipcRenderer.invoke('pty:getRendererDeliveryDebugSnapshot'), - - resetRendererDeliveryDebug: (): Promise => - ipcRenderer.invoke('pty:resetRendererDeliveryDebug'), - - /** True if the PTY's shell has child processes (a running command); false at an idle prompt. */ - hasChildProcesses: (id: string): Promise => - ipcRenderer.invoke('pty:hasChildProcesses', { id }), - - /** Return the PTY foreground process basename when available (e.g. "codex"). */ - getForegroundProcess: (id: string): Promise => - ipcRenderer.invoke('pty:getForegroundProcess', { id }), - inspectProcess: ( - id: string - ): Promise<{ - foregroundProcess: string | null - hasChildProcesses: boolean - unavailable?: true - }> => ipcRenderer.invoke('pty:inspectProcess', { id }), - confirmForegroundProcess: (id: string): Promise => - ipcRenderer.invoke('pty:confirmForegroundProcess', { id }), - - /** Resolve a PTY's live cwd via `/proc` (Linux) or `lsof` (macOS); `''` when unknown or unresolvable. */ - getCwd: (id: string): Promise => ipcRenderer.invoke('pty:getCwd', { id }), - - /** The PTY's last APPLIED size (real winsize), or null if unknown — lets the renderer detect drift after a dropped resize and re-assert. */ - getSize: (id: string): Promise<{ cols: number; rows: number } | null> => - ipcRenderer.invoke('pty:getSize', { id }), - - onData: ( - callback: (data: { - id: string - data: string - seq?: number - rawLength?: number - transformed?: boolean - background?: boolean - droppedOutput?: boolean - }) => void - ): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - data: { - id: string - data: string - seq?: number - rawLength?: number - transformed?: boolean - background?: boolean - droppedOutput?: boolean - } - ) => callback(data) - ipcRenderer.on('pty:data', listener) - return () => ipcRenderer.removeListener('pty:data', listener) - }, - - onReplay: (callback: (data: { id: string; data: string }) => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, data: { id: string; data: string }) => - callback(data) - ipcRenderer.on('pty:replay', listener) - return () => ipcRenderer.removeListener('pty:replay', listener) - }, - - /** Out-of-band signal that main dropped renderer-bound bytes (hidden-gate / pending cap); pane restores from the model snapshot. - * NOT on pty:data — an in-band marker is ambiguous with chunks fully stripped by OSC-9999 cleaning. */ - onModelRestoreNeeded: (callback: (event: PtyModelRestoreNeededEvent) => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, event: PtyModelRestoreNeededEvent) => - callback(event) - ipcRenderer.on('pty:modelRestoreNeeded', listener) - return () => ipcRenderer.removeListener('pty:modelRestoreNeeded', listener) - }, - - /** Batched side-effect facts (title/bell/agent transitions) for local-main PTYs. - * Per-PTY in-order; deliberately NOT synced with pty:data (terminal-side-effect-authority.md). */ - onSideEffect: (callback: (batch: TerminalSideEffectBatch) => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, batch: TerminalSideEffectBatch) => - callback(batch) - ipcRenderer.on('pty:sideEffect', listener) - return () => ipcRenderer.removeListener('pty:sideEffect', listener) - }, - - /** Title-only replay snapshot on (re)attach — attention facts (bells/completions) never replay. */ - getSideEffectSnapshot: (id: string): Promise => - ipcRenderer.invoke('pty:sideEffectSnapshot', { id }), - - onExit: ( - callback: (data: { - id: string - code: number - preserveRendererBinding?: boolean - /** Which lifetime of `id` died; absent when the execution host predates the field. */ - incarnationId?: string - }) => void - ): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - data: { - id: string - code: number - preserveRendererBinding?: boolean - incarnationId?: string - } - ) => callback(data) - ipcRenderer.on('pty:exit', listener) - return () => ipcRenderer.removeListener('pty:exit', listener) - }, - - onSpawned: (callback: (data: { id: string }) => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, data: { id: string }) => callback(data) - ipcRenderer.on('pty:spawned', listener) - return () => ipcRenderer.removeListener('pty:spawned', listener) - }, - - onSerializeBufferRequest: ( - callback: (data: { - requestId: string - ptyId: string - opts?: { scrollbackRows?: number; altScreenForcesZeroRows?: boolean } - }) => void - ): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - data: { - requestId: string - ptyId: string - opts?: { scrollbackRows?: number; altScreenForcesZeroRows?: boolean } - } - ) => callback(data) - ipcRenderer.on('pty:serializeBuffer:request', listener) - return () => ipcRenderer.removeListener('pty:serializeBuffer:request', listener) - }, - - onClearBufferRequest: (callback: (data: { ptyId: string }) => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, data: { ptyId: string }) => - callback(data) - ipcRenderer.on('pty:clearBuffer:request', listener) - return () => ipcRenderer.removeListener('pty:clearBuffer:request', listener) - }, - - sendSerializedBuffer: ( - requestId: string, - snapshot: { - data: string - cols: number - rows: number - seq?: number - lastTitle?: string - kittyKeyboardFlags?: number - } | null - ): void => { - ipcRenderer.send('pty:serializeBuffer:response', { requestId, snapshot }) - }, - - // Claim serializer ownership before spawn; echo the generation token on settle/clear to prevent pane-key reuse races. - declarePendingPaneSerializer: (paneKey: string): Promise => - ipcRenderer.invoke('pty:declarePendingPaneSerializer', { paneKey }), - - settlePaneSerializer: (paneKey: string, gen: number): Promise => - ipcRenderer.invoke('pty:settlePaneSerializer', { paneKey, gen }), - - clearPendingPaneSerializer: (paneKey: string, gen: number): Promise => - ipcRenderer.invoke('pty:clearPendingPaneSerializer', { paneKey, gen }), - - reportRendererSerializerReady: (ptyId: string): Promise => - ipcRenderer.invoke('pty:reportRendererSerializerReady', { ptyId }), - - management: { - listSessions: () => ipcRenderer.invoke('pty:management:listSessions'), - killAll: () => ipcRenderer.invoke('pty:management:killAll'), - killOne: (args: { sessionId: string }) => ipcRenderer.invoke('pty:management:killOne', args), - restart: () => ipcRenderer.invoke('pty:management:restart'), - macTccAttribution: () => ipcRenderer.invoke('pty:management:macTccAttribution') - } - }, - - feedback: { - submit: (args: { - feedback: string - submitAnonymously?: boolean - githubLogin: string | null - githubEmail: string | null - images?: { contentType: string; data: Uint8Array }[] - }): Promise< - { ok: true; imagesDelivered?: boolean } | { ok: false; status: number | null; error: string } - > => ipcRenderer.invoke('feedback:submit', args) - }, - - crashReports: { - getLatestPending: () => ipcRenderer.invoke('crashReports:getLatestPending'), - getLatestReport: () => ipcRenderer.invoke('crashReports:getLatestReport'), - dismiss: (args: { reportId: string }) => ipcRenderer.invoke('crashReports:dismiss', args), - recordRendererError: ( - args: ReactErrorBoundaryReportArgs - ): Promise => - ipcRenderer.invoke('crashReports:recordRendererError', args), - recordBreadcrumb: (args: { name: string; data?: CrashReportBreadcrumbData }): void => - ipcRenderer.send('crashReports:recordBreadcrumb', args), - submit: (args: CrashReportSubmitArgs): Promise => - ipcRenderer.invoke('crashReports:submit', args), - copyLatestDiagnostics: (args?: CrashReportCopyDiagnosticsArgs) => - ipcRenderer.invoke('crashReports:copyLatestDiagnostics', args), - readHeapStatistics: (): RendererHeapStatistics | null => readRendererHeapStatistics(), - readProcessMemory: (): Promise => readRendererProcessMemory() - }, - - export: { - htmlToPdf: (args: { - html: string - title: string - }): Promise< - { success: true; filePath: string } | { success: false; cancelled?: boolean; error?: string } - > => ipcRenderer.invoke('export:html-to-pdf', args) - }, - - gh: { - viewer: (): Promise => ipcRenderer.invoke('gh:viewer'), - - repoSlug: (args: { repoPath: string; repoId?: string }): Promise => - ipcRenderer.invoke('gh:repoSlug', args), - - repoUpstream: (args: { repoPath: string; repoId?: string }): Promise => - ipcRenderer.invoke('gh:repoUpstream', args), - - prForBranch: (args: { - repoPath: string - repoId?: string - branch: string - linkedPRNumber?: number | null - fallbackPRNumber?: number | null - acceptMergedFallbackPR?: boolean - currentHeadOid?: string | null - }): Promise => ipcRenderer.invoke('gh:prForBranch', args), - - refreshPRNow: (args: { - candidate: GitHubPRRefreshCandidate - reason?: GitHubPRRefreshReason - }): Promise => ipcRenderer.invoke('gh:refreshPRNow', args), - - enqueuePRRefresh: (args: { - candidate: GitHubPRRefreshCandidate - reason: GitHubPRRefreshReason - priority?: number - }): Promise => ipcRenderer.invoke('gh:enqueuePRRefresh', args), - - reportVisiblePRRefreshCandidates: (args: { - candidates: GitHubPRRefreshCandidate[] - generation: number - }): Promise => ipcRenderer.invoke('gh:reportVisiblePRRefreshCandidates', args), - - onPRRefreshEvent: (callback: (event: GitHubPRRefreshEvent) => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, event: GitHubPRRefreshEvent): void => - callback(event) - ipcRenderer.on('gh:prRefreshEvent', listener) - return () => ipcRenderer.removeListener('gh:prRefreshEvent', listener) - }, - - issue: (args: { - repoPath: string - repoId?: string - sourceContext?: TaskSourceContext | null - number: number - }): Promise => ipcRenderer.invoke('gh:issue', args), - - workItem: (args: { - repoPath: string - repoId?: string - sourceContext?: TaskSourceContext | null - number: number - type?: 'issue' | 'pr' - }): Promise => ipcRenderer.invoke('gh:workItem', args), - - workItemByOwnerRepo: (args: { - repoPath: string - repoId?: string - owner: string - repo: string - host?: string - number: number - type: 'issue' | 'pr' - }): Promise => ipcRenderer.invoke('gh:workItemByOwnerRepo', args), - - workItemDetails: (args: { - repoPath: string - repoId?: string - sourceContext?: TaskSourceContext | null - number: number - type?: 'issue' | 'pr' - }): Promise => ipcRenderer.invoke('gh:workItemDetails', args), - - notifyWorkItemMutated: (args: { - repoPath: string - repoId?: string - type: 'issue' | 'pr' - number: number - }): Promise => ipcRenderer.invoke('gh:notifyWorkItemMutated', args), - - prFileContents: (args: { - repoPath: string - repoId?: string - sourceContext?: TaskSourceContext | null - prNumber: number - prRepo?: GitHubOwnerRepo | null - path: string - oldPath?: string - status: string - headSha: string - baseSha: string - }): Promise => ipcRenderer.invoke('gh:prFileContents', args), - - listIssues: (args: { repoPath: string; repoId?: string; limit?: number }): Promise => - ipcRenderer.invoke('gh:listIssues', args), - - createIssue: (args: { - repoPath: string - repoId?: string - sourceContext?: TaskSourceContext | null - title: string - body: string - labels?: string[] - assignees?: string[] - }): Promise => ipcRenderer.invoke('gh:createIssue', args), - - countWorkItems: (args: { - repoPath: string - repoId?: string - query?: string - }): Promise => ipcRenderer.invoke('gh:countWorkItems', args), - - listWorkItems: (args: { - repoPath: string - repoId?: string - limit?: number - query?: string - page?: number - noCache?: boolean - }): Promise>> => - ipcRenderer.invoke('gh:listWorkItems', args), - - prChecks: (args: { - repoPath: string - repoId?: string - sourceContext?: TaskSourceContext | null - prNumber: number - headSha?: string - prRepo?: GitHubOwnerRepo | null - noCache?: boolean - }): Promise => ipcRenderer.invoke('gh:prChecks', args), - - prCheckDetails: (args: { - repoPath: string - repoId?: string - sourceContext?: TaskSourceContext | null - checkRunId?: number - workflowRunId?: number - checkName?: string - url?: string | null - prRepo?: GitHubOwnerRepo | null - }): Promise => ipcRenderer.invoke('gh:prCheckDetails', args), - - rerunPRChecks: (args: { - repoPath: string - repoId?: string - sourceContext?: TaskSourceContext | null - prNumber: number - headSha?: string - failedOnly?: boolean - prRepo?: GitHubOwnerRepo | null - }): Promise<{ ok: true; count: number } | { ok: false; error: string }> => - ipcRenderer.invoke('gh:rerunPRChecks', args), - - prComments: (args: { - repoPath: string - repoId?: string - sourceContext?: TaskSourceContext | null - prNumber: number - prRepo?: GitHubOwnerRepo | null - noCache?: boolean - }): Promise => ipcRenderer.invoke('gh:prComments', args), - - setPRCommentReaction: (args: { - repoPath: string - repoId?: string - sourceContext?: TaskSourceContext | null - reactionSubjectId: string - content: GitHubReactionContent - reacted: boolean - prRepo?: GitHubOwnerRepo | null - }): Promise => ipcRenderer.invoke('gh:setPRCommentReaction', args), - - resolveReviewThread: (args: { - repoPath: string - repoId?: string - sourceContext?: TaskSourceContext | null - threadId: string - resolve: boolean - prRepo?: GitHubOwnerRepo | null - }): Promise => ipcRenderer.invoke('gh:resolveReviewThread', args), - - setPRFileViewed: (args: { - repoPath: string - repoId?: string - sourceContext?: TaskSourceContext | null - prNumber: number - prRepo?: GitHubOwnerRepo | null - pullRequestId: string - path: string - viewed: boolean - }): Promise => ipcRenderer.invoke('gh:setPRFileViewed', args), - - updatePRTitle: (args: { - repoPath: string - repoId?: string - prNumber: number - title: string - prRepo?: GitHubOwnerRepo | null - }): Promise => ipcRenderer.invoke('gh:updatePRTitle', args), - - mergePR: (args: { - repoPath: string - repoId?: string - sourceContext?: TaskSourceContext | null - prNumber: number - method?: 'merge' | 'squash' | 'rebase' - prRepo?: GitHubOwnerRepo | null - }): Promise<{ ok: true } | { ok: false; error: string }> => - ipcRenderer.invoke('gh:mergePR', args), - - setPRAutoMerge: (args: { - repoPath: string - repoId?: string - sourceContext?: TaskSourceContext | null - prNumber: number - enabled: boolean - method?: 'merge' | 'squash' | 'rebase' - prRepo?: GitHubOwnerRepo | null - }): Promise<{ ok: true } | { ok: false; error: string }> => - ipcRenderer.invoke('gh:setPRAutoMerge', args), - - updatePRState: (args: { - repoPath: string - repoId?: string - sourceContext?: TaskSourceContext | null - prNumber: number - updates: { state: 'open' | 'closed' } - prRepo?: GitHubOwnerRepo | null - }): Promise<{ ok: true } | { ok: false; error: string }> => - ipcRenderer.invoke('gh:updatePRState', args), - - markPRReadyForReview: (args: { - repoPath: string - repoId?: string - sourceContext?: TaskSourceContext | null - prNumber: number - prRepo?: GitHubOwnerRepo | null - }): Promise<{ ok: true } | { ok: false; error: string }> => - ipcRenderer.invoke('gh:markPRReadyForReview', args), - - requestPRReviewers: (args: { - repoPath: string - repoId?: string - sourceContext?: TaskSourceContext | null - prNumber: number - reviewers: string[] - prRepo?: GitHubOwnerRepo | null - }): Promise<{ ok: true } | { ok: false; error: string }> => - ipcRenderer.invoke('gh:requestPRReviewers', args), - - removePRReviewers: (args: { - repoPath: string - repoId?: string - sourceContext?: TaskSourceContext | null - prNumber: number - reviewers: string[] - prRepo?: GitHubOwnerRepo | null - }): Promise<{ ok: true } | { ok: false; error: string }> => - ipcRenderer.invoke('gh:removePRReviewers', args), - - updateIssue: (args: { - repoPath: string - repoId?: string - sourceContext?: TaskSourceContext | null - number: number - updates: unknown - }): Promise<{ ok: true } | { ok: false; error: string }> => - ipcRenderer.invoke('gh:updateIssue', args), - - addIssueComment: (args: { - repoPath: string - repoId?: string - sourceContext?: TaskSourceContext | null - number: number - body: string - type?: 'issue' | 'pr' - prRepo?: GitHubOwnerRepo | null - }): Promise => ipcRenderer.invoke('gh:addIssueComment', args), - - addPRReviewCommentReply: (args: { - repoPath: string - repoId?: string - sourceContext?: TaskSourceContext | null - prNumber: number - commentId: number - body: string - threadId?: string - path?: string - line?: number - prRepo?: GitHubOwnerRepo | null - }): Promise => ipcRenderer.invoke('gh:addPRReviewCommentReply', args), - - addPRReviewComment: (args: { - repoPath: string - repoId?: string - sourceContext?: TaskSourceContext | null - prNumber: number - prRepo?: GitHubOwnerRepo | null - commitId: string - path: string - line: number - startLine?: number - body: string - }): Promise => ipcRenderer.invoke('gh:addPRReviewComment', args), - - listLabels: (args: { - repoPath: string - repoId?: string - sourceContext?: TaskSourceContext | null - }): Promise => ipcRenderer.invoke('gh:listLabels', args), - - listAssignableUsers: (args: { - repoPath: string - repoId?: string - sourceContext?: TaskSourceContext | null - }): Promise => ipcRenderer.invoke('gh:listAssignableUsers', args), - - // Why: renderer owns the work-item cache; main fires this for non-origin mutations only (origin callers updated optimistically). See src/main/ipc/github.ts. - onWorkItemMutated: ( - callback: (payload: { - repoPath: string - repoId?: string - type: 'issue' | 'pr' - number: number - }) => void - ): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - payload: { repoPath: string; repoId?: string; type: 'issue' | 'pr'; number: number } - ): void => callback(payload) - ipcRenderer.on('gh:workItemMutated', listener) - return () => ipcRenderer.removeListener('gh:workItemMutated', listener) - }, - - checkOrcaStarred: (): Promise => ipcRenderer.invoke('gh:checkOrcaStarred'), - starOrca: (source: AppStarSource): Promise => - ipcRenderer.invoke('gh:starOrca', source), - - // Why: rate_limit is exempt from rate-limit accounting; `force` still busts the 30s in-process cache after an expensive op. - rateLimit: (args?: { force?: boolean }): Promise => - ipcRenderer.invoke('gh:rateLimit', args), - - diagnoseAuth: (args?: { host?: string }): Promise => - ipcRenderer.invoke('gh:diagnoseAuth', args), - - // ── ProjectV2 (GitHub Projects) ─────────────────────────────────── - listAccessibleProjects: ( - args?: ListAccessibleProjectsArgs - ): Promise => - ipcRenderer.invoke('gh:listAccessibleProjects', args), - resolveProjectRef: (args: ResolveProjectRefArgs): Promise => - ipcRenderer.invoke('gh:resolveProjectRef', args), - listProjectViews: (args: ListProjectViewsArgs): Promise => - ipcRenderer.invoke('gh:listProjectViews', args), - getProjectViewTable: (args: GetProjectViewTableArgs): Promise => - ipcRenderer.invoke('gh:getProjectViewTable', args), - projectWorkItemDetailsBySlug: ( - args: ProjectWorkItemDetailsBySlugArgs - ): Promise => - ipcRenderer.invoke('gh:projectWorkItemDetailsBySlug', args), - updateProjectItemField: ( - args: UpdateProjectItemFieldArgs - ): Promise => - ipcRenderer.invoke('gh:updateProjectItemField', args), - clearProjectItemField: ( - args: ClearProjectItemFieldArgs - ): Promise => ipcRenderer.invoke('gh:clearProjectItemField', args), - updateIssueBySlug: (args: UpdateIssueBySlugArgs): Promise => - ipcRenderer.invoke('gh:updateIssueBySlug', args), - updatePullRequestBySlug: ( - args: UpdatePullRequestBySlugArgs - ): Promise => - ipcRenderer.invoke('gh:updatePullRequestBySlug', args), - addIssueCommentBySlug: ( - args: AddIssueCommentBySlugArgs - ): Promise => - ipcRenderer.invoke('gh:addIssueCommentBySlug', args), - updateIssueCommentBySlug: ( - args: UpdateIssueCommentBySlugArgs - ): Promise => - ipcRenderer.invoke('gh:updateIssueCommentBySlug', args), - deleteIssueCommentBySlug: ( - args: DeleteIssueCommentBySlugArgs - ): Promise => - ipcRenderer.invoke('gh:deleteIssueCommentBySlug', args), - listLabelsBySlug: (args: ListLabelsBySlugArgs): Promise => - ipcRenderer.invoke('gh:listLabelsBySlug', args), - listAssignableUsersBySlug: ( - args: ListAssignableUsersBySlugArgs - ): Promise => - ipcRenderer.invoke('gh:listAssignableUsersBySlug', args), - listIssueTypesBySlug: (args: ListIssueTypesBySlugArgs): Promise => - ipcRenderer.invoke('gh:listIssueTypesBySlug', args), - updateIssueTypeBySlug: ( - args: UpdateIssueTypeBySlugArgs - ): Promise => ipcRenderer.invoke('gh:updateIssueTypeBySlug', args) - }, - - hostedReview: { - forBranch: (args: HostedReviewForBranchArgs): Promise => - ipcRenderer.invoke('hostedReview:forBranch', args), - getCreationEligibility: (args: unknown): Promise => - ipcRenderer.invoke('hostedReview:getCreationEligibility', args), - create: (args: unknown): Promise => ipcRenderer.invoke('hostedReview:create', args), - createStacked: (args: unknown): Promise => - ipcRenderer.invoke('hostedReview:createStacked', args) - }, - - // Why: GitLab bindings live in `./gitlab` so `gl.*` changes don't conflict on every upstream sync of this central file. - gl: glApi, - - bitbucket: { - connect: (args: { - authMode: 'token' | 'basic' - accessToken?: string | null - email?: string | null - apiToken?: string | null - baseUrl?: string | null - }): Promise<{ ok: true; account: string | null } | { ok: false; error: string }> => - ipcRenderer.invoke('bitbucket:connect', args), - - disconnect: (): Promise => ipcRenderer.invoke('bitbucket:disconnect'), - - status: (): Promise => ipcRenderer.invoke('bitbucket:status') - }, - - linear: { - connect: (args: { - apiKey: string - }): Promise<{ ok: true; viewer: unknown } | { ok: false; error: string }> => - ipcRenderer.invoke('linear:connect', args), - - disconnect: (args?: { workspaceId?: string }): Promise => - ipcRenderer.invoke('linear:disconnect', args), - - selectWorkspace: (args: { workspaceId: string }): Promise => - ipcRenderer.invoke('linear:selectWorkspace', args), - - status: (): Promise => ipcRenderer.invoke('linear:status'), - - testConnection: (args?: { - workspaceId?: string - }): Promise<{ ok: true; viewer: unknown } | { ok: false; error: string }> => - ipcRenderer.invoke('linear:testConnection', args), - - searchIssues: (args: { - query: string - limit?: number - workspaceId?: string - }): Promise => ipcRenderer.invoke('linear:searchIssues', args), - - listIssues: (args?: { - filter?: 'assigned' | 'created' | 'all' | 'completed' - limit?: number - workspaceId?: string - attributeFilter?: unknown - }): Promise => ipcRenderer.invoke('linear:listIssues', args), - - createIssue: (args: { - teamId: string - title: string - description?: string - workspaceId?: string - parentIssueId?: string - projectId?: string | null - stateId?: string - priority?: number - assigneeId?: string | null - labelIds?: string[] - }): Promise< - | { ok: true; id: string; identifier: string; title: string; url: string } - | { ok: false; error: string } - > => ipcRenderer.invoke('linear:createIssue', args), - - getIssue: (args: { id: string; workspaceId?: string }): Promise => - ipcRenderer.invoke('linear:getIssue', args), - - updateIssue: (args: { - id: string - updates: unknown - workspaceId?: string - }): Promise<{ ok: true } | { ok: false; error: string }> => - ipcRenderer.invoke('linear:updateIssue', args), - - addIssueComment: (args: { - issueId: string - body: string - workspaceId?: string - }): Promise<{ ok: true; id: string } | { ok: false; error: string }> => - ipcRenderer.invoke('linear:addIssueComment', args), - - issueComments: (args: { issueId: string; workspaceId?: string }): Promise => - ipcRenderer.invoke('linear:issueComments', args), - - listTeams: (args?: { workspaceId?: string }): Promise => - ipcRenderer.invoke('linear:listTeams', args), - - listProjects: (args?: { - query?: string - limit?: number - workspaceId?: string - force?: boolean - }): Promise => ipcRenderer.invoke('linear:listProjects', args), - - createProject: (args: { - name: string - description?: string - content?: string - teamIds: string[] - workspaceId?: string - leadId?: string | null - memberIds?: string[] - labelIds?: string[] - priority?: number - startDate?: string - targetDate?: string - }): Promise<{ ok: true; project: LinearProjectDetail } | { ok: false; error: string }> => - ipcRenderer.invoke('linear:createProject', args), - - getProject: (args: { id: string; workspaceId: string; force?: boolean }): Promise => - ipcRenderer.invoke('linear:getProject', args), - - listProjectIssues: (args: { - projectId: string - limit?: number - workspaceId: string - force?: boolean - }): Promise => ipcRenderer.invoke('linear:listProjectIssues', args), - - listCustomViews: (args: { - model: string - limit?: number - workspaceId?: string - force?: boolean - }): Promise => ipcRenderer.invoke('linear:listCustomViews', args), - - getCustomView: (args: { - viewId: string - model: string - workspaceId: string - force?: boolean - }): Promise => ipcRenderer.invoke('linear:getCustomView', args), - - listCustomViewIssues: (args: { - viewId: string - limit?: number - workspaceId: string - force?: boolean - }): Promise => ipcRenderer.invoke('linear:listCustomViewIssues', args), - - listCustomViewProjects: (args: { - viewId: string - limit?: number - workspaceId: string - force?: boolean - }): Promise => ipcRenderer.invoke('linear:listCustomViewProjects', args), - - teamStates: (args: { teamId: string; workspaceId?: string }): Promise => - ipcRenderer.invoke('linear:teamStates', args), - - teamLabels: (args: { teamId: string; workspaceId?: string }): Promise => - ipcRenderer.invoke('linear:teamLabels', args), - - teamMembers: (args: { teamId: string; workspaceId?: string }): Promise => - ipcRenderer.invoke('linear:teamMembers', args) - }, - - jira: { - connect: (args: { - siteUrl: string - email: string - apiToken: string - authType?: 'cloud' | 'server' - }): Promise<{ ok: true; viewer: unknown } | { ok: false; error: string }> => - ipcRenderer.invoke('jira:connect', args), - - disconnect: (args?: { siteId?: string }): Promise => - ipcRenderer.invoke('jira:disconnect', args), - - selectSite: (args: { siteId: string }): Promise => - ipcRenderer.invoke('jira:selectSite', args), - - status: (): Promise => ipcRenderer.invoke('jira:status'), - - readStatus: (): Promise => ipcRenderer.invoke('jira:readStatus'), - - testConnection: (args?: { - siteId?: string - }): Promise<{ ok: true; viewer: unknown } | { ok: false; error: string }> => - ipcRenderer.invoke('jira:testConnection', args), - - searchIssues: (args: { - jql: string - limit?: number - siteId?: string - requestId?: string - }): Promise => ipcRenderer.invoke('jira:searchIssues', args), - cancelSearchIssues: (args: { requestId: string }): Promise => - ipcRenderer.invoke('jira:cancelSearchIssues', args), - - listIssues: (args?: { - filter?: 'assigned' | 'reported' | 'all' | 'done' - limit?: number - siteId?: string - }): Promise => ipcRenderer.invoke('jira:listIssues', args), - - getIssue: (args: { key: string; siteId?: string }): Promise => - ipcRenderer.invoke('jira:getIssue', args), - - lookupIssueSummary: (args: { - key: string - siteId: string - requestId?: string - }): Promise => ipcRenderer.invoke('jira:lookupIssueSummary', args), - cancelIssueSummary: (args: { requestId: string }): Promise => - ipcRenderer.invoke('jira:cancelIssueSummary', args), - - createIssue: (args: { - siteId?: string - projectId: string - issueTypeId: string - title: string - description?: string - customFields?: Record - }): Promise< - { ok: true; id: string; key: string; url: string } | { ok: false; error: string } - > => ipcRenderer.invoke('jira:createIssue', args), - - updateIssue: (args: { - key: string - updates: unknown - siteId?: string - }): Promise<{ ok: true } | { ok: false; error: string }> => - ipcRenderer.invoke('jira:updateIssue', args), - - addIssueComment: (args: { - key: string - body: string - siteId?: string - }): Promise<{ ok: true; id: string } | { ok: false; error: string }> => - ipcRenderer.invoke('jira:addIssueComment', args), - - issueComments: (args: { key: string; siteId?: string }): Promise => - ipcRenderer.invoke('jira:issueComments', args), - - listProjects: (args?: { siteId?: string }): Promise => - ipcRenderer.invoke('jira:listProjects', args), - - listIssueTypes: (args: { projectIdOrKey: string; siteId?: string }): Promise => - ipcRenderer.invoke('jira:listIssueTypes', args), - - listCreateFields: (args: { - projectIdOrKey: string - issueTypeId: string - siteId?: string - }): Promise => ipcRenderer.invoke('jira:listCreateFields', args), - - listPriorities: (args?: { siteId?: string }): Promise => - ipcRenderer.invoke('jira:listPriorities', args), - - listAssignableUsers: (args: { - key: string - query?: string - siteId?: string - }): Promise => ipcRenderer.invoke('jira:listAssignableUsers', args), - searchUsers: (args?: { query?: string; siteId?: string }): Promise => - ipcRenderer.invoke('jira:searchUsers', args), - - listTransitions: (args: { key: string; siteId?: string }): Promise => - ipcRenderer.invoke('jira:listTransitions', args), - getProjectStatusOrder: (args: { - projectKey: string - siteId?: string - }): Promise => ipcRenderer.invoke('jira:getProjectStatusOrder', args) - }, - - starNag: { - onShow: ( - callback: (payload?: { mode?: 'gh' | 'web'; surface?: 'card' | 'toast' }) => void - ): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - payload?: { mode?: 'gh' | 'web'; surface?: 'card' | 'toast' } - ): void => callback(payload) - ipcRenderer.on('star-nag:show', listener) - return () => ipcRenderer.removeListener('star-nag:show', listener) - }, - onHide: (callback: () => void): (() => void) => { - const listener = (): void => callback() - ipcRenderer.on('star-nag:hide', listener) - return () => ipcRenderer.removeListener('star-nag:hide', listener) - }, - dismiss: (): Promise => ipcRenderer.invoke('star-nag:dismiss'), - later: (): Promise => ipcRenderer.invoke('star-nag:later'), - complete: (): Promise => ipcRenderer.invoke('star-nag:complete'), - disable: (): Promise => ipcRenderer.invoke('star-nag:disable'), - openWeb: (): Promise => ipcRenderer.invoke('star-nag:openWeb'), - starOrca: (): Promise => ipcRenderer.invoke('star-nag:starOrca'), - forceShow: (): Promise => ipcRenderer.invoke('star-nag:forceShow'), - agentValueMoment: (): Promise< - { status: 'ready'; mode: 'gh' | 'web' } | { status: 'skipped' } - > => ipcRenderer.invoke('star-nag:agentValueMoment'), - showAgentValueMoment: (): Promise => ipcRenderer.invoke('star-nag:showAgentValueMoment'), - onboardingCompleted: (): Promise => ipcRenderer.invoke('star-nag:onboardingCompleted') - }, - - // Why: main validates telemetry; renderer call sites use typed wrappers. - telemetryTrack: (name: string, props: Record): Promise => - ipcRenderer.invoke('telemetry:track', name, props), - telemetrySetOptIn: (optedIn: boolean): Promise => - ipcRenderer.invoke('telemetry:setOptIn', optedIn), - telemetryAcknowledgeBanner: (): Promise => - ipcRenderer.invoke('telemetry:acknowledgeBanner'), - telemetryGetConsentState: (): Promise => - ipcRenderer.invoke('telemetry:getConsentState'), - - // Why: bridges are deliberately loose — main type-narrows this untrusted renderer input (see telemetry-error-tracking.md). - diagnostics: { - getStatus: (): Promise => ipcRenderer.invoke('diagnostics:getStatus'), - collectBundle: (lookbackMinutes?: number): Promise => - ipcRenderer.invoke('diagnostics:collectBundle', lookbackMinutes), - openBundlePreview: (bundleSubmissionId: string): Promise => - ipcRenderer.invoke('diagnostics:openBundlePreview', bundleSubmissionId), - discardBundlePreview: (bundleSubmissionId: string): Promise => - ipcRenderer.invoke('diagnostics:discardBundlePreview', bundleSubmissionId), - uploadBundle: (bundleSubmissionId: string): Promise => - ipcRenderer.invoke('diagnostics:uploadBundle', bundleSubmissionId), - deleteBundle: (ticketId: string): Promise => - ipcRenderer.invoke('diagnostics:deleteBundle', ticketId) - }, - - settings: { - get: (): Promise => ipcRenderer.invoke('settings:get'), - - // Why: blocking read for the few startup decisions (terminal side-effect authority) that can't wait for async hydration. Call sparingly. - getSync: (): unknown => ipcRenderer.sendSync('settings:get-sync'), - - set: (args: Record): Promise => - ipcRenderer.invoke('settings:set', args), - - setActiveRuntimeEnvironmentPreference: (args: { - environmentId: string | null - }): Promise => - ipcRenderer.invoke('settings:set-active-runtime-environment-preference', args), - - updatePRBotAuthorOverride: (args: { author: string; isBot: boolean }): Promise => - ipcRenderer.invoke('settings:update-pr-bot-author-override', args), - - listFonts: (): Promise => ipcRenderer.invoke('settings:listFonts'), - - previewGhosttyImport: (): Promise => - ipcRenderer.invoke('settings:previewGhosttyImport'), - - previewWarpThemeImport: (source: WarpThemeImportSource): Promise => - ipcRenderer.invoke('settings:previewWarpThemeImport', source), - - onChanged: (callback: (updates: Record) => void): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - updates: Record - ): void => callback(updates) - ipcRenderer.on('settings:changed', listener) - return () => ipcRenderer.removeListener('settings:changed', listener) - } - }, - - agentAwake: { - getStatus: (): Promise => ipcRenderer.invoke('agentAwake:getStatus'), - onChanged: (callback: (status: ComputerAwakeStatus) => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, status: ComputerAwakeStatus): void => - callback(status) - ipcRenderer.on('agentAwake:changed', listener) - return () => ipcRenderer.removeListener('agentAwake:changed', listener) - } - } satisfies PreloadApi['agentAwake'], - - localhostWorktreeLabels: { - register: (args: LocalhostWorktreeLabelRoute): Promise => - ipcRenderer.invoke('localhostWorktreeLabels:register', args) - } satisfies PreloadApi['localhostWorktreeLabels'], - - keybindings: { - get: (): Promise => ipcRenderer.invoke('keybindings:get'), - ensureFile: (): Promise => ipcRenderer.invoke('keybindings:ensureFile'), - setAction: (args: { - actionId: KeybindingActionId - bindings: string[] | null - }): Promise => ipcRenderer.invoke('keybindings:setAction', args), - reload: (): Promise => ipcRenderer.invoke('keybindings:reload'), - openFile: (): Promise => ipcRenderer.invoke('keybindings:openFile'), - revealFile: (): Promise => ipcRenderer.invoke('keybindings:revealFile'), - onChanged: (callback: (snapshot: KeybindingFileSnapshot) => void): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - snapshot: KeybindingFileSnapshot - ): void => callback(snapshot) - ipcRenderer.on('keybindings:changed', listener) - return () => ipcRenderer.removeListener('keybindings:changed', listener) - } - }, - - codexAccounts: { - list: (): Promise => ipcRenderer.invoke('codexAccounts:list'), - add: (args?: { runtime?: 'host' | 'wsl'; wslDistro?: string | null }): Promise => - ipcRenderer.invoke('codexAccounts:add', args), - reauthenticate: (args: { - accountId: string - activateIfSelectionWasEmpty?: boolean - }): Promise => ipcRenderer.invoke('codexAccounts:reauthenticate', args), - remove: (args: { accountId: string }): Promise => - ipcRenderer.invoke('codexAccounts:remove', args), - select: (args: { - accountId: string | null - runtime?: 'host' | 'wsl' - wslDistro?: string | null - }): Promise => ipcRenderer.invoke('codexAccounts:select', args), - listStalePanes: (args: { - ptyIds: string[] - }): Promise< - { - ptyId: string - launchAccountId: string | null - activeAccountId: string | null - reason?: 'account-change' | 'home-route-change' - }[] - > => ipcRenderer.invoke('codexAccounts:listStalePanes', args), - listRecordedPaneLanes: (args: { ptyIds: string[] }): Promise> => - ipcRenderer.invoke('codexAccounts:listRecordedPaneLanes', args), - forgetStalePanes: (args: { ptyIds: string[] }): Promise => - ipcRenderer.invoke('codexAccounts:forgetStalePanes', args) - }, - - claudeAccounts: { - list: (): Promise => ipcRenderer.invoke('claudeAccounts:list'), - add: (args?: { runtime?: 'host' | 'wsl'; wslDistro?: string | null }): Promise => - ipcRenderer.invoke('claudeAccounts:add', args), - cancelPendingLogin: (): Promise => - ipcRenderer.invoke('claudeAccounts:cancelPendingLogin'), - reauthenticate: (args: { accountId: string }): Promise => - ipcRenderer.invoke('claudeAccounts:reauthenticate', args), - remove: (args: { accountId: string }): Promise => - ipcRenderer.invoke('claudeAccounts:remove', args), - select: (args: { - accountId: string | null - runtime?: 'host' | 'wsl' - wslDistro?: string | null - }): Promise => ipcRenderer.invoke('claudeAccounts:select', args) - }, - - cli: { - getInstallStatus: (): Promise => ipcRenderer.invoke('cli:getInstallStatus'), - install: (): Promise => ipcRenderer.invoke('cli:install'), - remove: (): Promise => ipcRenderer.invoke('cli:remove'), - getWslInstallStatus: (args?: { distro?: string | null }): Promise => - ipcRenderer.invoke('cli:getWslInstallStatus', args), - installWsl: (args?: { distro?: string | null }): Promise => - ipcRenderer.invoke('cli:installWsl', args), - removeWsl: (args?: { distro?: string | null }): Promise => - ipcRenderer.invoke('cli:removeWsl', args) - }, - - codexConfigSync: { - status: (): Promise => ipcRenderer.invoke('codexConfigSync:status') - }, - agentTrust: { - markTrusted: (args: { - preset: 'cursor' | 'copilot' | 'codex' - workspacePath: string - connectionId?: string - }): Promise => ipcRenderer.invoke('agentTrust:markTrusted', args) - }, - - preflight: { - check: (args?: { - force?: boolean - }): Promise<{ - git: { installed: boolean } - gh: { installed: boolean; authenticated: boolean } - glab?: { installed: boolean; authenticated: boolean } - bitbucket?: { configured: boolean; authenticated: boolean; account: string | null } - azureDevOps?: { - configured: boolean - authenticated: boolean - account: string | null - baseUrl: string | null - tokenConfigured: boolean - } - gitea?: { - configured: boolean - authenticated: boolean - account: string | null - baseUrl: string | null - tokenConfigured: boolean - } - linear: { connected: boolean } - }> => ipcRenderer.invoke('preflight:check', args), - detectAgents: (args?: PreflightRuntimeContext): Promise => - ipcRenderer.invoke('preflight:detectAgents', args), - refreshAgents: (args?: PreflightRuntimeContext): Promise => - ipcRenderer.invoke('preflight:refreshAgents', args), - detectRemoteAgents: (args: { connectionId: string }): Promise => - ipcRenderer.invoke('preflight:detectRemoteAgents', args), - detectRemoteWindowsTerminalCapabilities: (args: { - connectionId: string - }): Promise<{ - wslAvailable: boolean - wslDistros: string[] - pwshAvailable: boolean - gitBashAvailable: boolean - hostPlatform: NodeJS.Platform | null - }> => ipcRenderer.invoke('preflight:detectRemoteWindowsTerminalCapabilities', args) - }, - - notifications: { - dispatch: (args: Record): Promise => - ipcRenderer.invoke('notifications:dispatch', args), - dismiss: (ids: string[]): Promise => - ipcRenderer.invoke('notifications:dismiss', ids), - openSystemSettings: (): Promise => ipcRenderer.invoke('notifications:openSystemSettings'), - getPermissionStatus: (): Promise => - ipcRenderer.invoke('notifications:getPermissionStatus'), - probeDelivery: (args?: { force?: boolean }): Promise => - ipcRenderer.invoke('notifications:probeDelivery', args), - playSound: async (options?: { - force?: boolean - volume?: number - }): Promise => { - try { - // Why: drop replays while still ringing; the test button passes force to always confirm. - if (!options?.force && isNotificationSoundPlaying) { - return { played: false, reason: 'deduped' } - } - - const resolved = (await ipcRenderer.invoke( - 'notifications:resolveSoundPath' - )) as NotificationSoundPathResult - if (!resolved.ok) { - if (cachedNotificationSound) { - disposeCachedNotificationSound() - } - return { played: false, reason: resolved.reason } - } - - let entry = cachedNotificationSound - if (!entry || entry.path !== resolved.path) { - const sound = (await ipcRenderer.invoke( - 'notifications:loadSound' - )) as NotificationSoundDataResult - if (!sound.ok) { - disposeCachedNotificationSound() - return { played: false, reason: sound.reason } - } - const arrayBuffer = new ArrayBuffer(sound.data.byteLength) - new Uint8Array(arrayBuffer).set(sound.data) - const blob = new Blob([arrayBuffer], { type: sound.mimeType }) - disposeCachedNotificationSound() - const blobUrl = URL.createObjectURL(blob) - entry = { path: sound.path, blobUrl, audio: new Audio(blobUrl) } - cachedNotificationSound = entry - } - - const audio = entry.audio - // Why: restart from zero on each play so bursts replay instead of stacking copies (GNOME canberra / VS Code signal service). - audio.currentTime = 0 - if (typeof options?.volume === 'number' && Number.isFinite(options.volume)) { - audio.volume = Math.min(1, Math.max(0, options.volume / 100)) - } - isNotificationSoundPlaying = true - cleanupNotificationSoundPlayback?.() - const release = (): void => { - cleanup() - if (cleanupNotificationSoundPlayback === cleanup) { - cleanupNotificationSoundPlayback = null - } - isNotificationSoundPlaying = false - } - const cleanup = (): void => { - audio.removeEventListener('ended', release) - audio.removeEventListener('error', release) - } - cleanupNotificationSoundPlayback = cleanup - audio.addEventListener('ended', release) - audio.addEventListener('error', release) - try { - await audio.play() - } catch { - release() - return { played: false, reason: 'playback-failed' } - } - return { played: true } - } catch { - clearNotificationSoundPlaybackState() - return { played: false, reason: 'playback-failed' } - } - } - }, - - onboarding: { - get: (): Promise => ipcRenderer.invoke('onboarding:get'), - update: ( - updates: Partial> & { - checklist?: Partial - } - ): Promise => ipcRenderer.invoke('onboarding:update', updates) - }, - - dashboard: { - // Open the pop-out dashboard window, or focus it if already open. - openPopout: (view?: 'board' | 'map'): Promise => - ipcRenderer.invoke('dashboardPopout:open', view), - - // ── Producer side (main window) ────────────────────────────────────── - publishSnapshot: (snapshot: DashboardSnapshot): Promise => - ipcRenderer.invoke('dashboard:publishSnapshot', snapshot), - getPopoutOpen: (): Promise => ipcRenderer.invoke('dashboard:getPopoutOpen'), - onPopoutOpenChanged: (callback: (open: boolean) => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, open: boolean): void => callback(open) - ipcRenderer.on('dashboard:popoutOpenChanged', listener) - return () => ipcRenderer.removeListener('dashboard:popoutOpenChanged', listener) - }, - onSnapshotRequested: (callback: () => void): (() => void) => { - const listener = (): void => callback() - ipcRenderer.on('dashboard:snapshotRequested', listener) - return () => ipcRenderer.removeListener('dashboard:snapshotRequested', listener) - }, - onRevealAgent: (callback: (args: DashboardRevealAgentArgs) => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, args: DashboardRevealAgentArgs): void => - callback(args) - ipcRenderer.on('ui:revealDashboardAgent', listener) - return () => ipcRenderer.removeListener('ui:revealDashboardAgent', listener) - }, - onAckAgent: (callback: (paneKey: string) => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, paneKey: string): void => - callback(paneKey) - ipcRenderer.on('ui:ackDashboardAgent', listener) - return () => ipcRenderer.removeListener('ui:ackDashboardAgent', listener) - }, - onSpawnAgent: (callback: (args: DashboardSpawnAgentArgs) => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, args: DashboardSpawnAgentArgs): void => - callback(args) - ipcRenderer.on('ui:spawnDashboardAgent', listener) - return () => ipcRenderer.removeListener('ui:spawnDashboardAgent', listener) - }, - onSleepWorkspace: (callback: (args: DashboardSleepWorkspaceArgs) => void): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - args: DashboardSleepWorkspaceArgs - ): void => callback(args) - ipcRenderer.on('ui:sleepDashboardWorkspace', listener) - return () => ipcRenderer.removeListener('ui:sleepDashboardWorkspace', listener) - }, - - // ── Consumer side (pop-out window) ─────────────────────────────────── - requestSnapshot: (): Promise => ipcRenderer.invoke('dashboard:requestSnapshot'), - onSnapshot: (callback: (snapshot: DashboardSnapshot) => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, snapshot: DashboardSnapshot): void => - callback(snapshot) - ipcRenderer.on('dashboard:snapshot', listener) - return () => ipcRenderer.removeListener('dashboard:snapshot', listener) - }, - onViewRequested: (callback: (view: 'board' | 'map') => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, view: 'board' | 'map'): void => - callback(view) - ipcRenderer.on('dashboard:viewRequested', listener) - return () => ipcRenderer.removeListener('dashboard:viewRequested', listener) - }, - revealAgent: (args: DashboardRevealAgentArgs): Promise => - ipcRenderer.invoke('dashboardPopout:revealAgent', args), - ackAgent: (paneKey: string): Promise => - ipcRenderer.invoke('dashboardPopout:ackAgent', { paneKey }), - spawnAgent: (args: DashboardSpawnAgentArgs): Promise => - ipcRenderer.invoke('dashboardPopout:spawnAgent', args), - sleepWorkspace: (args: DashboardSleepWorkspaceArgs): Promise => - ipcRenderer.invoke('dashboardPopout:sleepWorkspace', args) - }, - - terminalPreview: { - connect: ( - ptyId: string, - opts?: { scrollbackRows?: number } - ): Promise => - ipcRenderer.invoke('terminalPreview:connect', { ptyId, opts }), - input: (ptyId: string, data: string): Promise => - ipcRenderer.invoke('terminalPreview:input', { ptyId, data }), - fit: ( - ptyId: string, - cols: number, - rows: number - ): Promise<{ cols: number; rows: number } | null> => - ipcRenderer.invoke('terminalPreview:fit', { ptyId, cols, rows }), - ack: (ptyId: string, bytes: number): Promise => - ipcRenderer.invoke('terminalPreview:ack', { ptyId, bytes }), - unsubscribe: (ptyId: string): Promise => - ipcRenderer.invoke('terminalPreview:unsubscribe', { ptyId }), - onData: (callback: (payload: TerminalPreviewDataPayload) => void): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - payload: TerminalPreviewDataPayload - ): void => callback(payload) - ipcRenderer.on('terminalPreview:data', listener) - return () => ipcRenderer.removeListener('terminalPreview:data', listener) - } - }, - - macosTccPrompts: { - onThreshold: (callback: (payload: unknown) => void) => { - const listener = (_event: Electron.IpcRendererEvent, payload: unknown): void => - callback(payload) - ipcRenderer.on('macosTccPrompts:threshold', listener) - return () => ipcRenderer.removeListener('macosTccPrompts:threshold', listener) - }, - consumePending: (): Promise<{ claimId: number; promptCount: number } | null> => - ipcRenderer.invoke('macosTccPrompts:consumePending'), - acknowledgePending: (claimId: number): Promise => - ipcRenderer.invoke('macosTccPrompts:acknowledgePending', claimId), - releasePending: (claimId: number): Promise => - ipcRenderer.invoke('macosTccPrompts:releasePending', claimId), - dismiss: (): Promise => ipcRenderer.invoke('macosTccPrompts:dismiss') - }, - - developerPermissions: { - getStatus: (): Promise => ipcRenderer.invoke('developerPermissions:getStatus'), - request: (args: { id: string }): Promise => - ipcRenderer.invoke('developerPermissions:request', args), - openSettings: (args: { id: string }): Promise => - ipcRenderer.invoke('developerPermissions:openSettings', args), - testLocalNetworkConnection: (args: { host: string; port: number }): Promise => - ipcRenderer.invoke('developerPermissions:testLocalNetworkConnection', args) - }, - - computerUsePermissions: { - getStatus: (): Promise => ipcRenderer.invoke('computerUsePermissions:getStatus'), - openSetup: (args?: { id?: string }): Promise => - ipcRenderer.invoke('computerUsePermissions:openSetup', args), - reset: (): Promise => ipcRenderer.invoke('computerUsePermissions:reset') - }, - - shell: { - openPath: (path: string): Promise => ipcRenderer.invoke('shell:openPath', path), - - openInFileManager: (path: string): Promise => - ipcRenderer.invoke('shell:openInFileManager', path), - - openInExternalEditor: ( - request: ShellOpenExternalEditorRequest - ): Promise => - ipcRenderer.invoke('shell:openInExternalEditor', request), - - openUrl: (url: string): Promise => ipcRenderer.invoke('shell:openUrl', url), - - openFilePath: (path: string): Promise => - ipcRenderer.invoke('shell:openFilePath', path), - - openFileUri: (uri: string): Promise => ipcRenderer.invoke('shell:openFileUri', uri), - - pathExists: (path: string): Promise => ipcRenderer.invoke('shell:pathExists', path), - - pickAttachment: (): Promise => ipcRenderer.invoke('shell:pickAttachment'), - - pickImage: (): Promise => ipcRenderer.invoke('shell:pickImage'), - - pickRepoIconImage: (): Promise<{ dataUrl: string; fileName: string } | null> => - ipcRenderer.invoke('shell:pickRepoIconImage'), - - pickAudio: (): Promise => ipcRenderer.invoke('shell:pickAudio'), - - pickDirectory: (args: { defaultPath?: string }): Promise => - ipcRenderer.invoke('shell:pickDirectory', args), - - copyFile: (args: { srcPath: string; destPath: string }): Promise => - ipcRenderer.invoke('shell:copyFile', args) - }, - - skills: { - discover: (target?: SkillDiscoveryTarget): Promise => - ipcRenderer.invoke('skills:discover', target), - freshnessInventory: (): Promise => - ipcRenderer.invoke('skills:freshnessInventory'), - startUpdateRun: (names: string[]): Promise => - ipcRenderer.invoke('skills:startUpdateRun', names), - cancelUpdateRun: (): Promise => ipcRenderer.invoke('skills:cancelUpdateRun'), - acknowledgeUpdateRun: (): Promise => ipcRenderer.invoke('skills:acknowledgeUpdateRun'), - getUpdateRun: (): Promise => ipcRenderer.invoke('skills:getUpdateRun'), - prepareShare: (input: { - skillIds: string[] - bundleName: string - target?: SkillDiscoveryTarget - packageId?: string - }): Promise => ipcRenderer.invoke('skills:prepareShare', input), - publishShare: (input: SkillSharePublishInput): Promise => - ipcRenderer.invoke('skills:publishShare', input), - cancelShare: (preparationId: string): Promise => - ipcRenderer.invoke('skills:cancelShare', preparationId), - releaseShare: (preparationId: string): Promise => - ipcRenderer.invoke('skills:releaseShare', preparationId), - resolveShare: (shareId: string): Promise => - ipcRenderer.invoke('skills:resolveShare', shareId), - installShare: (input: SkillShareInstallInput): Promise => - ipcRenderer.invoke('skills:installShare', input), - installBundleShare: ( - input: SkillBundleShareInstallInput - ): Promise => - ipcRenderer.invoke('skills:installBundleShare', input), - installBundlePackageVersion: ( - input: SkillBundlePackageVersionInstallInput - ): Promise => - ipcRenderer.invoke('skills:installBundlePackageVersion', input), - installPackageVersion: ( - input: SkillPackageVersionInstallInput - ): Promise => - ipcRenderer.invoke('skills:installPackageVersion', input), - cancelInstall: (input: SkillInstallCancelInput): Promise<{ cancelled: boolean }> => - ipcRenderer.invoke('skills:cancelInstall', input), - previewInstall: (input: SkillInstallPreviewInput): Promise => - ipcRenderer.invoke('skills:previewInstall', input), - previewBundleInstall: ( - input: SkillBundleInstallPreviewInput - ): Promise => - ipcRenderer.invoke('skills:previewBundleInstall', input), - removeInstall: (input: SkillRemoveInput): Promise => - ipcRenderer.invoke('skills:removeInstall', input), - // Desktop always registers the delete IPC handlers in its own main process. - deleteSupported: (): Promise => Promise.resolve(true), - previewDelete: (request: SkillDeleteRequest): Promise => - ipcRenderer.invoke('skills:previewDelete', request), - delete: (request: SkillDeleteRequest): Promise => - ipcRenderer.invoke('skills:delete', request), - listManagedInstalls: (environmentId?: string): Promise => - ipcRenderer.invoke('skills:listManagedInstalls', environmentId), - getPackage: (packageId: string): Promise> => - ipcRenderer.invoke('skills:getPackage', packageId), - listOwnedShares: (): Promise> => - ipcRenderer.invoke('skills:listOwnedShares'), - revokeShare: (shareId: string): Promise> => - ipcRenderer.invoke('skills:revokeShare', shareId), - deletePackageVersion: (input: { - packageId: string - versionId: string - }): Promise> => - ipcRenderer.invoke('skills:deletePackageVersion', input), - deletePackage: (packageId: string): Promise> => - ipcRenderer.invoke('skills:deletePackage', packageId), - listWslDistros: (environmentId?: string): Promise => - ipcRenderer.invoke('skills:listWslDistros', environmentId), - onInstallProgress: (callback: (progress: SkillInstallProgress) => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, progress: SkillInstallProgress): void => - callback(progress) - ipcRenderer.on('skills:installProgress', listener) - return () => ipcRenderer.removeListener('skills:installProgress', listener) - }, - onShareProgress: (callback: (progress: SkillShareProgress) => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, progress: SkillShareProgress): void => - callback(progress) - ipcRenderer.on('skills:shareProgress', listener) - return () => ipcRenderer.removeListener('skills:shareProgress', listener) - }, - onUpdateRun: (callback: (run: SkillUpdateRun) => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, run: SkillUpdateRun): void => - callback(run) - ipcRenderer.on('skills:updateRun', listener) - return () => ipcRenderer.removeListener('skills:updateRun', listener) - } - }, - - pet: { - import: (): Promise => ipcRenderer.invoke('pet:import'), - importPetBundle: (): Promise => ipcRenderer.invoke('pet:importPetBundle'), - read: (id: string, fileName: string, kind?: 'image' | 'bundle'): Promise => - ipcRenderer.invoke('pet:read', id, fileName, kind), - delete: (id: string, fileName: string, kind?: 'image' | 'bundle'): Promise => - ipcRenderer.invoke('pet:delete', id, fileName, kind) - }, - - browser: { - onClientPageRendererRequest: browserClientPageRendererRequests.subscribe, - readClientHostId: (): string | null => readBrowserClientHostIdArgument(process.argv), - registerGuest: (args: { - browserPageId: string - workspaceId: string - worktreeId: string - sessionProfileId?: string | null - webContentsId: number - }): Promise => ipcRenderer.invoke('browser:registerGuest', args), - - isGuestRegistered: (args: { browserPageId: string; webContentsId: number }): Promise => - ipcRenderer.invoke('browser:isGuestRegistered', args), - - repairGuestRegistration: (args: { - browserPageId: string - workspaceId: string - worktreeId: string - sessionProfileId?: string | null - webContentsId: number - }): Promise => ipcRenderer.invoke('browser:repairGuestRegistration', args), - - unregisterGuest: (args: { browserPageId: string }): Promise => - ipcRenderer.invoke('browser:unregisterGuest', args), - - onWebAuthnAccountRequest: ( - callback: (request: BrowserWebAuthnAccountRequest) => void - ): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - request: BrowserWebAuthnAccountRequest - ): void => callback(request) - ipcRenderer.on('browser:webauthn-account-requested', listener) - return () => ipcRenderer.removeListener('browser:webauthn-account-requested', listener) - }, - - onWebAuthnAccountRequestClosed: ( - callback: (event: { requestId: string }) => void - ): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, data: { requestId: string }): void => - callback(data) - ipcRenderer.on('browser:webauthn-account-request-closed', listener) - return () => ipcRenderer.removeListener('browser:webauthn-account-request-closed', listener) - }, - - respondWebAuthnAccount: (response: BrowserWebAuthnAccountResponse): Promise => - ipcRenderer.invoke('browser:respondWebAuthnAccount', response), - - openDevTools: (args: { browserPageId: string }): Promise => - ipcRenderer.invoke('browser:openDevTools', args), - - setViewportOverride: (args: { - browserPageId: string - override: BrowserViewportOverride | null - }): Promise => ipcRenderer.invoke('browser:setViewportOverride', args), - - reportViewportScrollState: (args: { - browserPageId: string - state: { - scrollLeft: number - scrollTop: number - maxScrollLeft: number - maxScrollTop: number - } - }): void => ipcRenderer.send('browser:reportViewportScrollState', args), - - setAnnotationViewportBridge: (args): Promise => - ipcRenderer.invoke('browser:setAnnotationViewportBridge', args), - - publishClientPageMetadata: (args) => - ipcRenderer.invoke('browser:publishClientPageMetadata', args), - - onGuestLoadFailed: ( - callback: (args: { - browserPageId: string - loadError: { code: number; description: string; validatedUrl: string } - }) => void - ): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - data: { - browserPageId: string - loadError: { code: number; description: string; validatedUrl: string } - } - ) => callback(data) - ipcRenderer.on('browser:guest-load-failed', listener) - 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) => { - const listener = ( - _event: Electron.IpcRendererEvent, - data: { browserPageId: string; permission: string; origin: string } - ) => callback(data) - ipcRenderer.on('browser:permission-denied', listener) - return () => ipcRenderer.removeListener('browser:permission-denied', listener) - }, - - onPopup: ( - callback: (event: { - browserPageId: string - origin: string - action: 'opened-in-orca' | 'opened-external' | 'blocked' - }) => void - ): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - data: { - browserPageId: string - origin: string - action: 'opened-in-orca' | 'opened-external' | 'blocked' - } - ) => callback(data) - ipcRenderer.on('browser:popup', listener) - return () => ipcRenderer.removeListener('browser:popup', listener) - }, - - onDownloadRequested: ( - callback: (event: { - browserPageId: string - downloadId: string - origin: string - filename: string - totalBytes: number | null - mimeType: string | null - savePath: string - status: 'downloading' - }) => void - ): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - data: { - browserPageId: string - downloadId: string - origin: string - filename: string - totalBytes: number | null - mimeType: string | null - savePath: string - status: 'downloading' - } - ) => callback(data) - ipcRenderer.on('browser:download-requested', listener) - return () => ipcRenderer.removeListener('browser:download-requested', listener) - }, - - onDownloadProgress: ( - callback: (event: { - browserPageId?: string - downloadId: string - receivedBytes: number - totalBytes: number | null - state: 'progressing' | 'interrupted' | null - }) => void - ): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - data: { - browserPageId?: string - downloadId: string - receivedBytes: number - totalBytes: number | null - state: 'progressing' | 'interrupted' | null - } - ) => callback(data) - ipcRenderer.on('browser:download-progress', listener) - return () => ipcRenderer.removeListener('browser:download-progress', listener) - }, - - onDownloadFinished: ( - callback: (event: { - browserPageId?: string - downloadId: string - status: 'completed' | 'canceled' | 'failed' - savePath: string | null - remoteDestination?: { workspaceRelativePath: string; hostLabel: string } - error: string | null - }) => void - ): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - data: { - browserPageId?: string - downloadId: string - status: 'completed' | 'canceled' | 'failed' - savePath: string | null - remoteDestination?: { workspaceRelativePath: string; hostLabel: string } - error: string | null - } - ) => callback(data) - ipcRenderer.on('browser:download-finished', listener) - return () => ipcRenderer.removeListener('browser:download-finished', listener) - }, - - onContextMenuRequested: ( - callback: (event: { - browserPageId: string - x: number - y: number - screenX: number - screenY: number - pageUrl: string - linkUrl: string | null - selectionText: string - canGoBack: boolean - canGoForward: boolean - }) => void - ): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - data: { - browserPageId: string - x: number - y: number - screenX: number - screenY: number - pageUrl: string - linkUrl: string | null - selectionText: string - canGoBack: boolean - canGoForward: boolean - } - ) => callback(data) - ipcRenderer.on('browser:context-menu-requested', listener) - return () => ipcRenderer.removeListener('browser:context-menu-requested', listener) - }, - - onContextMenuDismissed: ( - callback: (event: { browserPageId: string }) => void - ): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, data: { browserPageId: string }) => - callback(data) - ipcRenderer.on('browser:context-menu-dismissed', listener) - return () => ipcRenderer.removeListener('browser:context-menu-dismissed', listener) - }, - - onNavigationUpdate: ( - callback: (event: { browserPageId: string; url: string; title: string }) => void - ): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - data: { browserPageId: string; url: string; title: string } - ) => callback(data) - ipcRenderer.on('browser:navigation-update', listener) - return () => ipcRenderer.removeListener('browser:navigation-update', listener) - }, - - onActivateView: ( - callback: (data: { worktreeId?: string; browserPageId?: string }) => void - ): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - data: { worktreeId?: string; browserPageId?: string } - ) => callback(data) - ipcRenderer.on('browser:activateView', listener) - return () => ipcRenderer.removeListener('browser:activateView', listener) - }, - - onPaneFocus: ( - callback: (data: { worktreeId: string | null; browserPageId: string }) => void - ): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - data: { worktreeId: string | null; browserPageId: string } - ) => callback(data) - ipcRenderer.on('browser:pane-focus', listener) - return () => ipcRenderer.removeListener('browser:pane-focus', listener) - }, - - onOpenLinkInOrcaTab: ( - callback: (event: { browserPageId: string; url: string }) => void - ): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - data: { browserPageId: string; url: string } - ) => callback(data) - ipcRenderer.on('browser:open-link-in-orca-tab', listener) - return () => ipcRenderer.removeListener('browser:open-link-in-orca-tab', listener) - }, - - cancelDownload: (args: { downloadId: string }): Promise => - ipcRenderer.invoke('browser:cancelDownload', args), - - setGrabMode: (args: { - browserPageId: string - enabled: boolean - }): Promise<{ ok: true } | { ok: false; reason: string }> => - ipcRenderer.invoke('browser:setGrabMode', args), - - awaitGrabSelection: (args: { browserPageId: string; opId: string }): Promise => - ipcRenderer.invoke('browser:awaitGrabSelection', args), - - cancelGrab: (args: { browserPageId: string }): Promise => - ipcRenderer.invoke('browser:cancelGrab', args), - - captureSelectionScreenshot: (args: { - browserPageId: string - rect: { x: number; y: number; width: number; height: number } - }): Promise<{ ok: true; screenshot: unknown } | { ok: false; reason: string }> => - ipcRenderer.invoke('browser:captureSelectionScreenshot', args), - - extractHoverPayload: (args: { - browserPageId: string - }): Promise<{ ok: true; payload: unknown } | { ok: false; reason: string }> => - ipcRenderer.invoke('browser:extractHoverPayload', args), - - onGrabModeToggle: (callback: (browserPageId: string) => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, browserPageId: string) => - callback(browserPageId) - ipcRenderer.on('browser:grabModeToggle', listener) - return () => ipcRenderer.removeListener('browser:grabModeToggle', listener) - }, - - onGrabActionShortcut: ( - callback: (args: { browserPageId: string; key: 'c' | 's' }) => void - ): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - data: { browserPageId: string; key: 'c' | 's' } - ) => callback(data) - ipcRenderer.on('browser:grabActionShortcut', listener) - return () => ipcRenderer.removeListener('browser:grabActionShortcut', listener) - }, - - sessionListProfiles: (): Promise => - ipcRenderer.invoke('browser:session:listProfiles'), - - prepareSshWorkspacePartition: (args: { - targetId: string - browserProfileId?: string - skipProbe?: boolean - }): Promise<{ partition: string }> => - ipcRenderer.invoke('browser:prepareSshWorkspacePartition', args), - - sessionCreateProfile: (args: { - scope: 'default' | 'isolated' | 'imported' - label: string - userAgentMode?: 'clean' | 'native' - }): Promise => ipcRenderer.invoke('browser:session:createProfile', args), - - sessionDeleteProfile: (args: { profileId: string }): Promise => - ipcRenderer.invoke('browser:session:deleteProfile', args), - - sessionImportCookies: (args: { - profileId: string - }): Promise< - { ok: true; profileId: string; summary: unknown } | { ok: false; reason: string } - > => ipcRenderer.invoke('browser:session:importCookies', args), - - sessionResolvePartition: (args: { profileId: string | null }): Promise => - ipcRenderer.invoke('browser:session:resolvePartition', args), - - sessionDetectBrowsers: (): Promise => - ipcRenderer.invoke('browser:session:detectBrowsers'), - - sessionDetectBrowsersForClientHost: (args: { - environmentId: string - }): Promise => - ipcRenderer.invoke('browser:session:detectBrowsersForClientHost', args), - - sessionImportFromBrowser: (args: { - profileId: string - browserFamily: string - }): Promise< - { ok: true; profileId: string; summary: unknown } | { ok: false; reason: string } - > => ipcRenderer.invoke('browser:session:importFromBrowser', args), - - sessionImportFromBrowserForClientHost: (args: { - environmentId: string - profileId: string - browserFamily: string - browserProfile?: string - }): Promise< - { ok: true; profileId: string; summary: unknown } | { ok: false; reason: string } | null - > => ipcRenderer.invoke('browser:session:importFromBrowserForClientHost', args), - - sessionClientRouteImportSources: (args: { - environmentId: string - }): Promise> => - ipcRenderer.invoke('browser:session:clientRouteImportSources', args), - - sessionClearDefaultCookies: (): Promise => - ipcRenderer.invoke('browser:session:clearDefaultCookies'), - - notifyActiveTabChanged: (args: { browserPageId: string }): Promise => - ipcRenderer.invoke('browser:activeTabChanged', args) - }, - - emulator: { - startFrameStream: (args: { - streamUrl: string - streamKey?: string - }): Promise<{ - streamId: string - }> => ipcRenderer.invoke('emulator:frameStreamStart', args), - stopFrameStream: (args: { streamId: string }): Promise => - ipcRenderer.invoke('emulator:frameStreamStop', args), - onFrameStreamFrame: ( - callback: (data: { streamId: string; bytes: ArrayBuffer }) => void - ): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - data: { streamId: string; bytes: ArrayBuffer } - ) => callback(data) - ipcRenderer.on('emulator:frameStreamFrame', listener) - return () => ipcRenderer.removeListener('emulator:frameStreamFrame', listener) - }, - onFrameStreamError: ( - callback: (data: { streamId: string; message: string }) => void - ): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - data: { streamId: string; message: string } - ) => callback(data) - ipcRenderer.on('emulator:frameStreamError', listener) - return () => ipcRenderer.removeListener('emulator:frameStreamError', listener) - }, - startVideoStream: (args: { - deviceId: string - streamId: string - }): Promise<{ streamId: string }> => ipcRenderer.invoke('emulator:videoStreamStart', args), - stopVideoStream: (args: { streamId: string }): Promise => - ipcRenderer.invoke('emulator:videoStreamStop', args), - onVideoStreamMeta: ( - callback: (data: { - streamId: string - deviceId: string - meta: { codecId: string; width: number; height: number } - }) => void - ): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - data: { - streamId: string - deviceId: string - meta: { codecId: string; width: number; height: number } - } - ) => callback(data) - ipcRenderer.on('emulator:videoStreamMeta', listener) - return () => ipcRenderer.removeListener('emulator:videoStreamMeta', listener) - }, - onVideoStreamFrame: ( - callback: (data: { - streamId: string - deviceId: string - config: boolean - keyFrame: boolean - bytes: ArrayBuffer - }) => void - ): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - data: { - streamId: string - deviceId: string - config: boolean - keyFrame: boolean - bytes: ArrayBuffer - } - ) => callback(data) - ipcRenderer.on('emulator:videoStreamFrame', listener) - return () => ipcRenderer.removeListener('emulator:videoStreamFrame', listener) - }, - onPaneFocus: (callback: (data: { worktreeId: string }) => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, data: { worktreeId: string }) => - callback(data) - ipcRenderer.on('emulator:pane-focus', listener) - return () => ipcRenderer.removeListener('emulator:pane-focus', listener) - }, - onAutoAttach: ( - callback: (data: { - worktreeId: string - info: { deviceUdid: string; streamUrl: string; wsUrl: string; axUrl?: string } - }) => void - ): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - data: { - worktreeId: string - info: { deviceUdid: string; streamUrl: string; wsUrl: string; axUrl?: string } - } - ) => callback(data) - ipcRenderer.on('ui:emulatorAutoAttach', listener) - return () => ipcRenderer.removeListener('ui:emulatorAutoAttach', listener) - } - }, - - hooks: { - check: (args: { - repoId: string - hostId?: ExecutionHostId - }): Promise<{ - status?: 'ok' | 'error' - hasHooks: boolean - hooks: unknown - mayNeedUpdate: boolean - }> => ipcRenderer.invoke('hooks:check', args), - - inspectSetupScriptImports: (args: { - repoId: string - hostId?: ExecutionHostId - }): Promise => ipcRenderer.invoke('hooks:inspectSetupScriptImports', args), - - createIssueCommandRunner: (args: { - repoId: string - worktreePath: string - command: string - }): Promise => ipcRenderer.invoke('hooks:createIssueCommandRunner', args), - - readIssueCommand: (args: { - repoId: string - hostId?: ExecutionHostId - }): Promise<{ - status?: 'ok' | 'error' - localContent: string | null - sharedContent: string | null - effectiveContent: string | null - localFilePath: string - source: 'local' | 'shared' | 'none' - }> => ipcRenderer.invoke('hooks:readIssueCommand', args), - - writeIssueCommand: (args: { - repoId: string - content: string - hostId?: ExecutionHostId - }): Promise => ipcRenderer.invoke('hooks:writeIssueCommand', args) - }, - - ephemeralVm: { - listRecipes: (args) => ipcRenderer.invoke('ephemeralVm:listRecipes', args), - listRecipeCatalog: () => ipcRenderer.invoke('ephemeralVm:listRecipeCatalog'), - doctor: (args) => ipcRenderer.invoke('ephemeralVm:doctor', args), - provision: (args) => ipcRenderer.invoke('ephemeralVm:provision', args), - cancelProvision: (args) => ipcRenderer.invoke('ephemeralVm:cancelProvision', args), - onProvisionEvent: (callback) => { - const listener = ( - _event: Electron.IpcRendererEvent, - event: { provisionId: string; stream: 'stdout' | 'stderr'; chunk: string } - ): void => callback(event) - ipcRenderer.on('ephemeralVm:provisionEvent', listener) - return () => ipcRenderer.removeListener('ephemeralVm:provisionEvent', listener) - }, - listRuntimes: () => ipcRenderer.invoke('ephemeralVm:listRuntimes'), - attachWorkspace: (args) => ipcRenderer.invoke('ephemeralVm:attachWorkspace', args), - suspendWorkspace: (args) => ipcRenderer.invoke('ephemeralVm:suspendWorkspace', args), - resumeWorkspace: (args) => ipcRenderer.invoke('ephemeralVm:resumeWorkspace', args), - cleanup: (args) => ipcRenderer.invoke('ephemeralVm:cleanup', args), - stopCleanup: (args) => ipcRenderer.invoke('ephemeralVm:stopCleanup', args), - getCleanupCommand: (args) => ipcRenderer.invoke('ephemeralVm:getCleanupCommand', args) - } satisfies PreloadApi['ephemeralVm'], - - cache: { - getGitHub: () => ipcRenderer.invoke('cache:getGitHub'), - setGitHub: (args) => ipcRenderer.invoke('cache:setGitHub', args) - } satisfies PreloadApi['cache'], - - session: { - // hostId is optional; main defaults it to 'local' so existing omitting call sites keep the local session partition. - get: (hostId) => ipcRenderer.invoke('session:get', hostId), - set: (args, hostId) => ipcRenderer.invoke('session:set', args, hostId), - patch: (args, hostId) => ipcRenderer.invoke('session:patch', args, hostId), - flush: () => ipcRenderer.invoke('session:flush'), - readTerminalScrollback: (args) => - ipcRenderer.sendSync('session:read-terminal-scrollback-sync', args), - /** Synchronous session save for beforeunload — blocks until flushed to disk. */ - setSync: (args, hostId) => { - ipcRenderer.sendSync('session:set-sync', args, hostId) - } - } satisfies PreloadApi['session'], - - remoteWorkspace: { - get: (args) => ipcRenderer.invoke('remoteWorkspace:get', args), - setForConnectedTargets: (args) => - ipcRenderer.invoke('remoteWorkspace:setForConnectedTargets', args), - listEnabledConnectedTargets: () => - ipcRenderer.invoke('remoteWorkspace:listEnabledConnectedTargets'), - listConnectedClients: (args) => - ipcRenderer.invoke('remoteWorkspace:listConnectedClients', args), - clientId: () => ipcRenderer.invoke('remoteWorkspace:clientId'), - onChanged: (callback) => { - const listener = (_event: Electron.IpcRendererEvent, data: RemoteWorkspaceChangedEvent) => - callback(data) - ipcRenderer.on('remoteWorkspace:changed', listener) - return () => ipcRenderer.removeListener('remoteWorkspace:changed', listener) - } - } satisfies PreloadApi['remoteWorkspace'], - - updater: { - getStatus: () => ipcRenderer.invoke('updater:getStatus'), - getVersion: () => ipcRenderer.invoke('updater:getVersion'), - check: (options) => ipcRenderer.invoke('updater:check', options), - download: () => ipcRenderer.invoke('updater:download'), - dismissNudge: () => ipcRenderer.invoke('updater:dismissNudge'), - dismissAvailableUpdate: () => ipcRenderer.invoke('updater:dismissAvailableUpdate'), - getLinuxPackageInstallInstructions: () => - ipcRenderer.invoke('updater:getLinuxPackageInstallInstructions'), - showLinuxPackage: () => ipcRenderer.invoke('updater:showLinuxPackage'), - listBuilds: (channel) => ipcRenderer.invoke('updater:listBuilds', channel), - quitAndInstall: (): Promise => - prepareAndInvokeUpdaterInstall( - window, - updaterQuitAbortRelay, - () => ipcRenderer.invoke('updater:quitAndInstall'), - awaitBeforeUnloadCheckpoint - ), - - onStatus: (callback) => { - const listener = (_event: Electron.IpcRendererEvent, status: UpdateStatus) => callback(status) - ipcRenderer.on('updater:status', listener) - return () => ipcRenderer.removeListener('updater:status', listener) - }, - onClearDismissal: (callback) => { - const listener = (_event: Electron.IpcRendererEvent) => callback() - ipcRenderer.on('updater:clearDismissal', listener) - return () => ipcRenderer.removeListener('updater:clearDismissal', listener) - } - } satisfies PreloadApi['updater'], - - docPreview: { - mintGrant: (request: DocPreviewGrantRequest): Promise<{ grantId: string; url: string }> => - ipcRenderer.invoke(DOC_PREVIEW_MINT_GRANT_CHANNEL, request), - revokeGrant: (grantId: string): Promise => - ipcRenderer.invoke(DOC_PREVIEW_REVOKE_GRANT_CHANNEL, grantId), - authorizeDirectory: (grantId: string, relativePath: string): Promise => - ipcRenderer.invoke(DOC_PREVIEW_AUTHORIZE_DIRECTORY_CHANNEL, grantId, relativePath), - onExternalLink: (callback: (payload: { url: string }) => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, payload: { url: string }): void => - callback(payload) - ipcRenderer.on(DOC_PREVIEW_EXTERNAL_LINK_CHANNEL, listener) - return () => ipcRenderer.removeListener(DOC_PREVIEW_EXTERNAL_LINK_CHANNEL, listener) - }, - onLoadFailure: (callback: (payload: DocPreviewFailure) => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, payload: DocPreviewFailure): void => - callback(payload) - ipcRenderer.on(DOC_PREVIEW_LOAD_FAILURE_CHANNEL, listener) - return () => ipcRenderer.removeListener(DOC_PREVIEW_LOAD_FAILURE_CHANNEL, listener) - } - }, - - notebook: { - runPythonCell: (args: { - filePath: string - code: string - preamble?: string - connectionId?: string | null - }): Promise<{ stdout: string; stderr: string; exitCode: number | null; error?: string }> => - ipcRenderer.invoke('notebook:runPythonCell', args) - }, - - fs: { - readDir: (args: { - dirPath: string - connectionId?: string - }): Promise<{ name: string; isDirectory: boolean; isSymlink: boolean }[]> => - ipcRenderer.invoke('fs:readDir', args), - readFile: (args: { - filePath: string - connectionId?: string - includeLocalLogMetadata?: boolean - }): Promise<{ - content: string - isBinary: boolean - isImage?: boolean - mimeType?: string - fileIdentity?: string - }> => ipcRenderer.invoke('fs:readFile', args), - readLocalLogTail: (args: LocalLogTailReadArgs): Promise => - ipcRenderer.invoke('fs:readLocalLogTail', args), - startLocalLogTail: (args: LocalLogTailWatchArgs): Promise => - ipcRenderer.invoke('fs:startLocalLogTail', args), - stopLocalLogTail: (args: { subscriptionId: string }): Promise => - ipcRenderer.invoke('fs:stopLocalLogTail', args), - onLocalLogTailChanged: ( - callback: (payload: LocalLogTailChangedPayload) => void - ): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - payload: LocalLogTailChangedPayload - ): void => callback(payload) - ipcRenderer.on('fs:localLogTailChanged', listener) - return () => ipcRenderer.removeListener('fs:localLogTailChanged', listener) - }, - downloadFile: (args: { - filePath: string - connectionId: string - }): Promise<{ canceled: true } | { canceled: false; destinationPath: string }> => - ipcRenderer.invoke('fs:downloadFile', args), - downloadFolder: (args: { - dirPath: string - connectionId: string - }): Promise<{ canceled: true } | { canceled: false; destinationPath: string }> => - ipcRenderer.invoke('fs:downloadFolder', args), - saveDownloadedFile: (args: { - suggestedName: string - content: string - encoding: 'utf8' | 'base64' - }): Promise<{ canceled: true } | { canceled: false; destinationPath: string }> => - ipcRenderer.invoke('fs:saveDownloadedFile', args), - startDownloadedFile: (args: { - suggestedName: string - }): Promise< - { canceled: true } | { canceled: false; transferId: string; destinationPath: string } - > => ipcRenderer.invoke('fs:startDownloadedFile', args), - appendDownloadedFileChunk: (args: { - transferId: string - contentBase64: string - }): Promise<{ ok: true }> => ipcRenderer.invoke('fs:appendDownloadedFileChunk', args), - finishDownloadedFile: (args: { - transferId: string - }): Promise<{ canceled: false; destinationPath: string }> => - ipcRenderer.invoke('fs:finishDownloadedFile', args), - cancelDownloadedFile: (args: { transferId: string }): Promise<{ ok: true }> => - ipcRenderer.invoke('fs:cancelDownloadedFile', args), - listMarkdownDocuments: (args: { - rootPath: string - connectionId?: string - }): Promise<{ filePath: string; relativePath: string; basename: string; name: string }[]> => - ipcRenderer.invoke('fs:listMarkdownDocuments', args), - writeFile: ( - args: { - filePath: string - content: string - connectionId?: string - } & SshMutationExpectation - ): Promise => ipcRenderer.invoke('fs:writeFile', args), - createFile: ( - args: { filePath: string; connectionId?: string } & SshMutationExpectation - ): Promise => ipcRenderer.invoke('fs:createFile', args), - createDir: ( - args: { dirPath: string; connectionId?: string } & SshMutationExpectation - ): Promise => ipcRenderer.invoke('fs:createDir', args), - rename: ( - args: { oldPath: string; newPath: string; connectionId?: string } & SshMutationExpectation - ): Promise => ipcRenderer.invoke('fs:rename', args), - copy: ( - args: { - sourcePath: string - destinationPath: string - connectionId?: string - } & SshMutationExpectation - ): Promise => ipcRenderer.invoke('fs:copy', args), - deletePath: ( - args: { - targetPath: string - connectionId?: string - recursive?: boolean - } & SshMutationExpectation - ): Promise => ipcRenderer.invoke('fs:deletePath', args), - authorizeExternalPath: (args: { targetPath: string }): Promise => - ipcRenderer.invoke('fs:authorizeExternalPath', args), - stat: (args: { - filePath: string - connectionId?: string - }): Promise<{ size: number; isDirectory: boolean; mtime: number }> => - ipcRenderer.invoke('fs:stat', args), - pathExists: (args: { filePath: string; connectionId?: string }): Promise => - ipcRenderer.invoke('fs:pathExists', args), - listFiles: (args: { - rootPath: string - connectionId?: string - excludePaths?: string[] - requestToken?: string - maxResults?: number - searchQuery?: string - }): Promise => ipcRenderer.invoke('fs:listFiles', args), - cancelListFiles: (args: { requestToken: string }): Promise => - ipcRenderer.invoke('fs:cancelListFiles', args), - search: (args: { - query: string - rootPath: string - caseSensitive?: boolean - wholeWord?: boolean - useRegex?: boolean - includePattern?: string - excludePattern?: string - maxResults?: number - connectionId?: string - }): Promise => ipcRenderer.invoke('fs:search', args), - importExternalPaths: ( - args: { - sourcePaths: string[] - destDir: string - connectionId?: string - ensureDir?: boolean - } & SshMutationExpectation - ): Promise<{ - results: ( - | { - sourcePath: string - status: 'imported' - destPath: string - kind: 'file' | 'directory' - renamed: boolean - } - | { - sourcePath: string - status: 'skipped' - reason: 'missing' | 'symlink' | 'permission-denied' | 'unsupported' - } - | { - sourcePath: string - status: 'failed' - reason: string - } - )[] - }> => ipcRenderer.invoke('fs:importExternalPaths', args), - stageExternalPathsForRuntimeUpload: (args: { - sourcePaths: string[] - }): Promise<{ - sources: ( - | { - sourcePath: string - status: 'staged' - name: string - kind: 'file' | 'directory' - entries: ( - | { relativePath: string; kind: 'directory' } - | { relativePath: string; kind: 'file'; contentBase64: string } - )[] - } - | { - sourcePath: string - status: 'skipped' - reason: 'missing' | 'symlink' | 'permission-denied' | 'unsupported' - } - | { - sourcePath: string - status: 'failed' - reason: string - } - )[] - }> => ipcRenderer.invoke('fs:stageExternalPathsForRuntimeUpload', args), - resolveDroppedPathsForAgent: ( - args: { - paths: string[] - worktreePath: string - connectionId?: string - } & SshMutationExpectation - ): Promise<{ - resolvedPaths: string[] - skipped: { - sourcePath: string - reason: 'missing' | 'symlink' | 'permission-denied' | 'unsupported' - }[] - failed: { sourcePath: string; reason: string }[] - }> => ipcRenderer.invoke('fs:resolveDroppedPathsForAgent', args), - watchWorktree: (args: { worktreePath: string; connectionId?: string }): Promise => - ipcRenderer.invoke('fs:watchWorktree', args), - unwatchWorktree: (args: { worktreePath: string; connectionId?: string }): Promise => - ipcRenderer.invoke('fs:unwatchWorktree', args), - onFsChanged: (callback: (payload: FsChangedPayload) => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, payload: FsChangedPayload) => - callback(payload) - ipcRenderer.on('fs:changed', listener) - return () => ipcRenderer.removeListener('fs:changed', listener) - } - }, - - git: { - status: (args: { - worktreePath: string - connectionId?: string - admissionTier?: 'interactive' | 'status' | 'background' - includeIgnored?: boolean - includeLineStats?: boolean - bypassEffectiveUpstreamNegativeCache?: boolean - reuseLineStats?: boolean - branchLineTotalMergeBase?: string - requestToken?: string - }): Promise => ipcRenderer.invoke('git:status', args), - cancelStatus: (args: { requestToken: string }): Promise => - ipcRenderer.invoke('git:cancelStatus', args), - setStatusUpstreamRefWatch: (args: { - worktreeId: string - worktreePath: string - executionHostId: string - connectionId?: string - branch?: string - upstreamName?: string - }): Promise => ipcRenderer.invoke('git:setStatusUpstreamRefWatch', args), - submoduleStatus: (args: { - worktreePath: string - submodulePath: string - connectionId?: string - area?: GitStagingArea - }): Promise => ipcRenderer.invoke('git:submoduleStatus', args), - checkIgnored: (args: { - worktreePath: string - paths: string[] - connectionId?: string - }): Promise => ipcRenderer.invoke('git:checkIgnored', args), - findHugeFoldersToIgnore: (args: { worktreePath: string }): Promise => - ipcRenderer.invoke('git:findHugeFoldersToIgnore', args), - appendGitignore: (args: { worktreePath: string; folderName: string }): Promise => - ipcRenderer.invoke('git:appendGitignore', args), - history: ( - args: { worktreePath: string; connectionId?: string } & GitHistoryOptions - ): Promise => ipcRenderer.invoke('git:history', args), - conflictOperation: (args: { worktreePath: string; connectionId?: string }): Promise => - ipcRenderer.invoke('git:conflictOperation', args), - abortMerge: (args: { worktreePath: string; connectionId?: string }): Promise => - ipcRenderer.invoke('git:abortMerge', args), - abortRebase: (args: { worktreePath: string; connectionId?: string }): Promise => - ipcRenderer.invoke('git:abortRebase', args), - diff: (args: { - worktreePath: string - filePath: string - staged: boolean - compareAgainstHead?: boolean - connectionId?: string - }): Promise => ipcRenderer.invoke('git:diff', args), - branchCompare: (args: { - worktreePath: string - baseRef: string - connectionId?: string - admissionTier?: 'interactive' | 'status' | 'background' - }): Promise => ipcRenderer.invoke('git:branchCompare', args), - commitCompare: (args: { - worktreePath: string - commitId: string - connectionId?: string - }): Promise => ipcRenderer.invoke('git:commitCompare', args), - upstreamStatus: (args: { - worktreePath: string - connectionId?: string - pushTarget?: GitPushTarget - }): Promise => ipcRenderer.invoke('git:upstreamStatus', args), - fetch: (args: { - worktreePath: string - connectionId?: string - pushTarget?: GitPushTarget - }): Promise => ipcRenderer.invoke('git:fetch', args), - syncFork: (args: { - worktreePath: string - connectionId?: string - expectedUpstream: GitForkSyncExpectedUpstream - }): Promise => ipcRenderer.invoke('git:syncFork', args), - push: (args: { - worktreePath: string - publish?: boolean - forceWithLease?: boolean - connectionId?: string - pushTarget?: unknown - }): Promise => ipcRenderer.invoke('git:push', args), - pull: (args: { - worktreePath: string - connectionId?: string - pushTarget?: GitPushTarget - }): Promise => ipcRenderer.invoke('git:pull', args), - fastForward: (args: { - worktreePath: string - connectionId?: string - pushTarget?: GitPushTarget - }): Promise => ipcRenderer.invoke('git:fastForward', args), - rebaseFromBase: (args: { - worktreePath: string - baseRef: string - connectionId?: string - }): Promise => ipcRenderer.invoke('git:rebaseFromBase', args), - branchDiff: (args: { - worktreePath: string - compare: { baseRef: string; baseOid: string; headOid: string; mergeBase: string } - filePath: string - oldPath?: string - connectionId?: string - }): Promise => ipcRenderer.invoke('git:branchDiff', args), - commitDiff: (args: { - worktreePath: string - commitOid: string - parentOid?: string | null - filePath: string - oldPath?: string - connectionId?: string - }): Promise => ipcRenderer.invoke('git:commitDiff', args), - commit: (args: { - worktreePath: string - message: string - connectionId?: string - }): Promise<{ success: boolean; error?: string }> => ipcRenderer.invoke('git:commit', args), - generateCommitMessage: (args: { - worktreePath: string - worktreeId?: string - repoId?: string - connectionId?: string - sourceControlAiResolvedParams?: unknown - sourceControlAi?: unknown - agentCmdOverrides?: Record - }): Promise => ipcRenderer.invoke('git:generateCommitMessage', args), - discoverCommitMessageModels: (args: { - agentId: string - worktreePath?: string - connectionId?: string - }): Promise => ipcRenderer.invoke('git:discoverCommitMessageModels', args), - cancelGenerateCommitMessage: (args: { - worktreePath: string - connectionId?: string - }): Promise => ipcRenderer.invoke('git:cancelGenerateCommitMessage', args), - generatePullRequestFields: (args: { - worktreePath: string - worktreeId?: string - repoId?: string - base: string - title: string - body: string - draft: boolean - provider?: unknown - useTemplate?: boolean - connectionId?: string - sourceControlAiResolvedParams?: unknown - sourceControlAi?: unknown - agentCmdOverrides?: Record - }): Promise => ipcRenderer.invoke('git:generatePullRequestFields', args), - cancelGeneratePullRequestFields: (args: { - worktreePath: string - connectionId?: string - }): Promise => ipcRenderer.invoke('git:cancelGeneratePullRequestFields', args), - stage: (args: { - worktreePath: string - filePath: string - connectionId?: string - }): Promise => ipcRenderer.invoke('git:stage', args), - bulkStage: (args: { - worktreePath: string - filePaths: string[] - connectionId?: string - }): Promise => ipcRenderer.invoke('git:bulkStage', args), - unstage: (args: { - worktreePath: string - filePath: string - connectionId?: string - }): Promise => ipcRenderer.invoke('git:unstage', args), - bulkUnstage: (args: { - worktreePath: string - filePaths: string[] - connectionId?: string - }): Promise => ipcRenderer.invoke('git:bulkUnstage', args), - discard: (args: { - worktreePath: string - filePath: string - connectionId?: string - }): Promise => ipcRenderer.invoke('git:discard', args), - bulkDiscard: (args: { - worktreePath: string - filePaths: string[] - connectionId?: string - }): Promise => ipcRenderer.invoke('git:bulkDiscard', args), - remoteFileUrl: (args: { - worktreePath: string - relativePath: string - line: number - connectionId?: string - }): Promise => ipcRenderer.invoke('git:remoteFileUrl', args), - remoteCommitUrl: (args: { - worktreePath: string - sha: string - connectionId?: string - }): Promise => ipcRenderer.invoke('git:remoteCommitUrl', args) - }, - - ui: { - get: () => ipcRenderer.invoke('ui:get'), - set: (args) => ipcRenderer.invoke('ui:set', args), - // Same channel: the local invoke already rejects when main fails to apply. - setWithAck: (args) => ipcRenderer.invoke('ui:set', args), - recordFeatureInteraction: (id) => ipcRenderer.invoke('ui:recordFeatureInteraction', id), - onStateChanged: (callback: (ui: PersistedUIState) => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, ui: PersistedUIState): void => - callback(ui) - ipcRenderer.on('ui:stateChanged', listener) - return () => ipcRenderer.removeListener('ui:stateChanged', listener) - }, - onOpenSettings: (callback: () => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent) => callback() - ipcRenderer.on('ui:openSettings', listener) - return () => ipcRenderer.removeListener('ui:openSettings', listener) - }, - consumePendingOpenSettings: (): Promise => - ipcRenderer.invoke('ui:consumePendingOpenSettings'), - onOpenSkillShare: (callback: (shareId: string) => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, shareId: string): void => - callback(shareId) - ipcRenderer.on('ui:openSkillShare', listener) - return () => ipcRenderer.removeListener('ui:openSkillShare', listener) - }, - consumePendingSkillShare: (): Promise => - ipcRenderer.invoke('ui:consumePendingSkillShare'), - onOpenSetupGuide: (callback: () => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent) => callback() - ipcRenderer.on('ui:openSetupGuide', listener) - return () => ipcRenderer.removeListener('ui:openSetupGuide', listener) - }, - onOpenFeatureTour: (callback: () => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent) => callback() - ipcRenderer.on('ui:openFeatureTour', listener) - return () => ipcRenderer.removeListener('ui:openFeatureTour', listener) - }, - onOpenCrashReport: (callback: () => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent) => callback() - ipcRenderer.on('ui:openCrashReport', listener) - return () => ipcRenderer.removeListener('ui:openCrashReport', listener) - }, - onToggleLeftSidebar: (callback: () => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent) => callback() - ipcRenderer.on('ui:toggleLeftSidebar', listener) - return () => ipcRenderer.removeListener('ui:toggleLeftSidebar', listener) - }, - onToggleRightSidebar: (callback: () => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent) => callback() - ipcRenderer.on('ui:toggleRightSidebar', listener) - return () => ipcRenderer.removeListener('ui:toggleRightSidebar', listener) - }, - onToggleWorktreePalette: (callback: () => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent) => callback() - ipcRenderer.on('ui:toggleWorktreePalette', listener) - return () => ipcRenderer.removeListener('ui:toggleWorktreePalette', listener) - }, - onToggleFloatingTerminal: (callback: () => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent) => callback() - ipcRenderer.on('ui:toggleFloatingTerminal', listener) - return () => ipcRenderer.removeListener('ui:toggleFloatingTerminal', listener) - }, - onTerminalShortcutCaptured: ( - callback: (data: { actionId: KeybindingActionId }) => void - ): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - data: { actionId: KeybindingActionId } - ) => callback(data) - ipcRenderer.on('ui:terminalShortcutCaptured', listener) - return () => ipcRenderer.removeListener('ui:terminalShortcutCaptured', listener) - }, - onOpenQuickOpen: (callback: () => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent) => callback() - ipcRenderer.on('ui:openQuickOpen', listener) - return () => ipcRenderer.removeListener('ui:openQuickOpen', listener) - }, - onToggleQuickCommandsMenu: (callback: () => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent) => callback() - ipcRenderer.on('ui:toggleQuickCommandsMenu', listener) - return () => ipcRenderer.removeListener('ui:toggleQuickCommandsMenu', listener) - }, - onOpenNewWorkspace: (callback: () => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent) => callback() - ipcRenderer.on('ui:openNewWorkspace', listener) - return () => ipcRenderer.removeListener('ui:openNewWorkspace', listener) - }, - onDeleteCurrentWorkspace: (callback: () => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent) => callback() - ipcRenderer.on('ui:deleteCurrentWorkspace', listener) - return () => ipcRenderer.removeListener('ui:deleteCurrentWorkspace', listener) - }, - onOpenWorkspaceBoard: (callback: () => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent) => callback() - ipcRenderer.on('ui:openWorkspaceBoard', listener) - return () => ipcRenderer.removeListener('ui:openWorkspaceBoard', listener) - }, - onOpenTasks: (callback: () => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent) => callback() - ipcRenderer.on('ui:openTasks', listener) - return () => ipcRenderer.removeListener('ui:openTasks', listener) - }, - onToggleAgentDashboard: (callback: () => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent) => callback() - ipcRenderer.on('ui:toggleAgentDashboard', listener) - return () => ipcRenderer.removeListener('ui:toggleAgentDashboard', listener) - }, - onJumpToWorktreeIndex: (callback: (index: number) => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, index: number) => callback(index) - ipcRenderer.on('ui:jumpToWorktreeIndex', listener) - return () => ipcRenderer.removeListener('ui:jumpToWorktreeIndex', listener) - }, - onJumpToTabIndex: (callback: (index: number) => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, index: number) => callback(index) - ipcRenderer.on('ui:jumpToTabIndex', listener) - return () => ipcRenderer.removeListener('ui:jumpToTabIndex', listener) - }, - onWorktreeHistoryNavigate: ( - callback: (direction: 'back' | 'forward') => void - ): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, direction: 'back' | 'forward') => - callback(direction) - ipcRenderer.on('ui:worktreeHistoryNavigate', listener) - return () => ipcRenderer.removeListener('ui:worktreeHistoryNavigate', listener) - }, - onNewBrowserTab: (callback: () => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent) => callback() - ipcRenderer.on('ui:newBrowserTab', listener) - return () => ipcRenderer.removeListener('ui:newBrowserTab', listener) - }, - onNewMarkdownTab: (callback: () => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent) => callback() - ipcRenderer.on('ui:newMarkdownTab', listener) - return () => ipcRenderer.removeListener('ui:newMarkdownTab', listener) - }, - onNewSimulatorTab: (callback: () => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent) => callback() - ipcRenderer.on('ui:newSimulatorTab', listener) - return () => ipcRenderer.removeListener('ui:newSimulatorTab', listener) - }, - onRequestTabCreate: ( - callback: (data: { - requestId: string - url: string - worktreeId?: string - browserPageId?: string - sessionProfileId?: string | null - sessionPartition?: string - activate?: boolean - }) => void - ): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - data: { - requestId: string - url: string - worktreeId?: string - browserPageId?: string - sessionProfileId?: string | null - sessionPartition?: string - activate?: boolean - } - ) => callback(data) - ipcRenderer.on('browser:requestTabCreate', listener) - return () => ipcRenderer.removeListener('browser:requestTabCreate', listener) - }, - replyTabCreate: (reply: { - requestId: string - browserPageId?: string - error?: string - }): void => { - ipcRenderer.send('browser:tabCreateReply', reply) - }, - onRequestTabSetProfile: ( - callback: (data: { - requestId: string - browserPageId: string - profileId: string - sessionPartition?: string - }) => void - ): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - data: { - requestId: string - browserPageId: string - profileId: string - sessionPartition?: string - } - ) => callback(data) - ipcRenderer.on('browser:requestTabSetProfile', listener) - return () => ipcRenderer.removeListener('browser:requestTabSetProfile', listener) - }, - replyTabSetProfile: (reply: { requestId: string; error?: string }): void => { - ipcRenderer.send('browser:tabSetProfileReply', reply) - }, - onRequestTabClose: ( - callback: (data: { requestId: string; tabId: string | null; worktreeId?: string }) => void - ): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - data: { requestId: string; tabId: string | null; worktreeId?: string } - ) => callback(data) - ipcRenderer.on('browser:requestTabClose', listener) - return () => ipcRenderer.removeListener('browser:requestTabClose', listener) - }, - replyTabClose: (reply: { - requestId: string - error?: string - code?: 'browser_tab_not_found' - }): void => { - ipcRenderer.send('browser:tabCloseReply', reply) - }, - onNewTerminalTab: (callback: () => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent) => callback() - ipcRenderer.on('ui:newTerminalTab', listener) - return () => ipcRenderer.removeListener('ui:newTerminalTab', listener) - }, - onFocusBrowserAddressBar: (callback: () => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent) => callback() - ipcRenderer.on('ui:focusBrowserAddressBar', listener) - return () => ipcRenderer.removeListener('ui:focusBrowserAddressBar', listener) - }, - onFindInBrowserPage: browserFindSubscriptions.subscribe, - onReloadBrowserPage: (callback: () => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent) => callback() - ipcRenderer.on('ui:reloadBrowserPage', listener) - return () => ipcRenderer.removeListener('ui:reloadBrowserPage', listener) - }, - onBrowserHistoryNavigate: (callback: (direction: 'back' | 'forward') => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, direction: 'back' | 'forward'): void => - callback(direction) - ipcRenderer.on('ui:browserHistoryNavigate', listener) - return () => ipcRenderer.removeListener('ui:browserHistoryNavigate', listener) - }, - onZoomBrowserPage: (callback: (direction: 'in' | 'out' | 'reset') => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, direction: 'in' | 'out' | 'reset') => - callback(direction) - ipcRenderer.on('ui:zoomBrowserPage', listener) - return () => ipcRenderer.removeListener('ui:zoomBrowserPage', listener) - }, - onScrollBrowserPage: ( - callback: (event: { browserPageId: string; deltaX: number; deltaY: number }) => void - ): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - payload: { browserPageId: string; deltaX: number; deltaY: number } - ) => callback(payload) - ipcRenderer.on('ui:scrollBrowserPage', listener) - return () => ipcRenderer.removeListener('ui:scrollBrowserPage', listener) - }, - onHardReloadBrowserPage: (callback: () => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent) => callback() - ipcRenderer.on('ui:hardReloadBrowserPage', listener) - return () => ipcRenderer.removeListener('ui:hardReloadBrowserPage', listener) - }, - onCloseActiveTab: (callback: (payload?: CloseActiveTabPayload) => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, payload?: unknown): void => { - const admitted = admitCloseActiveTabPayload(payload) - if (admitted.kind === 'legacy') { - callback() - } else if (admitted.kind === 'source') { - callback(admitted.payload) - } - } - ipcRenderer.on('ui:closeActiveTab', listener) - return () => ipcRenderer.removeListener('ui:closeActiveTab', listener) - }, - onCloseFloatingItem: (callback: (payload: { sourceId: string }) => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, payload: { sourceId: string }) => - callback(payload) - ipcRenderer.on('ui:closeFloatingItem', listener) - return () => ipcRenderer.removeListener('ui:closeFloatingItem', listener) - }, - onSelectFloatingIndex: (callback: (payload: { index: number }) => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, payload: { index: number }) => - callback(payload) - ipcRenderer.on('ui:selectFloatingIndex', listener) - return () => ipcRenderer.removeListener('ui:selectFloatingIndex', listener) - }, - onSwitchTab: (callback: (direction: 1 | -1) => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, direction: 1 | -1) => callback(direction) - ipcRenderer.on('ui:switchTab', listener) - return () => ipcRenderer.removeListener('ui:switchTab', listener) - }, - onSwitchTabAcrossAllTypes: (callback: (direction: 1 | -1) => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, direction: 1 | -1) => callback(direction) - ipcRenderer.on('ui:switchTabAcrossAllTypes', listener) - return () => ipcRenderer.removeListener('ui:switchTabAcrossAllTypes', listener) - }, - onSwitchRecentTab: (callback: () => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent) => callback() - ipcRenderer.on('ui:switchRecentTab', listener) - return () => ipcRenderer.removeListener('ui:switchRecentTab', listener) - }, - onSwitchTerminalTab: (callback: (direction: 1 | -1) => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, direction: 1 | -1) => callback(direction) - ipcRenderer.on('ui:switchTerminalTab', listener) - return () => ipcRenderer.removeListener('ui:switchTerminalTab', listener) - }, - onCtrlTabKeyDown: (callback: (data: { shiftKey: boolean }) => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, data: { shiftKey: boolean }) => - callback(data) - ipcRenderer.on('ui:ctrlTabKeyDown', listener) - return () => ipcRenderer.removeListener('ui:ctrlTabKeyDown', listener) - }, - onCtrlTabKeyUp: (callback: () => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent) => callback() - ipcRenderer.on('ui:ctrlTabKeyUp', listener) - return () => ipcRenderer.removeListener('ui:ctrlTabKeyUp', listener) - }, - onToggleStatusBar: (callback: () => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent) => callback() - ipcRenderer.on('ui:toggleStatusBar', listener) - return () => ipcRenderer.removeListener('ui:toggleStatusBar', listener) - }, - onExportPdfRequested: (callback: () => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent) => callback() - ipcRenderer.on('export:requestPdf', listener) - return () => ipcRenderer.removeListener('export:requestPdf', listener) - }, - onAppMenuPaste: (callback: () => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent) => callback() - ipcRenderer.on('ui:appMenuPaste', listener) - return () => ipcRenderer.removeListener('ui:appMenuPaste', listener) - }, - onAppMenuSelectionAction: (callback: (action: 'copy' | 'select-all') => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, action: 'copy' | 'select-all'): void => - callback(action) - ipcRenderer.on('ui:appMenuSelectionAction', listener) - return () => ipcRenderer.removeListener('ui:appMenuSelectionAction', listener) - }, - onEditableContextPaste: ( - callback: (data: { plainTextOnly: boolean }) => void - ): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - data: { plainTextOnly: boolean } - ): void => callback({ plainTextOnly: data?.plainTextOnly === true }) - ipcRenderer.on('ui:editableContextPaste', listener) - return () => ipcRenderer.removeListener('ui:editableContextPaste', listener) - }, - onDictationKeyDown: (callback: () => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent) => callback() - ipcRenderer.on('ui:dictationKeyDown', listener) - return () => ipcRenderer.removeListener('ui:dictationKeyDown', listener) - }, - onActivateWorktree: ( - callback: (data: { - repoId: string - worktreeId: string - setup?: WorktreeSetupLaunch - startup?: { command: string; env?: Record } - defaultTabs?: WorktreeDefaultTabsLaunch - }) => void - ): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - data: { - repoId: string - worktreeId: string - setup?: WorktreeSetupLaunch - startup?: { command: string; env?: Record } - defaultTabs?: WorktreeDefaultTabsLaunch - } - ) => callback(data) - ipcRenderer.on('ui:activateWorktree', listener) - return () => ipcRenderer.removeListener('ui:activateWorktree', listener) - }, - onCreateTerminal: ( - callback: (data: { - requestId?: string - worktreeId: string - command?: string - cwd?: string - env?: Record - launchConfig?: SleepingAgentLaunchConfig - resumeProviderSession?: AgentProviderSessionMetadata - launchToken?: string - launchAgent?: TuiAgent - viewMode?: 'terminal' | 'chat' - title?: string - ptyId?: string - activate?: boolean - focus?: boolean - presentation?: RuntimeTerminalPresentation - surfaceOwner?: false - tabId?: string - leafId?: string - splitFromLeafId?: string - splitDirection?: 'horizontal' | 'vertical' - splitTelemetrySource?: TerminalPaneSplitSource - }) => void - ): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - data: { - requestId?: string - worktreeId: string - command?: string - cwd?: string - env?: Record - launchConfig?: SleepingAgentLaunchConfig - resumeProviderSession?: AgentProviderSessionMetadata - launchToken?: string - launchAgent?: TuiAgent - viewMode?: 'terminal' | 'chat' - title?: string - ptyId?: string - activate?: boolean - focus?: boolean - presentation?: RuntimeTerminalPresentation - surfaceOwner?: false - tabId?: string - leafId?: string - splitFromLeafId?: string - splitDirection?: 'horizontal' | 'vertical' - splitTelemetrySource?: TerminalPaneSplitSource - } - ) => callback(data) - ipcRenderer.on('ui:createTerminal', listener) - return () => ipcRenderer.removeListener('ui:createTerminal', listener) - }, - onRequestTerminalCreate: ( - callback: (data: RuntimeTerminalCreateRequestPayload) => void - ): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - data: RuntimeTerminalCreateRequestPayload - ) => callback(data) - ipcRenderer.on('terminal:requestTabCreate', listener) - return () => ipcRenderer.removeListener('terminal:requestTabCreate', listener) - }, - onRequestTerminalTabMount: ( - callback: (data: { worktreeId: string; tabId?: string; ptyId?: string }) => void - ): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - data: { worktreeId: string; tabId?: string; ptyId?: string } - ) => callback(data) - ipcRenderer.on('terminal:requestTabMount', listener) - return () => ipcRenderer.removeListener('terminal:requestTabMount', listener) - }, - replyTerminalCreate: (reply: TerminalTabCreateReply): void => { - ipcRenderer.send('terminal:tabCreateReply', reply) - }, - onSplitTerminal: ( - callback: (data: { - tabId: string - paneRuntimeId: number - direction: 'horizontal' | 'vertical' - command?: string - worktreeId?: string - sourceLeafId?: string - telemetrySource?: TerminalPaneSplitSource - newLeafId?: string - }) => void - ): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - data: { - tabId: string - paneRuntimeId: number - direction: 'horizontal' | 'vertical' - command?: string - worktreeId?: string - sourceLeafId?: string - telemetrySource?: TerminalPaneSplitSource - newLeafId?: string - } - ) => callback(data) - ipcRenderer.on('ui:splitTerminal', listener) - return () => ipcRenderer.removeListener('ui:splitTerminal', listener) - }, - onRenameTerminal: ( - callback: (data: { tabId: string; title: string | null }) => void - ): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - data: { tabId: string; title: string | null } - ) => callback(data) - ipcRenderer.on('ui:renameTerminal', listener) - return () => ipcRenderer.removeListener('ui:renameTerminal', listener) - }, - onFocusTerminal: ( - callback: (data: { - tabId: string - worktreeId: string - leafId?: string | null - ackPaneKeyOnSuccess?: string - flashFocusedPane?: boolean - scrollToBottomIfOutputSinceLastView?: boolean - }) => void - ): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - data: { - tabId: string - worktreeId: string - leafId?: string | null - ackPaneKeyOnSuccess?: string - flashFocusedPane?: boolean - scrollToBottomIfOutputSinceLastView?: boolean - } - ) => callback(data) - ipcRenderer.on('ui:focusTerminal', listener) - return () => ipcRenderer.removeListener('ui:focusTerminal', listener) - }, - onFocusEditorTab: ( - callback: (data: { tabId: string; worktreeId: string }) => void - ): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - data: { tabId: string; worktreeId: string } - ) => callback(data) - ipcRenderer.on('ui:focusEditorTab', listener) - return () => ipcRenderer.removeListener('ui:focusEditorTab', listener) - }, - onCloseSessionTab: ( - callback: (data: { tabId: string; worktreeId: string }) => void - ): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - data: { tabId: string; worktreeId: string } - ) => callback(data) - ipcRenderer.on('ui:closeSessionTab', listener) - return () => ipcRenderer.removeListener('ui:closeSessionTab', listener) - }, - onSessionTabCloseRequest: (callback) => { - const listener = ( - _event: Electron.IpcRendererEvent, - request: Parameters[0] - ) => callback(request) - ipcRenderer.on('ui:sessionTabCloseRequest', listener) - return () => ipcRenderer.removeListener('ui:sessionTabCloseRequest', listener) - }, - respondSessionTabClose: (response) => { - ipcRenderer.send('ui:sessionTabCloseResponse', response) - }, - onMoveSessionTab: ( - callback: (data: { worktreeId: string } & RuntimeMobileSessionTabMove) => void - ): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - data: { worktreeId: string } & RuntimeMobileSessionTabMove - ) => callback(data) - ipcRenderer.on('ui:moveSessionTab', listener) - return () => ipcRenderer.removeListener('ui:moveSessionTab', listener) - }, - onOpenFileFromMobile: ( - callback: (data: { - worktreeId: string - filePath: string - relativePath: string - runtimeEnvironmentId?: string - }) => void - ): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - data: { - worktreeId: string - filePath: string - relativePath: string - runtimeEnvironmentId?: string - } - ) => callback(data) - ipcRenderer.on('ui:openFileFromMobile', listener) - return () => ipcRenderer.removeListener('ui:openFileFromMobile', listener) - }, - onOpenDiffFromMobile: ( - callback: (data: { - worktreeId: string - filePath: string - relativePath: string - staged: boolean - runtimeEnvironmentId?: string - }) => void - ): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - data: { - worktreeId: string - filePath: string - relativePath: string - staged: boolean - runtimeEnvironmentId?: string - } - ) => callback(data) - ipcRenderer.on('ui:openDiffFromMobile', listener) - return () => ipcRenderer.removeListener('ui:openDiffFromMobile', listener) - }, - onMobileMarkdownRequest: ( - callback: (request: RuntimeMobileMarkdownRequest) => void - ): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, request: RuntimeMobileMarkdownRequest) => - callback(request) - ipcRenderer.on('ui:mobileMarkdownRequest', listener) - return () => ipcRenderer.removeListener('ui:mobileMarkdownRequest', listener) - }, - respondMobileMarkdownRequest: (response: RuntimeMobileMarkdownResponse): void => { - ipcRenderer.send('ui:mobileMarkdownResponse', response) - }, - onCloseTerminal: ( - callback: (data: { tabId: string; paneRuntimeId?: number }) => void - ): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - data: { tabId: string; paneRuntimeId?: number } - ) => callback(data) - ipcRenderer.on('ui:closeTerminal', listener) - return () => ipcRenderer.removeListener('ui:closeTerminal', listener) - }, - onTerminalTabCloseRequest: (callback) => { - const listener = ( - _event: Electron.IpcRendererEvent, - request: Parameters[0] - ) => callback(request) - ipcRenderer.on('ui:terminalTabCloseRequest', listener) - return () => ipcRenderer.removeListener('ui:terminalTabCloseRequest', listener) - }, - respondTerminalTabClose: (response) => { - ipcRenderer.send('ui:terminalTabCloseResponse', response) - }, - onSleepWorktree: (callback: (data: { worktreeId: string }) => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, data: { worktreeId: string }) => - callback(data) - ipcRenderer.on('ui:sleepWorktree', listener) - return () => ipcRenderer.removeListener('ui:sleepWorktree', listener) - }, - onResumeSleepingAgents: (callback: (data: { worktreeId: string }) => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, data: { worktreeId: string }) => - callback(data) - ipcRenderer.on('ui:resumeSleepingAgents', listener) - return () => ipcRenderer.removeListener('ui:resumeSleepingAgents', listener) - }, - onTerminalZoom: (callback: (direction: 'in' | 'out' | 'reset') => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, direction: 'in' | 'out' | 'reset') => - callback(direction) - ipcRenderer.on('terminal:zoom', listener) - return () => ipcRenderer.removeListener('terminal:zoom', listener) - }, - readClipboardText: (options?: ReadClipboardTextOptions): Promise => - ipcRenderer.invoke('clipboard:readText', options), - readSelectionClipboardText: (options?: ReadClipboardTextOptions): Promise => - ipcRenderer.invoke('clipboard:readSelectionText', options), - saveClipboardImageAsTempFile: (args?: { - connectionId?: string | null - runtimeEnvironmentId?: string | null - }): Promise => ipcRenderer.invoke('clipboard:saveImageAsTempFile', args), - writeClipboardText: (text: string): Promise => - ipcRenderer.invoke('clipboard:writeText', text), - writeTerminalClipboardText: (text: string): Promise => - ipcRenderer.invoke('clipboard:writeTerminalText', text), - writeSelectionClipboardText: (text: string): Promise => - ipcRenderer.invoke('clipboard:writeSelectionText', text), - writeClipboardImage: (dataUrl: string): Promise => - ipcRenderer.invoke('clipboard:writeImage', dataUrl), - performNativePaste: (options?: { mode?: 'paste' | 'paste-and-match-style' }): void => { - ipcRenderer.send('ui:performNativePaste', { - mode: options?.mode === 'paste-and-match-style' ? 'paste-and-match-style' : 'paste' - }) - }, - performNativeSelectionAction: (action: 'copy' | 'select-all'): void => { - ipcRenderer.send('ui:performNativeSelectionAction', action) - }, - writeClipboardFile: ( - args: - | { - filePath: string - connectionId?: string | null - } - | string - ): Promise<{ ok: boolean; reason?: string }> => ipcRenderer.invoke('clipboard:writeFile', args), - onFileDrop: (callback: (data: NativeFileDropPayload) => void): (() => void) => - subscribeNativeFileDrop(callback), - getZoomLevel: (): number => webFrame.getZoomLevel(), - setZoomLevel: (level: number): void => webFrame.setZoomLevel(level), - syncTrafficLights: (zoomFactor: number): void => - ipcRenderer.send('ui:sync-traffic-lights', zoomFactor), - // Why: one-way send so main's before-input-event can synchronously skip Cmd+B while the markdown editor is focused (TipTap bold). - setMarkdownEditorFocused: (focused: boolean): void => { - ipcRenderer.send('ui:setMarkdownEditorFocused', focused) - }, - setRichMarkdownContextMenuTarget: (target: RichMarkdownContextMenuTableTarget | null): void => { - ipcRenderer.send(richMarkdownContextMenuTargetChannel, target) - }, - setTerminalInputFocused: (focused: boolean): void => { - ipcRenderer.send('ui:setTerminalInputFocused', focused) - }, - // Why: one atomic payload so main's synchronous before-input-event never sees a torn terminal=true/panel=false state. - setFloatingFocus: (state: { panelFocused: boolean; terminalFocused: boolean }): void => { - ipcRenderer.send('ui:setFloatingFocus', state) - }, - setShortcutRecorderFocused: (focused: boolean): void => { - ipcRenderer.send('ui:setShortcutRecorderFocused', focused) - }, - onRichMarkdownContextCommand: ( - callback: (payload: RichMarkdownContextMenuCommandPayload) => void - ): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - payload: RichMarkdownContextMenuCommandPayload - ) => callback(payload) - ipcRenderer.on(richMarkdownContextMenuCommandChannel, listener) - return () => ipcRenderer.removeListener(richMarkdownContextMenuCommandChannel, listener) - }, - onFullscreenChanged: (callback: (isFullScreen: boolean) => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, isFullScreen: boolean) => - callback(isFullScreen) - ipcRenderer.on('window:fullscreen-changed', listener) - return () => ipcRenderer.removeListener('window:fullscreen-changed', listener) - }, - /** Fired when the OS resumes from sleep — a focus-preserving wake fires no renderer focus/visibility events. */ - onSystemResumed: (callback: () => void): (() => void) => { - const listener = () => callback() - ipcRenderer.on('system:resumed', listener) - return () => ipcRenderer.removeListener('system:resumed', listener) - }, - /** Desktop custom titlebar only: minimize via renderer-drawn window controls. */ - minimize: (): void => { - ipcRenderer.send('window:minimize') - }, - /** Desktop custom titlebar only: toggle maximize/restore via renderer-drawn controls. */ - maximize: (): void => { - ipcRenderer.send('window:maximize') - }, - /** Desktop custom titlebar only: read initial maximize state on mount — maximize-changed only fires on transitions. */ - isMaximized: (): Promise => ipcRenderer.invoke('window:isMaximized'), - /** Desktop custom titlebar only: subscribe to maximize-state changes so the maximize button shows the right icon. */ - onMaximizeChanged: (callback: (isMaximized: boolean) => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, isMaximized: boolean) => - callback(isMaximized) - ipcRenderer.on('window:maximize-changed', listener) - return () => ipcRenderer.removeListener('window:maximize-changed', listener) - }, - /** Desktop custom titlebar only: request close via main so the BrowserWindow 'close' event - * (and its terminal-running guard) still fires — window.close() is unreliable in sandboxed renderers. */ - requestClose: (): void => { - ipcRenderer.send('window:request-close') - }, - /** Desktop custom titlebar only: pop up the app menu at the cursor — Alt-reveal replacement for the ··· button. */ - popupMenu: (): void => { - ipcRenderer.send('menu:popup') - }, - /** Fired by main when the user tries to close the window; renderer confirms running - * terminals then calls confirmWindowClose(). isQuitting (Cmd+Q / app.quit) skips that dialog. */ - onWindowCloseRequested: (callback: (data: { isQuitting: boolean }) => void): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - data: { isQuitting: boolean; requestId?: number } - ): void => { - // Why: main cannot reach will-quit while a frozen renderer owns the window close handshake. - ipcRenderer.send('window:close-request-received', data?.requestId) - callback({ isQuitting: data?.isQuitting ?? false }) - } - ipcRenderer.on('window:close-requested', listener) - return () => ipcRenderer.removeListener('window:close-requested', listener) - }, - /** Tell the main process to proceed with the window close. */ - confirmWindowClose: (): void => { - ipcRenderer.send('window:confirm-close') - }, - /** Report a genuine hidden→visible reveal so main can recover a stale (throttled) layout/compositor surface. */ - notifyWindowRevealed: (): void => { - ipcRenderer.send('ui:window-revealed') - } - } satisfies PreloadApi['ui'], - - stats: { - getSummary: (): Promise<{ - totalAgentsSpawned: number - totalPRsCreated: number - totalAgentTimeMs: number - firstEventAt: number | null - }> => ipcRenderer.invoke('stats:summary') - }, - - memory: { - getSnapshot: (): Promise => ipcRenderer.invoke('memory:getSnapshot') - }, - - claudeUsage: createUsageProviderApi(ipcRenderer, 'claudeUsage'), - codexUsage: createUsageProviderApi(ipcRenderer, 'codexUsage'), - openCodeUsage: createUsageProviderApi(ipcRenderer, 'openCodeUsage'), - - aiVault: { - listSessions: (args?: AiVaultListArgs): Promise => - ipcRenderer.invoke('aiVault:listSessions', args), - resolveSessionTitles: (args: AiVaultSessionTitlesArgs): Promise => - ipcRenderer.invoke('aiVault:resolveSessionTitles', args), - cancelListSessions: (args: { requestToken: string }): Promise => - ipcRenderer.invoke('aiVault:cancelListSessions', args), - prepareSessionResume: (args: AiVaultPrepareSessionResumeArgs): Promise => - ipcRenderer.invoke('aiVault:prepareSessionResume', args), - listSubagentSessions: (args: AiVaultSubagentListArgs): Promise => - ipcRenderer.invoke('aiVault:listSubagentSessions', args), - getFirstUserPrompt: (args: AiVaultFirstUserPromptArgs): Promise => - ipcRenderer.invoke('aiVault:getFirstUserPrompt', args), - deleteSession: (args: AiVaultDeleteSessionArgs): Promise => - ipcRenderer.invoke('aiVault:deleteSession', args), - onWindowFocused: (callback: () => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent) => callback() - ipcRenderer.on('aiVault:windowFocused', listener) - return () => ipcRenderer.removeListener('aiVault:windowFocused', listener) - } - }, - - nativeChat: { - readSession: ( - agent: AgentType, - sessionId: string, - limit?: number, - transcriptPath?: string - ): Promise => - ipcRenderer.invoke('nativeChat:readSession', { agent, sessionId, limit, transcriptPath }), - /** Start live tailing; onAppended fires with only newly-appended messages. Returns an unsubscribe fn that closes the watcher. */ - subscribe: ( - args: { - subscriptionId: string - agent: AgentType - sessionId: string - transcriptPath?: string - limit?: number - }, - onFrame: (frame: NativeChatSubscriptionFrame) => void - ): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, payload: NativeChatAppendedPayload) => { - if (payload.subscriptionId === args.subscriptionId) { - onFrame(payload.frame) - } - } - ipcRenderer.on('nativeChat:appended', listener) - ipcRenderer.send('nativeChat:subscribe', args) - return () => { - ipcRenderer.removeListener('nativeChat:appended', listener) - ipcRenderer.send('nativeChat:unsubscribe', { subscriptionId: args.subscriptionId }) - } - } - }, - - runtime: { - syncWindowGraph: ( - graph: RuntimeRendererSyncWindowGraph - ): Promise => - ipcRenderer.invoke('runtime:syncWindowGraph', graph), - getStatus: (): Promise => ipcRenderer.invoke('runtime:getStatus'), - call: (args: { method: string; params?: unknown }): Promise> => - ipcRenderer.invoke('runtime:call', args), - subscribe: async ( - args: { method: string; params?: unknown }, - callback: (response: RuntimeRpcResponse) => void - ): Promise => { - const subscriptionId = `desktop-${crypto.randomUUID()}` - const channel = `runtime:subscription:${subscriptionId}` - const listener = (_event: Electron.IpcRendererEvent, response: RuntimeRpcResponse) => - callback(response) - ipcRenderer.on(channel, listener) - try { - await ipcRenderer.invoke('runtime:subscribe', { subscriptionId, ...args }) - } catch (error) { - ipcRenderer.removeListener(channel, listener) - throw error - } - return { - unsubscribe: () => { - ipcRenderer.removeListener(channel, listener) - ipcRenderer.send('runtime:unsubscribe', { subscriptionId }) - }, - sendBinary: () => { - throw new Error('Local runtime subscriptions do not accept binary input') - } - } - }, - getTerminalFitOverrides: (): Promise< - { ptyId: string; mode: 'mobile-fit' | 'remote-desktop-fit'; cols: number; rows: number }[] - > => ipcRenderer.invoke('runtime:getTerminalFitOverrides'), - getTerminalDrivers: (): Promise< - { - ptyId: string - driver: RuntimeTerminalDriverState - }[] - > => ipcRenderer.invoke('runtime:getTerminalDrivers'), - getBrowserDrivers: (): Promise< - { - browserPageId: string - driver: RuntimeBrowserDriverState - }[] - > => ipcRenderer.invoke('runtime:getBrowserDrivers'), - getBrowserRemoteViewerPages: (): Promise => - ipcRenderer.invoke('runtime:getBrowserRemoteViewerPages'), - getClientHostedBrowserRows: (): Promise => - ipcRenderer.invoke('runtime:getClientHostedBrowserRows'), - restoreTerminalFit: (ptyId: string): Promise<{ restored: boolean }> => - ipcRenderer.invoke('runtime:restoreTerminalFit', { ptyId }), - reclaimBrowserForDesktop: (browserPageId: string): Promise<{ reclaimed: boolean }> => - ipcRenderer.invoke('runtime:reclaimBrowserForDesktop', { browserPageId }), - onTerminalFitOverrideChanged: ( - callback: (event: { - ptyId: string - mode: 'mobile-fit' | 'remote-desktop-fit' | 'desktop-fit' - cols: number - rows: number - }) => void - ): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - data: { - ptyId: string - mode: 'mobile-fit' | 'remote-desktop-fit' | 'desktop-fit' - cols: number - rows: number - } - ) => callback(data) - ipcRenderer.on('runtime:terminalFitOverrideChanged', listener) - return () => ipcRenderer.removeListener('runtime:terminalFitOverrideChanged', listener) - }, - onTerminalDriverChanged: ( - callback: (event: { ptyId: string; driver: RuntimeTerminalDriverState }) => void - ): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - data: { - ptyId: string - driver: RuntimeTerminalDriverState - } - ) => callback(data) - ipcRenderer.on('runtime:terminalDriverChanged', listener) - return () => ipcRenderer.removeListener('runtime:terminalDriverChanged', listener) - }, - onNativeChatLaunchDraftResolved: ( - callback: (event: { tabId: string; text: string; createdAt: number }) => void - ): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - data: { tabId: string; text: string; createdAt: number } - ) => callback(data) - ipcRenderer.on('runtime:nativeChatLaunchDraftResolved', listener) - return () => ipcRenderer.removeListener('runtime:nativeChatLaunchDraftResolved', listener) - }, - onBrowserDriverChanged: ( - callback: (event: { browserPageId: string; driver: RuntimeBrowserDriverState }) => void - ): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - data: { - browserPageId: string - driver: RuntimeBrowserDriverState - } - ) => callback(data) - ipcRenderer.on('runtime:browserDriverChanged', listener) - return () => ipcRenderer.removeListener('runtime:browserDriverChanged', listener) - }, - onBrowserRemoteViewersChanged: ( - callback: (event: { browserPageId: string; hasRemoteViewers: boolean }) => void - ): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - data: { - browserPageId: string - hasRemoteViewers: boolean - } - ) => callback(data) - ipcRenderer.on('runtime:browserRemoteViewersChanged', listener) - return () => ipcRenderer.removeListener('runtime:browserRemoteViewersChanged', listener) - }, - onClientHostedBrowserRowsChanged: ( - callback: (event: ClientHostedBrowserRowsEvent) => void - ): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, data: ClientHostedBrowserRowsEvent) => - callback(data) - ipcRenderer.on('runtime:clientHostedBrowserRowsChanged', listener) - return () => ipcRenderer.removeListener('runtime:clientHostedBrowserRowsChanged', listener) - } - }, - - runtimeEnvironments: { - list: (): Promise => - ipcRenderer.invoke('runtimeEnvironments:list'), - addFromPairingCode: (args: { - name: string - pairingCode: string - }): Promise<{ environment: PublicKnownRuntimeEnvironment }> => - ipcRenderer.invoke('runtimeEnvironments:addFromPairingCode', args), - verifyAndAddFromPairingCode: (args: { - name: string - pairingCode: string - allowLoopback?: boolean - }): Promise => - ipcRenderer.invoke('runtimeEnvironments:verifyAndAddFromPairingCode', args), - resolve: (args: { selector: string }): Promise => - ipcRenderer.invoke('runtimeEnvironments:resolve', args), - remove: (args: { selector: string }): Promise<{ removed: PublicKnownRuntimeEnvironment }> => - ipcRenderer.invoke('runtimeEnvironments:remove', args), - disconnect: (args: { - selector: string - }): Promise<{ disconnected: PublicKnownRuntimeEnvironment }> => - ipcRenderer.invoke('runtimeEnvironments:disconnect', args), - connect: (args: { - selector: string - timeoutMs?: number - }): Promise> => - ipcRenderer.invoke('runtimeEnvironments:connect', args), - getStatus: (args: { - selector: string - timeoutMs?: number - observeOnly?: true - }): Promise> => - ipcRenderer.invoke('runtimeEnvironments:getStatus', args), - retryControlConnection: (args: { selector: string }): Promise => - ipcRenderer.invoke('runtimeEnvironments:retryControlConnection', args), - onSharedControlDiagnostics: ( - callback: (event: { - environmentId: string - transportGeneration: number - diagnostics: RemoteRuntimeSharedConnectionDiagnostics - }) => void - ): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - data: { - environmentId: string - transportGeneration: number - diagnostics: RemoteRuntimeSharedConnectionDiagnostics - } - ): void => callback(data) - ipcRenderer.on(RUNTIME_ENVIRONMENT_DIAGNOSTICS_CHANNEL, listener) - return () => ipcRenderer.removeListener(RUNTIME_ENVIRONMENT_DIAGNOSTICS_CHANNEL, listener) - }, - prepareBrowserClientHostPlacement: (args) => - ipcRenderer.invoke('runtimeEnvironments:prepareBrowserClientHostPlacement', args), - retryConnectionsNow: (): Promise => - ipcRenderer.invoke('runtimeEnvironments:retryConnectionsNow'), - call: (args: { - selector: string - method: string - params?: unknown - timeoutMs?: number - expectedEnvironmentPairingRevision?: number - }): Promise> => - ipcRenderer.invoke('runtimeEnvironments:call', args), - subscribe: async ( - args: { - selector: string - method: string - params?: unknown - timeoutMs?: number - expectedEnvironmentPairingRevision?: number - }, - callbacks: { - onResponse: (response: RuntimeRpcResponse) => void - onBinary?: (bytes: Uint8Array) => void - onError?: (error: { code: string; message: string }) => void - onClose?: () => void - } - ): Promise => - subscribeRuntimeEnvironmentFromPreload(ipcRenderer, args, callbacks) - }, - - rateLimits: { - get: (): Promise => ipcRenderer.invoke('rateLimits:get'), - refresh: (): Promise => ipcRenderer.invoke('rateLimits:refresh'), - refreshCodexForTarget: (target: RateLimitRuntimeTarget): Promise => - ipcRenderer.invoke('rateLimits:refreshCodexForTarget', target), - consumeCodexResetCredit: (): Promise => - ipcRenderer.invoke('rateLimits:consumeCodexResetCredit'), - refreshClaudeForTarget: (target: RateLimitRuntimeTarget): Promise => - ipcRenderer.invoke('rateLimits:refreshClaudeForTarget', target), - setPollingInterval: (ms: number): Promise => - ipcRenderer.invoke('rateLimits:setPollingInterval', ms), - fetchInactiveClaudeAccounts: (): Promise => - ipcRenderer.invoke('rateLimits:fetchInactiveClaudeAccounts'), - fetchInactiveCodexAccounts: (): Promise => - ipcRenderer.invoke('rateLimits:fetchInactiveCodexAccounts'), - refreshMiniMax: (): Promise => ipcRenderer.invoke('rateLimits:refreshMiniMax'), - refreshGrok: (): Promise => ipcRenderer.invoke('rateLimits:refreshGrok'), - onUpdate: (callback: (state: RateLimitState) => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, state: RateLimitState) => callback(state) - ipcRenderer.on('rateLimits:update', listener) - return () => ipcRenderer.removeListener('rateLimits:update', listener) - } - }, - - minimaxCredentials: { - getStatus: (): Promise<{ configured: boolean }> => - ipcRenderer.invoke('minimaxCredentials:getStatus'), - saveCookie: (cookie: string): Promise<{ configured: boolean }> => - ipcRenderer.invoke('minimaxCredentials:saveCookie', cookie), - clearCookie: (): Promise<{ configured: boolean }> => - ipcRenderer.invoke('minimaxCredentials:clearCookie') - }, - - grokAccounts: { - getStatus: (): Promise => ipcRenderer.invoke('grokAccounts:getStatus') - }, - - ssh: { - listTargets: (): Promise => ipcRenderer.invoke('ssh:listTargets'), - - listRemovedTargetLabels: (): Promise> => - ipcRenderer.invoke('ssh:listRemovedTargetLabels'), - - addTarget: (args: { target: SshTargetCreateInput }): Promise => - ipcRenderer.invoke('ssh:addTarget', args), - - updateTarget: (args: { id: string; updates: SshTargetUpdateInput }): Promise => - ipcRenderer.invoke('ssh:updateTarget', args), - - removeTarget: (args: { id: string }): Promise => - ipcRenderer.invoke('ssh:removeTarget', args), - - importConfig: (args?: { reAdopt?: boolean }): Promise => - ipcRenderer.invoke('ssh:importConfig', args), - - listConfigHosts: (args?: SshConfigHostListArgs): Promise => - ipcRenderer.invoke('ssh:listConfigHosts', args), - - resolveConfigHost: (args: { alias: string }): Promise => - ipcRenderer.invoke('ssh:resolveConfigHost', args), - - connect: async (args: { targetId: string }): Promise => { - const state: unknown = await ipcRenderer.invoke('ssh:connect', args) - return state ? admitSshConnectionStateForAuthorityReconciliation(state, args.targetId) : null - }, - - disconnect: (args: { targetId: string }): Promise => - ipcRenderer.invoke('ssh:disconnect', args), - - terminateSessions: (args: { targetId: string }): Promise => - ipcRenderer.invoke('ssh:terminateSessions', args), - - resetRelay: (args: { targetId: string }): Promise => - ipcRenderer.invoke('ssh:resetRelay', args), - - getState: async (args: { targetId: string }): Promise => { - const state: unknown = await ipcRenderer.invoke('ssh:getState', args) - return state ? admitSshConnectionStateForAuthorityReconciliation(state, args.targetId) : null - }, - - needsPassphrasePrompt: (args: { targetId: string }): Promise => - ipcRenderer.invoke('ssh:needsPassphrasePrompt', args), - - testConnection: async (args: { - targetId: string - }): Promise<{ success: boolean; error?: string; state?: SshConnectionState }> => { - const result: { success: boolean; error?: string; state?: unknown } = - await ipcRenderer.invoke('ssh:testConnection', args) - const state = result.state - ? admitSshConnectionStateForAuthorityReconciliation(result.state, args.targetId) - : null - return { ...result, ...(state ? { state } : { state: undefined }) } - }, - - onStateChanged: ( - callback: (data: { targetId: string; state: SshConnectionState }) => void - ): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - data: { targetId: string; state: unknown } - ): void => { - const state = admitSshConnectionStateForAuthorityReconciliation(data.state, data.targetId) - if (state) { - callback({ targetId: data.targetId, state }) - } - } - ipcRenderer.on('ssh:state-changed', listener) - return () => ipcRenderer.removeListener('ssh:state-changed', listener) - }, - - addPortForward: (args: { - targetId: string - localPort: number - remoteHost: string - remotePort: number - label?: string - }): Promise => ipcRenderer.invoke('ssh:addPortForward', args), - - updatePortForward: (args: { - id: string - targetId: string - localPort: number - remoteHost: string - remotePort: number - label?: string - }): Promise => ipcRenderer.invoke('ssh:updatePortForward', args), - - removePortForward: (args: { id: string }): Promise => - ipcRenderer.invoke('ssh:removePortForward', args), - - listPortForwards: (args?: { targetId?: string }): Promise => - ipcRenderer.invoke('ssh:listPortForwards', args), - - listDetectedPorts: async (args: { targetId: string }): Promise => - admitSshDetectedPorts(await ipcRenderer.invoke('ssh:listDetectedPorts', args)), - - onPortForwardsChanged: ( - callback: (data: { targetId: string; forwards: PortForwardEntry[] }) => void - ): (() => void) => { - const handler = ( - _event: Electron.IpcRendererEvent, - data: { targetId: string; forwards: PortForwardEntry[] } - ) => callback(data) - ipcRenderer.on('ssh:port-forwards-changed', handler) - return () => ipcRenderer.removeListener('ssh:port-forwards-changed', handler) - }, - - onDetectedPortsChanged: ( - callback: (data: { targetId: string; ports: EnrichedDetectedPort[] }) => void - ): (() => void) => { - const handler = ( - _event: Electron.IpcRendererEvent, - data: { targetId: string; ports: unknown } - ) => callback({ targetId: data.targetId, ports: admitSshDetectedPorts(data.ports) }) - ipcRenderer.on('ssh:detected-ports-changed', handler) - return () => ipcRenderer.removeListener('ssh:detected-ports-changed', handler) - }, - - browseDir: (args: { - targetId: string - dirPath: string - }): Promise<{ - entries: { name: string; isDirectory: boolean }[] - resolvedPath: string - pathFlavor: FilesystemPathFlavor - }> => ipcRenderer.invoke('ssh:browseDir', args), - - onCredentialRequest: ( - callback: (data: { - requestId: string - targetId: string - kind: 'passphrase' | 'password' | 'keyboard-interactive' - detail: string - }) => void - ): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - data: { - requestId: string - targetId: string - kind: 'passphrase' | 'password' | 'keyboard-interactive' - detail: string - } - ) => callback(data) - ipcRenderer.on('ssh:credential-request', listener) - return () => ipcRenderer.removeListener('ssh:credential-request', listener) - }, - - onCredentialResolved: (callback: (data: { requestId: string }) => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, data: { requestId: string }) => - callback(data) - ipcRenderer.on('ssh:credential-resolved', listener) - return () => ipcRenderer.removeListener('ssh:credential-resolved', listener) - }, - - submitCredential: (args: { requestId: string; value: string | null }): Promise => - ipcRenderer.invoke('ssh:submitCredential', args) - }, - - // Orca automation CRUD rides the local runtime RPC surface (`runtime:call`), - // so only external-manager and dispatch-loop plumbing stays on IPC. - automations: { - listExternalManagerForOwner: ( - request: ScopedExternalManagerListRequest - ): Promise => - ipcRenderer.invoke('automations:listExternalManagerForOwner', request), - listExternalRunsForOwner: ( - request: ScopedExternalManagerRunsRequest - ): Promise => - ipcRenderer.invoke('automations:listExternalRunsForOwner', request), - createExternalForOwner: (request: ScopedExternalManagerCreateRequest): Promise => - ipcRenderer.invoke('automations:createExternalForOwner', request), - updateExternalForOwner: (request: ScopedExternalManagerUpdateRequest): Promise => - ipcRenderer.invoke('automations:updateExternalForOwner', request), - runExternalActionForOwner: (request: ScopedExternalManagerActionRequest): Promise => - ipcRenderer.invoke('automations:runExternalActionForOwner', request), - retainExternalScopes: (request: { owners: readonly AutomationOwnerRef[] }): Promise => - ipcRenderer.invoke('automations:retainExternalScopes', request), - runPrecheck: (args: { - automationId: string - runId: string - }): Promise => - ipcRenderer.invoke('automations:runPrecheck', args), - markDispatchResult: (result: AutomationDispatchResult): Promise => - ipcRenderer.invoke('automations:markDispatchResult', result), - snapshotWorkspaceName: (args: { workspaceId: string; displayName: string }): Promise => - ipcRenderer.invoke('automations:snapshotWorkspaceName', args), - rendererReady: (): Promise => ipcRenderer.invoke('automations:rendererReady'), - onDispatchRequested: (callback: (request: AutomationDispatchRequest) => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, request: AutomationDispatchRequest) => - callback(request) - ipcRenderer.on('automations:dispatchRequested', listener) - return () => ipcRenderer.removeListener('automations:dispatchRequested', listener) - }, - onChanged: (callback: (payload: AutomationsChangedPayload) => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, payload: AutomationsChangedPayload) => - callback(payload) - ipcRenderer.on('automations:changed', listener) - return () => ipcRenderer.removeListener('automations:changed', listener) - } - }, - - e2e: { - getConfig: () => preloadE2EConfig - }, - - mobile: { - listNetworkInterfaces: (): Promise<{ - interfaces: { name: string; address: string; hasDefaultRoute?: boolean }[] - }> => ipcRenderer.invoke('mobile:listNetworkInterfaces'), - - getPairingQR: (args?: { - address?: string - connectionMode?: MobilePairingConnectionMode - rotate?: boolean - }): Promise< - | { - available: false - reason?: string - guidance?: string - relayFailure?: MobileRelayMintFailure - } - | { - available: true - qrDataUrl: string | null - /** Natural bitmap width and height in pixels. */ - qrSize: number | null - qrError?: 'encoding_failed' - pairingUrl: string - /** Null when no direct address was advertised — the QR pairs over Relay alone. */ - endpoint: string | null - deviceId: string - connectionMode: MobilePairingConnectionMode - } - > => ipcRenderer.invoke('mobile:getPairingQR', args), - - getWindowsFirewallStatus: (args?: { address?: string }) => - ipcRenderer.invoke('mobile:getWindowsFirewallStatus', args), - - repairWindowsFirewall: () => ipcRenderer.invoke('mobile:repairWindowsFirewall'), - - openWindowsNetworkSettings: () => ipcRenderer.invoke('mobile:openWindowsNetworkSettings'), - - getRuntimePairingUrl: (args?: { - address?: string - rotate?: boolean - // Why: the widen is one-way and host-wide, so main must gate it on the reach the user picked, not - // on how the typed address happens to look (a Custom loopback may front an SSH tunnel). - reach?: RuntimePairingReach - }): Promise< - | { available: false; reason?: 'network_exposure_failed'; guidance?: string } - | { - available: true - pairingUrl: string - webClientUrl: string | null - endpoint: string - deviceId: string - } - > => ipcRenderer.invoke('mobile:getRuntimePairingUrl', args), - - listDevices: (): Promise<{ - devices: { deviceId: string; name: string; pairedAt: number; lastSeenAt: number }[] - }> => ipcRenderer.invoke('mobile:listDevices'), - - revokeDevice: (args: { deviceId: string }): Promise<{ revoked: boolean }> => - ipcRenderer.invoke('mobile:revokeDevice', args), - - listRuntimeAccessGrants: () => ipcRenderer.invoke('mobile:listRuntimeAccessGrants'), - - revokeRuntimeAccess: (args: { deviceId: string }): Promise<{ revoked: boolean }> => - ipcRenderer.invoke('mobile:revokeRuntimeAccess', args), - - isWebSocketReady: (): Promise<{ ready: boolean; endpoint: string | null }> => - ipcRenderer.invoke('mobile:isWebSocketReady'), - - getRelayStatus: (): Promise<{ status: MobileRelayStatus }> => - ipcRenderer.invoke('mobile:getRelayStatus'), - - onRelayStatusChanged: (callback: (status: MobileRelayStatus) => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, status: MobileRelayStatus) => - callback(status) - ipcRenderer.on('mobile:relayStatusChanged', listener) - return () => ipcRenderer.removeListener('mobile:relayStatusChanged', listener) - }, - - consumePendingUnpairedDeviceAuthFailure: (): Promise => - ipcRenderer.invoke('mobile:consumePendingUnpairedDeviceAuthFailure'), - - /** Fires (throttled, once per session) when an unpaired phone repeatedly fails direct-transport auth. */ - onUnpairedDeviceAuthFailure: (callback: () => void): (() => void) => { - const listener = () => callback() - ipcRenderer.on('mobile:unpairedDeviceAuthFailure', listener) - return () => ipcRenderer.removeListener('mobile:unpairedDeviceAuthFailure', listener) - } - }, - - agentStatus: { - /** Listen for agent status updates forwarded from native hook receivers. */ - onSet: (callback: (data: AgentStatusIpcPayload) => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, data: AgentStatusIpcPayload) => - callback(data) - ipcRenderer.on('agentStatus:set', listener) - return () => ipcRenderer.removeListener('agentStatus:set', listener) - }, - onClear: (callback: (data: AgentStatusClearIpcPayload) => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, data: AgentStatusClearIpcPayload) => - callback(data) - ipcRenderer.on('agentStatus:clear', listener) - return () => ipcRenderer.removeListener('agentStatus:clear', listener) - }, - /** Pull cached hook statuses after renderer hydration, so startup replays aren't lost before tabs exist. */ - getSnapshot: (): Promise => - ipcRenderer.invoke('agentStatus:getSnapshot'), - inferInterrupt: (request: AgentInterruptInferenceRequest): Promise => - ipcRenderer.invoke('agentStatus:inferInterrupt', request), - inferQuestionAnswered: (request: AgentQuestionAnsweredInferenceRequest): Promise => - ipcRenderer.invoke('agentStatus:inferQuestionAnswered', request), - onMigrationUnsupported: ( - callback: (entry: MigrationUnsupportedPtyEntry) => void - ): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, entry: MigrationUnsupportedPtyEntry) => - callback(entry) - ipcRenderer.on('agentStatus:migrationUnsupported', listener) - return () => ipcRenderer.removeListener('agentStatus:migrationUnsupported', listener) - }, - onMigrationUnsupportedClear: (callback: (data: { ptyId: string }) => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, data: { ptyId: string }) => - callback(data) - ipcRenderer.on('agentStatus:migrationUnsupportedClear', listener) - return () => ipcRenderer.removeListener('agentStatus:migrationUnsupportedClear', listener) - }, - onLegacyWorkerTerminalRecovery: ( - callback: (data: { - paneKey: string - resolution: 'adopted' | 'exited' | 'rolled_back' - ptyId?: string - }) => void - ): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - data: { - paneKey: string - resolution: 'adopted' | 'exited' | 'rolled_back' - ptyId?: string - } - ) => callback(data) - ipcRenderer.on('agentStatus:legacyWorkerTerminalRecovery', listener) - return () => ipcRenderer.removeListener('agentStatus:legacyWorkerTerminalRecovery', listener) - }, - getMigrationUnsupportedSnapshot: (): Promise => - ipcRenderer.invoke('agentStatus:getMigrationUnsupportedSnapshot'), - /** Drop the cached hook status for a paneKey on both sides (memory + on-disk) so a relaunch can't resurrect a dismissed row. */ - drop: (paneKey: string): void => { - ipcRenderer.send('agentStatus:drop', paneKey) - }, - reconcileEndedProcess: (paneKey: string): void => { - ipcRenderer.send('agentStatus:reconcileEndedProcess', paneKey) - }, - /** Drop all cached hook statuses under one terminal tab prefix; fired on explicit tab close even without a local row. */ - dropByTabPrefix: (tabId: string): void => { - ipcRenderer.send('agentStatus:dropByTabPrefix', tabId) - }, - retirePaneAuthority: (paneKey: string): void => { - ipcRenderer.send('agentStatus:retirePaneAuthority', paneKey) - }, - restorePaneAuthority: (paneKey: string): void => { - ipcRenderer.send('agentStatus:restorePaneAuthority', paneKey) - }, - transferPaneAuthority: (args: { - fromPaneKey: string - toPaneKey: string - ptyId?: string - }): void => { - ipcRenderer.send('agentStatus:transferPaneAuthority', args) - } - }, - - speech: { - getCatalog: (): Promise => ipcRenderer.invoke('speech:getCatalog'), - getModelStates: (): Promise => ipcRenderer.invoke('speech:getModelStates'), - getOpenAiApiKeyStatus: (): Promise<{ configured: boolean }> => - ipcRenderer.invoke('speech:getOpenAiApiKeyStatus'), - saveOpenAiApiKey: (apiKey: string): Promise<{ configured: boolean }> => - ipcRenderer.invoke('speech:saveOpenAiApiKey', apiKey), - clearOpenAiApiKey: (): Promise<{ configured: boolean }> => - ipcRenderer.invoke('speech:clearOpenAiApiKey'), - downloadModel: (modelId: string): Promise => - ipcRenderer.invoke('speech:downloadModel', modelId), - cancelDownload: (modelId: string): Promise => - ipcRenderer.invoke('speech:cancelDownload', modelId), - deleteModel: (modelId: string): Promise => - ipcRenderer.invoke('speech:deleteModel', modelId), - startDictation: ( - modelId: string, - hotwords: string[] | undefined, - sessionId: string - ): Promise => ipcRenderer.invoke('speech:startDictation', modelId, hotwords, sessionId), - feedAudio: (samples: Float32Array, sampleRate: number, sessionId = 'desktop'): Promise => - // Why: Float32Array is zeroed crossing the contextBridge/IPC boundary; wrap in a Buffer to preserve bytes. - ipcRenderer.invoke( - 'speech:feedAudio', - Buffer.from(samples.buffer, samples.byteOffset, samples.byteLength), - sampleRate, - sessionId - ), - stopDictation: (sessionId = 'desktop'): Promise => - ipcRenderer.invoke('speech:stopDictation', sessionId), - - onPartialTranscript: (callback: (data: SpeechTranscriptEvent) => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, data: SpeechTranscriptEvent): void => - callback(data) - ipcRenderer.on('speech:partial', listener) - return () => ipcRenderer.removeListener('speech:partial', listener) - }, - onFinalTranscript: (callback: (data: SpeechTranscriptEvent) => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, data: SpeechTranscriptEvent): void => - callback(data) - ipcRenderer.on('speech:final', listener) - return () => ipcRenderer.removeListener('speech:final', listener) - }, - onDownloadProgress: ( - callback: (data: { modelId: string; progress: number }) => void - ): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - data: { modelId: string; progress: number } - ): void => callback(data) - ipcRenderer.on('speech:downloadProgress', listener) - return () => ipcRenderer.removeListener('speech:downloadProgress', listener) - }, - onReady: (callback: (data: SpeechLifecycleEvent) => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, data: SpeechLifecycleEvent): void => - callback(data) - ipcRenderer.on('speech:ready', listener) - return () => ipcRenderer.removeListener('speech:ready', listener) - }, - onStopped: (callback: (data: SpeechLifecycleEvent) => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, data: SpeechLifecycleEvent): void => - callback(data) - ipcRenderer.on('speech:stopped', listener) - return () => ipcRenderer.removeListener('speech:stopped', listener) - }, - onError: (callback: (data: SpeechErrorEvent) => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, data: SpeechErrorEvent): void => - callback(data) - ipcRenderer.on('speech:error', listener) - return () => ipcRenderer.removeListener('speech:error', listener) - } - } -} - -// Expose Electron APIs via contextBridge when context-isolated, otherwise attach to the DOM global. if (process.contextIsolated) { try { contextBridge.exposeInMainWorld('electron', electronAPI) @@ -5355,6 +193,5 @@ if (process.contextIsolated) { } } else { window.electron = electronAPI - // @ts-expect-error (define in dts) window.api = api } diff --git a/src/preload/preload-runtime-support.ts b/src/preload/preload-runtime-support.ts new file mode 100644 index 00000000000..b4ea3260ac8 --- /dev/null +++ b/src/preload/preload-runtime-support.ts @@ -0,0 +1,165 @@ +import { ipcRenderer, webUtils } from 'electron' +import { createBrowserClientPageRendererRequests } from './browser-client-page-renderer-requests' +import { createBrowserFindSubscriptions } from './browser-find-subscriptions' +import { registerRendererRestartIpcRelays } from './renderer-restart-wiring' +import { createUpdaterQuitAbortRelay } from '../shared/renderer-restart-preparation' +import { ORCA_UPDATER_QUIT_AND_INSTALL_ABORTED_EVENT } from '../shared/updater-renderer-events' +import { + ORCA_INTERNAL_FILE_DRAG_TYPE, + createNativeFileDropPayload, + createRejectedNativeFileDropPayload, + hasNativeFileDragTypes, + NATIVE_FILE_DROP_MAX_PATHS, + resolveNativeFileDropPath, + type NativeDropResolution, + type NativeFileDropPayload, + type NativeFileDropPathEntry +} from '../shared/native-file-drop' + +/** Joins the synchronous unload checkpoint with its durable renderer write. */ +export async function awaitBeforeUnloadCheckpoint(): Promise { + const result = (await ipcRenderer.invoke('app:await-before-unload-checkpoint')) as { + ok?: unknown + } + if (result?.ok !== true) { + throw new Error('Failed to persist renderer state before unload.') + } +} + +export const startupDiagnosticsEnabled = process.env.ORCA_STARTUP_DIAGNOSTICS === '1' + +export function getLinuxDisplayServer(): 'wayland' | 'x11' | null { + if (process.platform !== 'linux') { + return null + } + if ( + process.env.WAYLAND_DISPLAY || + process.env.XDG_SESSION_TYPE?.toLowerCase() === 'wayland' || + process.env.ELECTRON_OZONE_PLATFORM_HINT?.toLowerCase() === 'wayland' + ) { + return 'wayland' + } + return process.env.DISPLAY ? 'x11' : null +} + +type NativeFileDropCallback = (data: NativeFileDropPayload) => void +const nativeFileDropCallbacks: NativeFileDropCallback[] = [] +let nativeFileDropListenerRegistered = false + +const onNativeFileDrop = (_event: Electron.IpcRendererEvent, data: NativeFileDropPayload): void => { + for (const callback of Array.from(nativeFileDropCallbacks)) { + callback(data) + } +} + +export function subscribeNativeFileDrop(callback: NativeFileDropCallback): () => void { + nativeFileDropCallbacks.push(callback) + if (!nativeFileDropListenerRegistered) { + ipcRenderer.on('terminal:file-drop', onNativeFileDrop) + nativeFileDropListenerRegistered = true + } + return () => { + const callbackIndex = nativeFileDropCallbacks.indexOf(callback) + if (callbackIndex !== -1) { + nativeFileDropCallbacks.splice(callbackIndex, 1) + } + if (nativeFileDropCallbacks.length === 0 && nativeFileDropListenerRegistered) { + ipcRenderer.removeListener('terminal:file-drop', onNativeFileDrop) + nativeFileDropListenerRegistered = false + } + } +} + +function resolveNativeFileDrop(event: DragEvent): NativeDropResolution | null { + const pathEntries: NativeFileDropPathEntry[] = [] + for (const entry of event.composedPath()) { + if (entry instanceof HTMLElement) { + pathEntries.push({ + nativeFileDropTarget: entry.dataset.nativeFileDropTarget, + nativeFileDropDir: entry.dataset.nativeFileDropDir, + terminalTabId: entry.dataset.terminalTabId, + terminalPaneLeafId: entry.dataset.terminalPaneLeafId ?? entry.dataset.leafId + }) + } + } + return resolveNativeFileDropPath(pathEntries) +} + +/** Installs the one preload-side listener that converts native File objects to paths. */ +export function installNativeFileDropHandlers(): void { + document.addEventListener( + 'dragover', + (event) => { + if (event.dataTransfer && !hasNativeFileDragTypes(event.dataTransfer.types)) { + return + } + event.preventDefault() + if (event.dataTransfer) { + event.dataTransfer.dropEffect = 'copy' + } + }, + true + ) + document.addEventListener( + 'drop', + (event) => { + if (event.dataTransfer?.types.includes(ORCA_INTERNAL_FILE_DRAG_TYPE)) { + return + } + event.preventDefault() + event.stopPropagation() + const files = event.dataTransfer?.files + if (!files || files.length === 0) { + return + } + const resolution = resolveNativeFileDrop(event) + if (files.length > NATIVE_FILE_DROP_MAX_PATHS) { + ipcRenderer.send( + 'terminal:file-dropped-from-preload', + createRejectedNativeFileDropPayload({ + byteLength: 0, + pathCount: files.length, + reason: 'too-many-paths', + status: 'rejected' + }) + ) + return + } + const paths: string[] = [] + for (let index = 0; index < files.length; index += 1) { + const filePath = webUtils.getPathForFile(files[index]) + if (filePath) { + paths.push(filePath) + } + } + if (paths.length === 0 || resolution?.target === 'rejected') { + return + } + const payload = createNativeFileDropPayload(resolution, paths) + if (payload) { + ipcRenderer.send('terminal:file-dropped-from-preload', payload) + } + }, + true + ) +} + +export const browserFindSubscriptions = createBrowserFindSubscriptions() +export const browserClientPageRendererRequests = createBrowserClientPageRendererRequests({ + ipc: ipcRenderer, + isTopFrame: () => window.top === window +}) + +/** Registers browser find forwarding once for this preload context. */ +export function installBrowserFindListener(): void { + ipcRenderer.on('ui:findInBrowserPage', (_event, source: unknown) => { + browserFindSubscriptions.dispatch(source) + }) +} + +export const updaterQuitAbortRelay = createUpdaterQuitAbortRelay( + window, + ORCA_UPDATER_QUIT_AND_INSTALL_ABORTED_EVENT +) + +registerRendererRestartIpcRelays(ipcRenderer, window, updaterQuitAbortRelay) diff --git a/src/renderer/src/app-shell/AppWorkspaceShell.tsx b/src/renderer/src/app-shell/AppWorkspaceShell.tsx index af0df59ccd6..3ce49d84bf6 100644 --- a/src/renderer/src/app-shell/AppWorkspaceShell.tsx +++ b/src/renderer/src/app-shell/AppWorkspaceShell.tsx @@ -16,7 +16,7 @@ const Landing = lazy(() => import('../components/Landing')) const WorktreeCreationPanel = lazy( () => import('../components/worktree-creation/WorktreeCreationPanel') ) -const TaskPage = lazy(() => import('../components/TaskPage')) +const TaskPage = lazy(() => import('../components/task-page/TaskPage')) const AutomationsPage = lazy(() => import('../components/automations/AutomationsPage')) const ActivityPrototypePage = lazy(() => import('../components/activity/ActivityPrototypePage')) const Settings = lazy(() => import('../components/settings/Settings')) diff --git a/src/renderer/src/app-shell/use-app-startup-hydration.ts b/src/renderer/src/app-shell/use-app-startup-hydration.ts index 584cd9a9c9a..2b5272f440d 100644 --- a/src/renderer/src/app-shell/use-app-startup-hydration.ts +++ b/src/renderer/src/app-shell/use-app-startup-hydration.ts @@ -6,7 +6,7 @@ import { reconcileHydratedWorkspaceTabModels } from './reconcile-hydrated-worksp import { useStartupActions } from './use-app-startup-actions' import { WORKTREE_REFRESH_CONCURRENCY } from '../store/slices/worktrees' import { sweepRestoredCodexPanesForStaleAccounts } from '../lib/codex-stale-pane-sweep' -import { fetchWorkspaceSessionWithRuntimeHostOwners } from '../lib/workspace-session-host-persistence' +import { fetchWorkspaceSessionWithRuntimeHostOwners } from '../lib/workspace-session-host-hydration' import { collectFolderWorkspaceKeysFromSession, collectWorktreeHydrationRepoIdsFromSession @@ -189,7 +189,9 @@ export function useAppStartupHydration(onOnboardingLoaded: (state: OnboardingSta timeRendererStartupSyncStep('hydrate-session-stores', () => { actions.hydrateWorkspaceSession(sessionRead.session, { ...sessionHydrationOptions, - runtimeHostIdByWorkspaceSessionKey: sessionRead.runtimeHostIdByWorkspaceSessionKey + runtimeHostIdByWorkspaceSessionKey: sessionRead.runtimeHostIdByWorkspaceSessionKey, + contestedHostWorkspaceSessions: sessionRead.contestedHostWorkspaceSessions, + contestedPrimaryHostBySessionKey: sessionRead.contestedPrimaryHostBySessionKey }) actions.hydrateTabsSession(sessionRead.session, sessionHydrationOptions) actions.hydrateEditorSession(sessionRead.session, sessionHydrationOptions) diff --git a/src/renderer/src/components/TaskPage.tsx b/src/renderer/src/components/TaskPage.tsx deleted file mode 100644 index fcd84c20856..00000000000 --- a/src/renderer/src/components/TaskPage.tsx +++ /dev/null @@ -1,84 +0,0 @@ -import React from 'react' -import { useTaskPageStoreBindings } from './use-task-page-store-bindings' -import { useTaskPageRepoSelection } from './use-task-page-repo-selection' -import { useTaskPageRuntimeHosts } from './use-task-page-runtime-hosts' -import { useTaskPageSourceAvailability } from './use-task-page-source-availability' -import { useTaskPageProviderState } from './use-task-page-provider-state' -import { useTaskPageGitHubListState } from './use-task-page-github-list-state' -import { useTaskPageGitHubDetail } from './use-task-page-github-detail' -import { useTaskPageGitHubCacheReconciliation } from './use-task-page-github-cache-reconciliation' -import { useTaskPageGitHubIssueDraft } from './use-task-page-github-issue-draft' -import { useTaskPageDetailRouting } from './use-task-page-detail-routing' -import { useTaskPageLinearViewState } from './use-task-page-linear-view-state' -import { useTaskPageJiraListState } from './use-task-page-jira-list-state' -import { useTaskPageResumeRestoration } from './use-task-page-resume-restoration' -import { useTaskPageProviderMetadata } from './use-task-page-provider-metadata' -import { useTaskPageGitLabLoading } from './use-task-page-gitlab-loading' -import { useTaskPageLinearListSelection } from './use-task-page-linear-list-selection' -import { useTaskPageLinearListProjection } from './use-task-page-linear-list-projection' -import { useTaskPageLinearBoard } from './use-task-page-linear-board' -import { useTaskPageJiraListProjection } from './use-task-page-jira-list-projection' -import { useTaskPageLinearCreationState } from './use-task-page-linear-creation-state' -import { useTaskPageGitHubMutationState } from './use-task-page-github-mutation-state' -import { useTaskPageJiraCreationState } from './use-task-page-jira-creation-state' -import { useTaskPageJiraCreationMetadata } from './use-task-page-jira-creation-metadata' -import { useTaskPageGitHubListProjection } from './use-task-page-github-list-projection' -import { useTaskPageGitHubSearchPagination } from './use-task-page-github-search-pagination' -import { useTaskPageGitHubLandingRefresh } from './use-task-page-github-landing-refresh' -import { useTaskPageGitHubQuietRefresh } from './use-task-page-github-quiet-refresh' -import { useTaskPageSearchActions } from './use-task-page-search-actions' -import { useTaskPageWorkspaceActions } from './use-task-page-workspace-actions' -import { useTaskPageGitHubIssueCreation } from './use-task-page-github-issue-creation' -import { useTaskPageLinearProjectCreation } from './use-task-page-linear-project-creation' -import { useTaskPageLinearIssueCreation } from './use-task-page-linear-issue-creation' -import { useTaskPageJiraIssueCreation } from './use-task-page-jira-issue-creation' -import { useTaskPageGlobalEffects } from './use-task-page-global-effects' -import { useTaskPageLinearListEffects } from './use-task-page-linear-list-effects' -import { useTaskPageLinearInOrcaEffects } from './use-task-page-linear-in-orca-effects' -import { useTaskPageLinearCollectionEffects } from './use-task-page-linear-collection-effects' -import { useTaskPageJiraListEffects } from './use-task-page-jira-list-effects' -import { useTaskPageComposerActions } from './use-task-page-composer-actions' -import { TaskPageSurface } from './TaskPageSurface' - -export default function TaskPage(): React.JSX.Element { - const stage1 = useTaskPageStoreBindings() - const stage2 = useTaskPageRepoSelection(stage1) - const stage3 = useTaskPageRuntimeHosts(stage2) - const stage4 = useTaskPageSourceAvailability(stage3) - const stage5 = useTaskPageProviderState(stage4) - const stage6 = useTaskPageGitHubListState(stage5) - const stage7 = useTaskPageGitHubDetail(stage6) - const stage8 = useTaskPageGitHubCacheReconciliation(stage7) - const stage9 = useTaskPageGitHubIssueDraft(stage8) - const stage10 = useTaskPageDetailRouting(stage9) - const stage11 = useTaskPageLinearViewState(stage10) - const stage12 = useTaskPageJiraListState(stage11) - const stage13 = useTaskPageResumeRestoration(stage12) - const stage14 = useTaskPageProviderMetadata(stage13) - const stage15 = useTaskPageGitLabLoading(stage14) - const stage16 = useTaskPageLinearListSelection(stage15) - const stage17 = useTaskPageLinearListProjection(stage16) - const stage18 = useTaskPageLinearBoard(stage17) - const stage19 = useTaskPageJiraListProjection(stage18) - const stage20 = useTaskPageLinearCreationState(stage19) - const stage21 = useTaskPageGitHubMutationState(stage20) - const stage22 = useTaskPageJiraCreationState(stage21) - const stage23 = useTaskPageJiraCreationMetadata(stage22) - const stage24 = useTaskPageGitHubListProjection(stage23) - const stage25 = useTaskPageGitHubSearchPagination(stage24) - const stage26 = useTaskPageGitHubLandingRefresh(stage25) - const stage27 = useTaskPageGitHubQuietRefresh(stage26) - const stage28 = useTaskPageSearchActions(stage27) - const stage29 = useTaskPageWorkspaceActions(stage28) - const stage30 = useTaskPageGitHubIssueCreation(stage29) - const stage31 = useTaskPageLinearProjectCreation(stage30) - const stage32 = useTaskPageLinearIssueCreation(stage31) - const stage33 = useTaskPageJiraIssueCreation(stage32) - const stage34 = useTaskPageGlobalEffects(stage33) - const stage35 = useTaskPageLinearListEffects(stage34) - const stage36 = useTaskPageLinearInOrcaEffects(stage35) - const stage37 = useTaskPageLinearCollectionEffects(stage36) - const stage38 = useTaskPageJiraListEffects(stage37) - const stage39 = useTaskPageComposerActions(stage38) - return -} diff --git a/src/renderer/src/components/TaskPageGitHubAvatars.tsx b/src/renderer/src/components/TaskPageGitHubAvatars.tsx deleted file mode 100644 index 14b8bacaee8..00000000000 --- a/src/renderer/src/components/TaskPageGitHubAvatars.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import type { GitHubPRPrimaryReviewer } from '@/components/github-pr-reviewer-display' -import React from 'react' -import { GitHubUserAvatar } from '@/components/github/github-user-avatar' -import { Users } from 'lucide-react' -import type { GitHubAssignableUser } from '../../../shared/github/pull-request-types' -export function ReviewChipAvatar({ - reviewer, - avatarHost -}: { - reviewer: GitHubPRPrimaryReviewer | null - avatarHost?: string -}): React.JSX.Element { - if (reviewer?.login) { - // Why: review requests may contain only logins; use the PR host before falling back to initials. - const avatarUrl = - reviewer.avatarUrl || `https://${avatarHost ?? 'github.com'}/${reviewer.login}.png?size=40` - return ( - - ) - } - return -} -export function GitHubAssigneeAvatar({ - assignee -}: { - assignee: GitHubAssignableUser -}): React.JSX.Element { - if (assignee.avatarUrl) { - return ( - {assignee.login} - ) - } - return ( - - {assignee.login.slice(0, 1).toUpperCase()} - - ) -} diff --git a/src/renderer/src/components/TerminalSplitWorkspaceSurfaces.tsx b/src/renderer/src/components/TerminalSplitWorkspaceSurfaces.tsx index b6842fd95fe..5c3145360f6 100644 --- a/src/renderer/src/components/TerminalSplitWorkspaceSurfaces.tsx +++ b/src/renderer/src/components/TerminalSplitWorkspaceSurfaces.tsx @@ -1,3 +1,4 @@ +import { useAnyBrowserGuestNeedsPaint } from './browser-pane/host-guest/browser-guest-paint-retention' import { WorktreeSplitSurface } from './TerminalWorktreeSplitSurface' import type { TerminalController } from './use-terminal-controller' @@ -22,12 +23,22 @@ export function TerminalSplitWorkspaceSurfaces({ renderedActiveWorktreeId, workspaceSurfaces } = controller + // Why: this and TerminalSurface are both strict ancestors of every browser , so a + // remote controller needs each to drop `hidden` — the per-worktree surface hatch below cannot + // override an ancestor that stopped compositing. + const retainBrowserGuestPaint = useAnyBrowserGuestNeedsPaint(!effectiveActiveLayout) if (!anyMountedWorktreeHasLayout) { return null } return (
{workspaceSurfaces .filter((workspace) => mountedWorktreeIdsRef.current.has(workspace.id)) diff --git a/src/renderer/src/components/TerminalSurface.tsx b/src/renderer/src/components/TerminalSurface.tsx index 66553037b42..023f19f7311 100644 --- a/src/renderer/src/components/TerminalSurface.tsx +++ b/src/renderer/src/components/TerminalSurface.tsx @@ -1,4 +1,5 @@ import EditorAutosaveController from './editor/EditorAutosaveController' +import { useAnyBrowserGuestNeedsPaint } from './browser-pane/host-guest/browser-guest-paint-retention' import { TerminalTitlebarTabs } from './TerminalTitlebarTabs' import { TerminalSplitWorkspaceSurfaces } from './TerminalSplitWorkspaceSurfaces' import { TerminalLegacyWorkspaceSurface } from './TerminalLegacyWorkspaceSurface' @@ -11,10 +12,17 @@ export function TerminalSurface({ controller: TerminalController }): React.JSX.Element { const { renderedActiveWorktreeId } = controller + const retainBrowserGuestPaint = useAnyBrowserGuestNeedsPaint(!renderedActiveWorktreeId) return (
diff --git a/src/renderer/src/components/TerminalTitlebarTabs.test.tsx b/src/renderer/src/components/TerminalTitlebarTabs.test.tsx new file mode 100644 index 00000000000..e4f4162c97a --- /dev/null +++ b/src/renderer/src/components/TerminalTitlebarTabs.test.tsx @@ -0,0 +1,111 @@ +// @vitest-environment happy-dom + +import { act } from 'react' +import { createRoot } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { ClientHostedBrowserRow } from '../../../shared/client-hosted-browser-rows' +import { + applyClientHostedBrowserRows, + getClientHostedBrowserRows +} from '@/lib/pane-manager/client-hosted-browser-row-state' +import { TerminalTitlebarTabs } from './TerminalTitlebarTabs' +import type { TerminalController } from './use-terminal-controller' + +const mocks = vi.hoisted(() => ({ + state: {} as Record, + tabBarProps: [] as Record[] +})) + +vi.mock('../store', () => ({ + useAppStore: Object.assign((selector: (state: unknown) => unknown) => selector(mocks.state), { + getState: () => mocks.state + }) +})) +vi.mock('./tab-bar/TabBar', () => ({ + default: (props: Record) => { + mocks.tabBarProps.push(props) + return null + } +})) + +const WORKTREE_ID = 'repo-1::/repo/worktree' +const ROW: ClientHostedBrowserRow = { + browserPageId: 'page-1', + title: 'Client page', + url: 'https://example.com' +} as ClientHostedBrowserRow + +let titlebarTarget: HTMLElement +let container: HTMLElement + +function renderTitlebarTabs(): void { + const controller = { + activeBrowserTabId: null, + activeFileId: null, + activeTabId: null, + activeTabType: 'terminal', + effectiveActiveLayout: null, + expandedPaneByTabId: {}, + handleActivateBrowserTab: vi.fn(), + handleActivateTab: vi.fn(), + handleCloseAllFiles: vi.fn(), + handleCloseBrowserTab: vi.fn(), + handleCloseFile: vi.fn(), + handleCloseOthers: vi.fn(), + handleCloseTab: vi.fn(), + handleCloseTabsToLeft: vi.fn(), + handleCloseTabsToRight: vi.fn(), + handleDuplicateBrowserTab: vi.fn(), + handleNewBrowserTab: vi.fn(), + handleNewFile: vi.fn(), + handleNewSimulatorTab: vi.fn(), + handleNewTab: vi.fn(), + handleOpenEntry: vi.fn(), + handleTogglePaneExpand: vi.fn(), + makePreviewFilePermanent: vi.fn(), + mobileEmulatorEnabled: false, + pinFile: vi.fn(), + renderedActiveWorktreeId: WORKTREE_ID, + setActiveFile: vi.fn(), + setActiveTab: vi.fn(), + setActiveTabType: vi.fn(), + setTabColor: vi.fn(), + setTabCustomTitle: vi.fn(), + tabBarOrder: [], + titlebarTabsTarget: titlebarTarget, + worktreeBrowserTabs: [], + // Mirrors the projection hook's derivation so the assertion follows the real row store. + worktreeClientHostedBrowserRows: getClientHostedBrowserRows(WORKTREE_ID), + worktreeFiles: [] + } as unknown as TerminalController + const root = createRoot(container) + act(() => root.render()) + act(() => root.unmount()) +} + +describe('TerminalTitlebarTabs', () => { + beforeEach(() => { + mocks.tabBarProps = [] + mocks.state = { tabsByWorktree: {}, unifiedTabsByWorktree: {}, getActiveTab: () => null } + titlebarTarget = document.createElement('div') + container = document.createElement('div') + document.body.append(titlebarTarget, container) + }) + + afterEach(() => { + applyClientHostedBrowserRows({ worktreeId: WORKTREE_ID, rows: [] }) + titlebarTarget.remove() + container.remove() + }) + + it('forwards client-hosted browser rows to the titlebar tab bar', () => { + applyClientHostedBrowserRows({ worktreeId: WORKTREE_ID, rows: [ROW] }) + renderTitlebarTabs() + expect(mocks.tabBarProps.at(-1)?.clientHostedBrowserRows).toEqual([ROW]) + }) + + it('passes no rows when the worktree has none', () => { + renderTitlebarTabs() + expect(mocks.tabBarProps.at(-1)?.clientHostedBrowserRows).toEqual([]) + }) +}) diff --git a/src/renderer/src/components/TerminalTitlebarTabs.tsx b/src/renderer/src/components/TerminalTitlebarTabs.tsx index a275da9d801..c2a5d8218e4 100644 --- a/src/renderer/src/components/TerminalTitlebarTabs.tsx +++ b/src/renderer/src/components/TerminalTitlebarTabs.tsx @@ -1,8 +1,20 @@ import { createPortal } from 'react-dom' +import type { TerminalTab } from '../../../shared/terminal-tab-types' import { useAppStore } from '../store' import TabBar from './tab-bar/TabBar' import type { TerminalController } from './use-terminal-controller' +const EMPTY_TERMINAL_TABS: TerminalTab[] = [] + +// Why: keeps title-only tab updates a leaf subscription so the Terminal root, +// which reads the topology projection, does not re-render on every rename. +function LiveTerminalTabBar( + props: Omit, 'tabs'> +): React.JSX.Element { + const tabs = useAppStore((state) => state.tabsByWorktree[props.worktreeId] ?? EMPTY_TERMINAL_TABS) + return +} + export function TerminalTitlebarTabs({ controller }: { @@ -41,17 +53,16 @@ export function TerminalTitlebarTabs({ setTabColor, setTabCustomTitle, tabBarOrder, - tabs, titlebarTabsTarget, worktreeBrowserTabs, + worktreeClientHostedBrowserRows, worktreeFiles } = controller if (!renderedActiveWorktreeId || effectiveActiveLayout || !titlebarTabsTarget) { return null } return createPortal( - { }) }) - it('publishes content at the 5 MiB boundary', async () => { + it('publishes content at the 10 MiB boundary', async () => { mocks.callRuntimeRpc.mockResolvedValue({ status: 'ok', value: published }) const createRequest = vi.fn().mockResolvedValue({ ...request, diff --git a/src/renderer/src/components/browser-pane/assemble-chrome/browser-address-bar-suggestions.test.ts b/src/renderer/src/components/browser-pane/assemble-chrome/browser-address-bar-suggestions.test.ts index 597bc7b990f..40d8314a7a2 100644 --- a/src/renderer/src/components/browser-pane/assemble-chrome/browser-address-bar-suggestions.test.ts +++ b/src/renderer/src/components/browser-pane/assemble-chrome/browser-address-bar-suggestions.test.ts @@ -1,9 +1,10 @@ -import { describe, expect, it } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { BrowserHistoryEntry } from '../../../../../shared/browser-workspace-types' import { BROWSER_ADDRESS_BAR_QUERY_MAX_BYTES, buildBrowserAddressBarSuggestions, - isBrowserAddressBarQueryTooLarge + isBrowserAddressBarQueryTooLarge, + MAX_BROWSER_ADDRESS_BAR_SUGGESTIONS } from './browser-address-bar-suggestions' function historyEntry(overrides: Partial): BrowserHistoryEntry { @@ -121,6 +122,172 @@ describe('browser address bar suggestions', () => { }) ).toEqual([]) }) + + describe('history scoring', () => { + const now = 1_700_000_000_000 + const hoursAgo = (hours: number): number => now - hours * 60 * 60 * 1000 + // The synthetic top row is composition, not scoring; ranking asserts on history only. + const historyUrls = (suggestions: readonly { isSearch: boolean; url: string }[]): string[] => + suggestions.filter((suggestion) => !suggestion.isSearch).map((suggestion) => suggestion.url) + + beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(now) + }) + afterEach(() => { + vi.useRealTimers() + }) + + it('ranks a host-prefix match above a host-substring match above a title match', () => { + const suggestions = buildBrowserAddressBarSuggestions({ + value: 'git', + browserUrlHistory: [ + historyEntry({ + url: 'https://example.com/setup', + normalizedUrl: 'https://example.com/setup', + title: 'Configure git hooks', + lastVisitedAt: now, + visitCount: 50 + }), + historyEntry({ + url: 'https://docs.github.com/actions', + normalizedUrl: 'https://docs.github.com/actions', + title: 'Actions', + lastVisitedAt: hoursAgo(20), + visitCount: 1 + }), + historyEntry({ + url: 'https://github.com/acme/orca', + normalizedUrl: 'https://github.com/acme/orca', + title: 'acme/orca', + lastVisitedAt: hoursAgo(20), + visitCount: 1 + }) + ] + }) + + expect(historyUrls(suggestions)).toEqual([ + 'https://github.com/acme/orca', + 'https://docs.github.com/actions', + 'https://example.com/setup' + ]) + }) + + it('sorts a path-only match last even when it is frequent and fresh', () => { + const suggestions = buildBrowserAddressBarSuggestions({ + value: 'git', + browserUrlHistory: [ + historyEntry({ + url: 'https://example.com/?ref=git', + normalizedUrl: 'https://example.com/?ref=git', + title: 'Example', + lastVisitedAt: now, + visitCount: 500 + }), + historyEntry({ + url: 'https://example.org/notes', + normalizedUrl: 'https://example.org/notes', + title: 'Stale git notes', + lastVisitedAt: hoursAgo(400), + visitCount: 1 + }) + ] + }) + + expect(historyUrls(suggestions)).toEqual([ + 'https://example.org/notes', + 'https://example.com/?ref=git' + ]) + }) + + it('clamps the visit-count bonus at 50 so recency still breaks the tie', () => { + const suggestions = buildBrowserAddressBarSuggestions({ + value: 'acme', + browserUrlHistory: [ + historyEntry({ + url: 'https://acme.dev/a', + normalizedUrl: 'https://acme.dev/a', + title: 'A', + lastVisitedAt: hoursAgo(23), + visitCount: 50 + }), + historyEntry({ + url: 'https://acme.dev/b', + normalizedUrl: 'https://acme.dev/b', + title: 'B', + lastVisitedAt: hoursAgo(1), + visitCount: 5000 + }) + ] + }) + + expect(historyUrls(suggestions)).toEqual(['https://acme.dev/b', 'https://acme.dev/a']) + }) + + it('floors the recency bonus at zero for entries older than a day', () => { + const suggestions = buildBrowserAddressBarSuggestions({ + value: 'acme', + browserUrlHistory: [ + historyEntry({ + url: 'https://acme.dev/ancient', + normalizedUrl: 'https://acme.dev/ancient', + title: 'Ancient', + lastVisitedAt: hoursAgo(10_000), + visitCount: 3 + }), + historyEntry({ + url: 'https://acme.dev/old', + normalizedUrl: 'https://acme.dev/old', + title: 'Old', + lastVisitedAt: hoursAgo(100), + visitCount: 2 + }) + ] + }) + + expect(historyUrls(suggestions)).toEqual(['https://acme.dev/ancient', 'https://acme.dev/old']) + }) + + it('leaves one slot for the synthetic top row when history overflows', () => { + const suggestions = buildBrowserAddressBarSuggestions({ + value: 'acme', + browserUrlHistory: Array.from({ length: 20 }, (_, index) => + historyEntry({ + url: `https://acme.dev/page-${index}`, + normalizedUrl: `https://acme.dev/page-${index}`, + title: `Page ${index}`, + lastVisitedAt: hoursAgo(index), + visitCount: 1 + }) + ) + }) + + expect(suggestions).toHaveLength(MAX_BROWSER_ADDRESS_BAR_SUGGESTIONS) + expect(suggestions[0]).toMatchObject({ isSearch: true }) + expect(suggestions.slice(1).every((suggestion) => suggestion.url.includes('/page-'))).toBe( + true + ) + }) + + it('folds the synthetic row away when history already offers the same url', () => { + const suggestions = buildBrowserAddressBarSuggestions({ + value: 'acme.dev', + browserUrlHistory: [ + historyEntry({ + url: 'https://acme.dev/', + normalizedUrl: 'https://acme.dev', + title: 'Acme', + lastVisitedAt: now, + visitCount: 4 + }) + ] + }) + + expect(suggestions).toEqual([ + expect.objectContaining({ url: 'https://acme.dev/', title: 'Acme', isSearch: false }) + ]) + }) + }) }) describe('workspace document suggestions', () => { @@ -171,4 +338,22 @@ describe('workspace document suggestions', () => { }) expect(byPath.some((row) => row.docLocation)).toBe(true) }) + + it('ranks a path-prefix document above a heavily visited url-tail history match', () => { + const rows = buildBrowserAddressBarSuggestions({ + value: '/repo/docs', + browserUrlHistory: [ + { + url: 'https://example.com/repo/docs/index.html', + normalizedUrl: 'https://example.com/repo/docs/index.html', + title: 'Docs Index', + lastVisitedAt: Date.now(), + visitCount: 500 + } + ], + workspaceDocHistory: [DOC_ENTRY] + }) + + expect(rows.find((row) => !row.isSearch)?.docLocation).toEqual(DOC_ENTRY.docLocation) + }) }) diff --git a/src/renderer/src/components/browser-pane/assemble-chrome/browser-address-bar-suggestions.ts b/src/renderer/src/components/browser-pane/assemble-chrome/browser-address-bar-suggestions.ts index a3122ceb8c6..898b6189407 100644 --- a/src/renderer/src/components/browser-pane/assemble-chrome/browser-address-bar-suggestions.ts +++ b/src/renderer/src/components/browser-pane/assemble-chrome/browser-address-bar-suggestions.ts @@ -11,6 +11,7 @@ import type { BrowserPageDocLocation } from '../../../../../shared/browser-workspace-types' import type { WorkspaceDocHistoryEntry } from '../../../../../shared/workspace-doc-history' +import { matchBrowserHistory, prepareBrowserHistoryEntries } from '@/lib/browser-history-match' import { isClipboardTextByteLengthOverLimit } from '../../../../../shared/clipboard-text' import { translate } from '@/i18n/i18n' @@ -49,28 +50,6 @@ export function isBrowserAddressBarQueryTooLarge( return isClipboardTextByteLengthOverLimit(query, maxBytes) } -function scoreBrowserAddressBarSuggestion( - entry: { url: string; title: string; lastVisitedAt: number; visitCount: number }, - query: string -): number { - const lowerQuery = query.toLowerCase() - const lowerUrl = entry.url.toLowerCase() - const lowerTitle = entry.title.toLowerCase() - - if (!lowerUrl.includes(lowerQuery) && !lowerTitle.includes(lowerQuery)) { - return -1 - } - - let score = 0 - if (lowerUrl.startsWith(lowerQuery) || lowerUrl.startsWith(`https://${lowerQuery}`)) { - score += 100 - } - score += Math.min(entry.visitCount, 50) - const ageHours = (Date.now() - entry.lastVisitedAt) / (1000 * 60 * 60) - score += Math.max(0, 24 - ageHours) - return score -} - export function buildBrowserAddressBarSuggestions({ browserUrlHistory, workspaceDocHistory = [], @@ -97,22 +76,35 @@ export function buildBrowserAddressBarSuggestions({ .sort((a, b) => b.lastVisitedAt - a.lastVisitedAt) .slice(0, MAX_BROWSER_ADDRESS_BAR_SUGGESTIONS) } - const scoredRows: { row: BrowserAddressBarSuggestion; score: number }[] = [ - ...browserUrlHistory.map((entry) => ({ - row: { ...entry, subtitle: entry.url, isSearch: false }, - score: scoreBrowserAddressBarSuggestion(entry, trimmed) - })), - // The scorer only reads url/title/recency/visits, and a doc row's url is its path. - ...workspaceDocHistory.map(toWorkspaceDocSuggestion).map((row) => ({ - row, - score: scoreBrowserAddressBarSuggestion(row, trimmed) - })) - ] - const historySuggestions: BrowserAddressBarSuggestion[] = scoredRows - .filter((item) => item.score >= 0) - .sort((a, b) => b.score - a.score) - .slice(0, MAX_BROWSER_ADDRESS_BAR_SUGGESTIONS - 1) - .map((item) => item.row) + const documentRows = workspaceDocHistory.map(toWorkspaceDocSuggestion) + const documentEntries: BrowserHistoryEntry[] = documentRows.map((row) => ({ + url: row.url, + normalizedUrl: row.url, + title: row.title, + lastVisitedAt: row.lastVisitedAt, + visitCount: row.visitCount + })) + const rowByEntry = new Map() + for (const entry of browserUrlHistory) { + rowByEntry.set(entry, { ...entry, subtitle: entry.url, isSearch: false }) + } + documentEntries.forEach((entry, index) => rowByEntry.set(entry, documentRows[index])) + // Why prepare the caller's array as-is: it is the stable `browserUrlHistory` + // identity, so the prepare cache hits instead of re-lowercasing every keystroke. + const preparedHistory = prepareBrowserHistoryEntries(browserUrlHistory) + const prepared = + documentEntries.length === 0 + ? preparedHistory + : [...preparedHistory, ...prepareBrowserHistoryEntries(documentEntries)] + // Why url-tail is kept here: the address bar is a navigation surface, so a + // path-only recall is still a destination — it just never outranks a real one. + const historySuggestions: BrowserAddressBarSuggestion[] = matchBrowserHistory({ + prepared, + query: trimmed, + limit: MAX_BROWSER_ADDRESS_BAR_SUGGESTIONS - 1 + }) + .map(({ entry }) => rowByEntry.get(entry)) + .filter((row): row is BrowserAddressBarSuggestion => row !== undefined) const isQuery = looksLikeSearchQuery(trimmed) let topAction: BrowserAddressBarSuggestion | null diff --git a/src/renderer/src/components/browser-pane/describe-page/browser-artifact-upload.test.ts b/src/renderer/src/components/browser-pane/describe-page/browser-artifact-upload.test.ts index a5708a04a4a..5490e8de27a 100644 --- a/src/renderer/src/components/browser-pane/describe-page/browser-artifact-upload.test.ts +++ b/src/renderer/src/components/browser-pane/describe-page/browser-artifact-upload.test.ts @@ -64,7 +64,7 @@ describe('browser artifact upload', () => { } satisfies Partial) }) - it('accepts a file whose stat is exactly at the 5 MiB boundary', async () => { + it('accepts a file whose stat is exactly at the 10 MiB boundary', async () => { stat.mockResolvedValueOnce({ size: ARTIFACT_MAX_CONTENT_BYTES, isDirectory: false, diff --git a/src/renderer/src/components/browser-pane/host-guest/browser-guest-retention-site-census.test.ts b/src/renderer/src/components/browser-pane/host-guest/browser-guest-retention-site-census.test.ts index c273be18e68..67063ff8805 100644 --- a/src/renderer/src/components/browser-pane/host-guest/browser-guest-retention-site-census.test.ts +++ b/src/renderer/src/components/browser-pane/host-guest/browser-guest-retention-site-census.test.ts @@ -56,6 +56,10 @@ const RETENTION_HELPER_SYMBOLS = [ // Every place that decides whether a browser guest keeps painting, and the helper it must use. const RETENTION_SITES = new Map([ ['components/TerminalWorkbenchContainer.tsx', ['useAnyBrowserGuestNeedsPaint']], + // The two outermost workbench wrappers: strict ancestors of every guest, so the per-worktree + // surface hatch below cannot rescue a guest either one parked with `hidden`. + ['components/TerminalSurface.tsx', ['useAnyBrowserGuestNeedsPaint']], + ['components/TerminalSplitWorkspaceSurfaces.tsx', ['useAnyBrowserGuestNeedsPaint']], ['components/TerminalWorktreeSplitSurface.tsx', ['useBrowserGuestPaintRetention']], [ 'components/browser-pane/assemble-chrome/BrowserPaneOverlayLayer.tsx', diff --git a/src/renderer/src/components/editor/ReviewNotesSendMenuContent.tsx b/src/renderer/src/components/editor/ReviewNotesSendMenuContent.tsx index 5fbb92c7636..e0b1d70d5a2 100644 --- a/src/renderer/src/components/editor/ReviewNotesSendMenuContent.tsx +++ b/src/renderer/src/components/editor/ReviewNotesSendMenuContent.tsx @@ -124,17 +124,18 @@ export function ReviewNotesSendMenuContent({ toast.message( activeAgentNotesSendFailureMessage(result.status, { - explicitTarget: options.explicitTarget + explicitTarget: options.explicitTarget, + code: result.code }) ) }) - .catch((error) => { - console.error('Failed to send notes:', error) + .catch(() => { + console.error('Failed to send notes:', { code: 'runtime-unverifiable' }) toast.error( - translate( - 'auto.components.editor.ReviewNotesSendMenuContent.f5096c6e4e', - 'Could not send notes.' - ) + activeAgentNotesSendFailureMessage('status-unavailable', { + explicitTarget: options.explicitTarget, + code: 'runtime-unverifiable' + }) ) }) .finally(() => { diff --git a/src/renderer/src/components/feature-interaction-writer-boundaries.test.ts b/src/renderer/src/components/feature-interaction-writer-boundaries.test.ts index 9842a75f7d7..4a02840098e 100644 --- a/src/renderer/src/components/feature-interaction-writer-boundaries.test.ts +++ b/src/renderer/src/components/feature-interaction-writer-boundaries.test.ts @@ -75,7 +75,7 @@ describe('feature interaction writer boundaries', () => { ).toContain(githubWriter) expect( sourceBetween( - componentSource('task-page/hooks/use-task-page-use-item-actions.ts'), + componentSource('use-task-page-workspace-actions.ts'), 'const handleOpenOrUseGitHubWorkItem', 'const openComposerForGitLabItem' ) @@ -84,25 +84,25 @@ describe('feature interaction writer boundaries', () => { it('threads GitHub task source context through inline task mutations', () => { const sections = [ - componentSource('task-page/github/github-status-cell.tsx'), - componentSource('task-page/github/github-assignees-cell.tsx'), - componentSource('task-page/github/pr-review-cell.tsx'), - componentSource('task-page/github/pr-merge-cell.tsx'), + componentSource('task-page/github/StatusCell.tsx'), + componentSource('task-page/github/AssigneesCell.tsx'), + componentSource('task-page/github/ReviewCell.tsx'), + componentSource('task-page/github/MergeCell.tsx'), sourceBetween( - componentSource('task-page/hooks/use-task-page-create-github-submit.ts'), + componentSource('use-task-page-github-issue-creation.ts'), 'const handleCreateNewIssue', - 'return { handleCreateNewIssue }' + 'const nextModel' ) ] for (const section of sections) { expect(section).toContain('sourceContext') } - const rowSource = componentSource('task-page/github/github-work-item-row.tsx') - expect(rowSource).toContain( - "const rowSourceContext = getTaskPageRepoSourceContext(itemRepo, 'github')" - ) - expect(rowSource).toContain('sourceContext={rowSourceContext}') + const rowSource = componentSource('task-page/github/Rows.tsx') + // Rows inline the repo lookup per cell rather than hoisting one const. + expect( + rowSource.match(/sourceContext=\{getTaskPageRepoSourceContext\(itemRepo, 'github'\)\}/g) + ).toHaveLength(4) }) it('suppresses Tasks surface telemetry for in-page provider switches and detail opens', () => { @@ -120,7 +120,7 @@ describe('feature interaction writer boundaries', () => { 'const openRelatedLinearIssue' ), sourceBetween( - componentSource('task-page/chrome/task-page-source-toolbar.tsx'), + componentSource('task-page/SourceBar.tsx'), 'taskSourceManuallyChangedRef.current = true', 'void updateSettings' ) @@ -165,16 +165,16 @@ describe('feature interaction writer boundaries', () => { expect( sourceBetween( - componentSource('task-page/gitlab/gitlab-work-item-list.tsx'), + componentSource('task-page/gitlab/ItemList.tsx'), '{displayedGitLabItems.map((item) => (', 'handleUseGitLabItem(item)' ).match(/recordFeatureInteraction\('gitlab-tasks'\)/g) ).toHaveLength(2) expect( sourceBetween( - componentSource('task-page/hooks/use-task-page-use-item-actions.ts'), + componentSource('use-task-page-workspace-actions.ts'), 'const handleUseGitLabItem', - 'return {' + 'const nextModel' ) ).toContain(gitlabWriter) @@ -200,18 +200,18 @@ describe('feature interaction writer boundaries', () => { it('keeps nested GitLab row actions from also opening task details by keyboard', () => { const rowSection = sourceBetween( - componentSource('task-page/gitlab/gitlab-work-item-list.tsx'), - 'onKeyDown={(event) => {', + componentSource('task-page/gitlab/ItemList.tsx'), + 'onKeyDown={(e) => {', 'className="grid w-full cursor-pointer' ) - expect(rowSection).toContain('event.target !== event.currentTarget') - expect(rowSection.indexOf('event.target !== event.currentTarget')).toBeLessThan( - rowSection.indexOf("event.key === 'Enter'") + expect(rowSection).toContain('e.target !== e.currentTarget') + expect(rowSection.indexOf('e.target !== e.currentTarget')).toBeLessThan( + rowSection.indexOf("e.key === 'Enter'") ) }) it('keys GitLab rows by repository and item identity across hosts', () => { - expect(componentSource('task-page/gitlab/gitlab-work-item-list.tsx')).toContain( + expect(componentSource('task-page/gitlab/ItemList.tsx')).toContain( 'key={`${item.repoId}:${item.id}`}' ) }) @@ -225,22 +225,22 @@ describe('feature interaction writer boundaries', () => { const taskPageSections = [ sourceBetween( - componentSource('task-page/linear/linear-state-cell.tsx'), + componentSource('task-page-linear-issue-model.tsx'), 'export function LinearStateCell', 'return (' ), sourceBetween( - componentSource('task-page/hooks/use-task-page-linear-board.tsx'), + componentSource('use-task-page-linear-board.ts'), 'const handleLinearBoardDrop', 'const toggleLinearDisplayProperty' ), sourceBetween( - componentSource('task-page/hooks/use-task-page-create-linear-submits.tsx'), + componentSource('use-task-page-linear-issue-creation.ts'), 'const handleCreateNewLinearIssue', - 'return {' + 'const nextModel' ), sourceBetween( - componentSource('task-page/hooks/use-task-page-linear-actions.ts'), + componentSource('use-task-page-composer-actions.ts'), 'const handleUseLinearItem', 'const handleLinearWorkspaceChange' ) @@ -270,9 +270,9 @@ describe('feature interaction writer boundaries', () => { // marker) no longer exists in TaskPage. expect( sourceBetween( - componentSource('task-page/hooks/use-task-page-jira-actions.ts'), + componentSource('use-task-page-composer-actions.ts'), 'const handleUseJiraItem', - 'return {' + 'const nextModel' ) ).toContain(jiraWriter) }) diff --git a/src/renderer/src/components/floating-terminal/use-floating-terminal-create-actions.ts b/src/renderer/src/components/floating-terminal/use-floating-terminal-create-actions.ts index 781948111c1..8ef85f7e556 100644 --- a/src/renderer/src/components/floating-terminal/use-floating-terminal-create-actions.ts +++ b/src/renderer/src/components/floating-terminal/use-floating-terminal-create-actions.ts @@ -3,7 +3,8 @@ import { toast } from 'sonner' import { resolveGroupTabFromVisibleId } from '@/components/tab-group/tab-group-visible-id' import { getConnectionId } from '@/lib/connection-context' import { createUntitledMarkdownFileWithTemplateSelection } from '@/lib/create-untitled-markdown' -import { detectLanguage } from '@/lib/language-detect' +import { ensureClientCreationActionAllowed } from '@/lib/client-creation-action-error' +import { openMarkdownDocumentInFloatingWorkspace } from '@/lib/open-markdown-in-floating-workspace' import { extractIpcErrorMessage } from '@/lib/ipc-error' import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface' import { translate } from '@/i18n/i18n' @@ -74,6 +75,9 @@ export function useFloatingTerminalCreateActions({ ) const createFloatingBrowserTab = useCallback(() => { + if (!ensureClientCreationActionAllowed(FLOATING_TERMINAL_WORKTREE_ID, 'managed-browser')) { + return + } const url = browserDefaultUrl ?? 'about:blank' createBrowserTab(FLOATING_TERMINAL_WORKTREE_ID, url, { title: translate( @@ -119,21 +123,9 @@ export function useFloatingTerminalCreateActions({ if (!document) { return } - openFile( - { - filePath: document.filePath, - relativePath: document.relativePath, - worktreeId: FLOATING_TERMINAL_WORKTREE_ID, - language: detectLanguage(document.relativePath), - mode: 'edit', - runtimeEnvironmentId: null - }, - { - preview: false, - targetGroupId: activeGroup?.id, - suppressActiveRuntimeFallback: true - } - ) + openMarkdownDocumentInFloatingWorkspace(openFile, document, { + targetGroupId: activeGroup?.id + }) } catch (error) { toast.error(extractIpcErrorMessage(error, 'Failed to open markdown file.')) } diff --git a/src/renderer/src/components/floating-terminal/use-floating-terminal-panel-shortcuts.ts b/src/renderer/src/components/floating-terminal/use-floating-terminal-panel-shortcuts.ts index 67e0bc79db3..b597810c0fd 100644 --- a/src/renderer/src/components/floating-terminal/use-floating-terminal-panel-shortcuts.ts +++ b/src/renderer/src/components/floating-terminal/use-floating-terminal-panel-shortcuts.ts @@ -1,5 +1,6 @@ import { useCallback, useEffect, useRef, type KeyboardEvent as ReactKeyboardEvent } from 'react' import { isTerminalPaneCloseChord } from '@/components/terminal-pane/terminal-shortcut-policy' +import { ensureClientCreationActionAllowed } from '@/lib/client-creation-action-error' import { matchFloatingWorkspacePanelOwnedAction, matchFloatingWorkspacePanelShortcut @@ -7,6 +8,7 @@ import { import { isFloatingWorkspaceTerminalInputTarget } from '@/lib/floating-workspace-terminal-actions' import { getShortcutPlatform } from '@/lib/shortcut-platform' import { useAppStore } from '@/store' +import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' import type { KeybindingContext, KeybindingMatchOptions } from '../../../../shared/keybindings' import type { FloatingPanelShortcutInput, @@ -123,6 +125,11 @@ export function useFloatingTerminalPanelShortcuts({ if (resolution.action === 'tab.newTerminal') { createFloatingTerminalTab() } else if (resolution.action === 'tab.newBrowser') { + if ( + !ensureClientCreationActionAllowed(FLOATING_TERMINAL_WORKTREE_ID, 'managed-browser') + ) { + return 'handled' + } createFloatingBrowserTab() } else if (resolution.action === 'tab.newMarkdown') { createFloatingMarkdownTab() diff --git a/src/renderer/src/components/github-enterprise-slug-routing-boundary.test.ts b/src/renderer/src/components/github-enterprise-slug-routing-boundary.test.ts index aa90a447780..469991fc55e 100644 --- a/src/renderer/src/components/github-enterprise-slug-routing-boundary.test.ts +++ b/src/renderer/src/components/github-enterprise-slug-routing-boundary.test.ts @@ -16,8 +16,8 @@ function sourceBetween(source: string, startPattern: string, endPattern: string) describe('GitHub Enterprise slug routing boundaries', () => { it('keeps work-item URL hosts on TaskPage metadata and issue mutations', () => { - const statusSection = componentSource('task-page/github/github-status-cell.tsx') - const assigneeSection = componentSource('task-page/github/github-assignees-cell.tsx') + const statusSection = componentSource('task-page/github/StatusCell.tsx') + const assigneeSection = componentSource('task-page/github/AssigneesCell.tsx') expect(statusSection).toContain('host: githubProjectHost(parsedOwnerRepo.host)') expect(assigneeSection).toContain('parsed?.slug.host') @@ -25,11 +25,13 @@ describe('GitHub Enterprise slug routing boundaries', () => { }) it('uses URL-host fallback for TaskPage reviewer and merge mutations', () => { - const reviewSection = componentSource('task-page/github/pr-review-cell.tsx') - const mergeSection = componentSource('task-page/github/pr-merge-cell.tsx') + const reviewSection = componentSource('task-page/github/ReviewCell.tsx') + const reviewActionsSection = componentSource('task-page-github-reviewer-actions.ts') + const mergeSection = componentSource('task-page/github/MergeCell.tsx') expect(reviewSection).toContain('resolveTaskPullRequestRepo(item)') - expect(reviewSection.match(/prRepo: reviewRepo/g)).toHaveLength(4) + expect(reviewSection).toContain('reviewRepo,') + expect(reviewActionsSection.match(/prRepo: reviewRepo/g)).toHaveLength(4) expect(mergeSection).toContain('const prRepo = resolveTaskPullRequestRepo(item)') expect(mergeSection).not.toContain('prRepo: item.prRepo ?? null') }) diff --git a/src/renderer/src/components/github-project/group-sort.test.ts b/src/renderer/src/components/github-project/group-sort.test.ts index cd5e77738bc..57b96267bae 100644 --- a/src/renderer/src/components/github-project/group-sort.test.ts +++ b/src/renderer/src/components/github-project/group-sort.test.ts @@ -33,6 +33,27 @@ const iterationField: GitHubProjectField = { ] } +const assigneesField: GitHubProjectField = { + kind: 'field', + id: 'F_assignees', + name: 'Assignees', + dataType: 'ASSIGNEES' +} + +const labelsField: GitHubProjectField = { + kind: 'field', + id: 'F_labels', + name: 'Labels', + dataType: 'LABELS' +} + +const textField: GitHubProjectField = { + kind: 'field', + id: 'F_text', + name: 'Notes', + dataType: 'TEXT' +} + function makeRow( id: string, position: number, @@ -206,6 +227,66 @@ describe('sortRows', () => { expect(sorted.map((r) => r.id)).toEqual(['rHas', 'rEmpty']) }) + it('sorts an empty user list last in both directions, like a missing value', () => { + // Why: the DESC flip negated the empty branch, sending unassigned rows to the top. + const rows = [ + makeRow('empty-list', 0, { + F_assignees: { kind: 'users', fieldId: 'F_assignees', users: [] } + }), + makeRow('alice', 1, { + F_assignees: { + kind: 'users', + fieldId: 'F_assignees', + users: [{ login: 'alice', name: null, avatarUrl: null }] + } + }), + makeRow('no-value', 2, {}) + ] + + for (const direction of ['ASC', 'DESC'] as const) { + const view = makeView(assigneesField, { direction, field: assigneesField }) + const sorted = sortRows(makeTable(view, rows), rows) + expect(sorted.map((r) => r.id)).toEqual(['alice', 'empty-list', 'no-value']) + } + }) + + it('sorts an empty label list last in both directions, like a missing value', () => { + const rows = [ + makeRow('empty-list', 0, { + F_labels: { kind: 'labels', fieldId: 'F_labels', labels: [] } + }), + makeRow('bug', 1, { + F_labels: { + kind: 'labels', + fieldId: 'F_labels', + labels: [{ name: 'bug', color: 'ff0000' }] + } + }), + makeRow('no-value', 2, {}) + ] + + for (const direction of ['ASC', 'DESC'] as const) { + const view = makeView(labelsField, { direction, field: labelsField }) + const sorted = sortRows(makeTable(view, rows), rows) + expect(sorted.map((r) => r.id)).toEqual(['bug', 'empty-list', 'no-value']) + } + }) + + it('sorts a blank text value last in both directions, like a missing value', () => { + // Why reachable: the normalizer turns a null GitHub text/date into ''. + const rows = [ + makeRow('blank', 0, { F_text: { kind: 'text', fieldId: 'F_text', text: '' } }), + makeRow('alpha', 1, { F_text: { kind: 'text', fieldId: 'F_text', text: 'alpha' } }), + makeRow('no-value', 2, {}) + ] + + for (const direction of ['ASC', 'DESC'] as const) { + const view = makeView(textField, { direction, field: textField }) + const sorted = sortRows(makeTable(view, rows), rows) + expect(sorted.map((r) => r.id)).toEqual(['alpha', 'blank', 'no-value']) + } + }) + it('keeps sort fallback finite when row positions are absent', () => { const view = makeView(singleSelectField) const rows = [ @@ -220,6 +301,31 @@ describe('sortRows', () => { }) describe('groupRows', () => { + it('groups a present-but-empty user list with the missing-value rows', () => { + // Why: an empty list fell through to a blank-label group, which renders as "All". + const view = { ...makeView(assigneesField), groupByFields: [assigneesField] } + const rows = [ + makeRow('empty-list', 0, { + F_assignees: { kind: 'users', fieldId: 'F_assignees', users: [] } + }), + makeRow('alice', 1, { + F_assignees: { + kind: 'users', + fieldId: 'F_assignees', + users: [{ login: 'alice', name: null, avatarUrl: null }] + } + }), + makeRow('no-value', 2, {}) + ] + + const groups = groupRows(makeTable(view, rows), rows) + + expect(groups.map((group) => [group.label, group.rows.map((r) => r.id)])).toEqual([ + ['alice', ['alice']], + ['No Assignees', ['empty-list', 'no-value']] + ]) + }) + it('places the empty group last', () => { const view = { ...makeView(singleSelectField), 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 index c48cd53071a..8b33b3dc40c 100644 --- 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 @@ -46,10 +46,7 @@ describe('issue #8784 GHE avatar fallback (regression)', () => { // Why: author chip must not ignore API avatar_url and only pass login. expect(prPage).not.toMatch(/githubAvatarUrl\(workItem\.author\)/) - const reviewChip = readFileSync( - join(__dirname, '../task-page/github/github-assignee-avatars.tsx'), - 'utf8' - ) + const reviewChip = readFileSync(join(__dirname, '../task-page/github/Avatars.tsx'), 'utf8') expect(reviewChip).toMatch(/GitHubUserAvatar/) // Why: list chip must not hardcode github.com/{login}.png. expect(reviewChip).not.toMatch(/github\.com\/\$\{reviewer\.login\}\.png/) @@ -77,20 +74,20 @@ describe('issue #8784 GHE avatar fallback (regression)', () => { displayName: 'option.name' }, { - file: 'task-page/github/github-assignee-avatars.tsx', + file: 'task-page/github/Avatars.tsx', fn: 'GitHubAssigneeAvatar', login: 'assignee.login', displayName: 'assignee.name' }, { - file: 'task-page/github/github-assignees-cell.tsx', + file: 'task-page/github/AssigneesCell.tsx', fn: 'GHAssigneesCell', login: 'user.login', displayName: 'user.name' }, { - file: 'task-page/github/pr-review-picker-panel.tsx', - fn: 'PRReviewPickerPanel', + file: 'task-page/github/ReviewerPicker.tsx', + fn: 'TaskPageGitHubReviewerPicker', login: 'reviewer.login', displayName: 'reviewer.name' } diff --git a/src/renderer/src/components/native-chat/use-native-chat-hook-status.test.ts b/src/renderer/src/components/native-chat/use-native-chat-hook-status.test.ts new file mode 100644 index 00000000000..90d31d09e3d --- /dev/null +++ b/src/renderer/src/components/native-chat/use-native-chat-hook-status.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from 'vitest' +import { resolveNativeChatHookState } from './use-native-chat-hook-status' + +describe('resolveNativeChatHookState', () => { + const now = 1_000_000 + + it('does not treat a restored working row as live activity', () => { + expect( + resolveNativeChatHookState( + { + state: 'working', + workingMode: undefined, + updatedAt: now, + restoredUnconfirmed: true + }, + now + ) + ).toBeNull() + }) + + it('keeps confirmed working activity live', () => { + expect( + resolveNativeChatHookState( + { + state: 'working', + workingMode: undefined, + updatedAt: now, + restoredUnconfirmed: false + }, + now + ) + ).toBe('working') + }) + + it('continues to suppress monitoring rows', () => { + expect( + resolveNativeChatHookState( + { + state: 'working', + workingMode: 'monitoring', + updatedAt: now, + restoredUnconfirmed: false + }, + now + ) + ).toBeNull() + }) + + it('does not keep an expired working row live', () => { + expect( + resolveNativeChatHookState( + { + state: 'working', + workingMode: undefined, + updatedAt: now - 30 * 60 * 1000 - 1, + restoredUnconfirmed: false + }, + now + ) + ).toBeNull() + }) +}) diff --git a/src/renderer/src/components/native-chat/use-native-chat-hook-status.ts b/src/renderer/src/components/native-chat/use-native-chat-hook-status.ts index 687dbc83ad2..5cfa0f11679 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-hook-status.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-hook-status.ts @@ -1,16 +1,39 @@ import { useAppStore } from '../../store' -import type { AgentStatusState } from '../../../../shared/agent-status-types' +import { isExplicitAgentStatusFresh } from '@/lib/agent-status' +import { + AGENT_STATUS_STALE_AFTER_MS, + type AgentStatusEntry, + type AgentStatusState +} from '../../../../shared/agent-status-types' + +/** + * Hydrated nonterminal rows are only recovery evidence until a live hook event + * confirms the turn. They must not make Native Chat look permanently busy. + */ +export function resolveNativeChatHookState( + entry: + | Pick + | undefined, + now = Date.now() +): AgentStatusState | null { + if (!entry || !isExplicitAgentStatusFresh(entry, now, AGENT_STATUS_STALE_AFTER_MS)) { + return null + } + return entry.state === 'working' && entry.workingMode === 'monitoring' ? null : entry.state +} export function useNativeChatHookStatus( paneKey: string ): readonly [AgentStatusState | null, number | null, boolean] { + // Freshness is time-based; subscribe to the scheduler epoch so a silent + // working row stops driving Native Chat when its TTL expires. + const agentStatusEpoch = useAppStore((store) => store.agentStatusEpoch) + void agentStatusEpoch // Why: primitive selectors keep unrelated pane/status updates from rerendering // native chat while still exposing the three fields used for reconciliation. const state = useAppStore((store) => { const entry = store.agentStatusByPaneKey[paneKey] - return entry?.state === 'working' && entry.workingMode === 'monitoring' - ? null - : (entry?.state ?? null) + return resolveNativeChatHookState(entry) }) const stateStartedAt = useAppStore( (store) => store.agentStatusByPaneKey[paneKey]?.stateStartedAt ?? null diff --git a/src/renderer/src/components/native-chat/use-native-chat-live-session-visibility.test.ts b/src/renderer/src/components/native-chat/use-native-chat-live-session-visibility.test.ts index a4855b9600b..80002ac263d 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-live-session-visibility.test.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-live-session-visibility.test.ts @@ -122,7 +122,7 @@ describe('useNativeChatLiveSession visibility', () => { it('unsubscribes on hide, retains committed messages, and rejects hidden work', async () => { useAppStore.setState({ agentStatusByPaneKey: { - [BASE_ARGS.paneKey]: { state: 'working', stateStartedAt: 100 } + [BASE_ARGS.paneKey]: { state: 'working', stateStartedAt: 100, updatedAt: Date.now() } } } as never) await render(BASE_ARGS) diff --git a/src/renderer/src/components/native-chat/use-native-chat-live-session.test.ts b/src/renderer/src/components/native-chat/use-native-chat-live-session.test.ts index 3bb2e2785e4..d08eb4bd6d6 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-live-session.test.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-live-session.test.ts @@ -385,7 +385,9 @@ describe('useNativeChatLiveSession — transport routing', () => { it("self-heals a stale 'working' hook once the turn-complete marker lands", async () => { useAppStore.setState({ - agentStatusByPaneKey: { [PANE]: { state: 'working', stateStartedAt: 1 } as never } + agentStatusByPaneKey: { + [PANE]: { state: 'working', stateStartedAt: 1, updatedAt: Date.now() } as never + } }) const transport = getMockTransport('env-1') await render({ paneKey: PANE, agent: AGENT, sessionId: SESSION, runtimeEnvironmentId: 'env-1' }) @@ -403,7 +405,9 @@ describe('useNativeChatLiveSession — transport routing', () => { it('stops foreground working UI when Claude enters monitoring', async () => { useAppStore.setState({ - agentStatusByPaneKey: { [PANE]: { state: 'working', stateStartedAt: 1 } as never } + agentStatusByPaneKey: { + [PANE]: { state: 'working', stateStartedAt: 1, updatedAt: Date.now() } as never + } }) const transport = getMockTransport('env-1') await render({ paneKey: PANE, agent: AGENT, sessionId: SESSION, runtimeEnvironmentId: 'env-1' }) @@ -419,7 +423,12 @@ describe('useNativeChatLiveSession — transport routing', () => { await act(async () => { useAppStore.setState({ agentStatusByPaneKey: { - [PANE]: { state: 'working', workingMode: 'monitoring', stateStartedAt: 1 } as never + [PANE]: { + state: 'working', + workingMode: 'monitoring', + stateStartedAt: 1, + updatedAt: Date.now() + } as never } }) }) @@ -429,7 +438,9 @@ describe('useNativeChatLiveSession — transport routing', () => { it('applies a lifecycle-only append after the final message frame', async () => { useAppStore.setState({ - agentStatusByPaneKey: { [PANE]: { state: 'working', stateStartedAt: 1 } as never } + agentStatusByPaneKey: { + [PANE]: { state: 'working', stateStartedAt: 1, updatedAt: Date.now() } as never + } }) const transport = getMockTransport('env-1') await render({ paneKey: PANE, agent: AGENT, sessionId: SESSION, runtimeEnvironmentId: 'env-1' }) @@ -456,7 +467,9 @@ describe('useNativeChatLiveSession — transport routing', () => { it('applies a terminal-side interruption frame without a local Stop action', async () => { useAppStore.setState({ - agentStatusByPaneKey: { [PANE]: { state: 'working', stateStartedAt: 1 } as never } + agentStatusByPaneKey: { + [PANE]: { state: 'working', stateStartedAt: 1, updatedAt: Date.now() } as never + } }) const transport = getMockTransport('env-1') await render({ paneKey: PANE, agent: AGENT, sessionId: SESSION, runtimeEnvironmentId: 'env-1' }) @@ -483,7 +496,9 @@ describe('useNativeChatLiveSession — transport routing', () => { it('does not let an older pagination read rewind a live completion', async () => { useAppStore.setState({ - agentStatusByPaneKey: { [PANE]: { state: 'working', stateStartedAt: 1 } as never } + agentStatusByPaneKey: { + [PANE]: { state: 'working', stateStartedAt: 1, updatedAt: Date.now() } as never + } }) const transport = getMockTransport('env-1') const many = Array.from({ length: NATIVE_CHAT_INITIAL_LIMIT }, (_unused, index) => @@ -529,7 +544,9 @@ describe('useNativeChatLiveSession — transport routing', () => { it('reconciles completion from a reconnect snapshot', async () => { useAppStore.setState({ - agentStatusByPaneKey: { [PANE]: { state: 'working', stateStartedAt: 10 } as never } + agentStatusByPaneKey: { + [PANE]: { state: 'working', stateStartedAt: 10, updatedAt: Date.now() } as never + } }) const transport = getMockTransport('env-1') await render({ paneKey: PANE, agent: AGENT, sessionId: SESSION, runtimeEnvironmentId: 'env-1' }) @@ -557,7 +574,9 @@ describe('useNativeChatLiveSession — transport routing', () => { it('reconciles interruption from a reconnect snapshot', async () => { useAppStore.setState({ - agentStatusByPaneKey: { [PANE]: { state: 'working', stateStartedAt: 10 } as never } + agentStatusByPaneKey: { + [PANE]: { state: 'working', stateStartedAt: 10, updatedAt: Date.now() } as never + } }) const transport = getMockTransport('env-1') await render({ paneKey: PANE, agent: AGENT, sessionId: SESSION, runtimeEnvironmentId: 'env-1' }) @@ -708,7 +727,9 @@ describe('useNativeChatLiveSession — notFound retry (#8401)', () => { const transport = getMockTransport('env-1', { autoSnapshot: false }) transport.readSession.mockResolvedValue({ error: 'No transcript found', notFound: true }) useAppStore.setState({ - agentStatusByPaneKey: { [PANE]: { state: 'working', stateStartedAt: 1 } } + agentStatusByPaneKey: { + [PANE]: { state: 'working', stateStartedAt: 1, updatedAt: Date.now() } + } } as never) await render({ paneKey: PANE, agent: AGENT, sessionId: SESSION, runtimeEnvironmentId: 'env-1' }) diff --git a/src/renderer/src/components/new-workspace/SmartWorkspaceNameField-source-boundaries.test.ts b/src/renderer/src/components/new-workspace/SmartWorkspaceNameField-source-boundaries.test.ts index e1d58f70ded..4382cd1aeb0 100644 --- a/src/renderer/src/components/new-workspace/SmartWorkspaceNameField-source-boundaries.test.ts +++ b/src/renderer/src/components/new-workspace/SmartWorkspaceNameField-source-boundaries.test.ts @@ -21,23 +21,6 @@ const COPY_SOURCE = readSource('smart-workspace-name-field-copy.ts') const INPUT_SOURCE = readSource('smart-workspace-name-input-surface.tsx') const SURFACE_SOURCE = readSource('smart-workspace-name-field-surface.tsx') const DIALOG_SOURCE = readSource('smart-workspace-cross-repo-dialog.tsx') -const FIELD_SOURCES = [ - MODEL_SOURCE, - CONTROLLER_SOURCE, - FOUNDATION_SOURCE, - AVAILABILITY_SOURCE, - FOCUS_SOURCE, - STATE_SOURCE, - GITHUB_SOURCE, - GITLAB_SOURCE, - SECONDARY_SEARCH_SOURCE, - ACTIONS_SOURCE, - PRESENTATION_SOURCE, - COPY_SOURCE, - INPUT_SOURCE, - SURFACE_SOURCE, - DIALOG_SOURCE -].join('\n') function sourceBetween(source: string, startPattern: string, endPattern: string): string { const start = source.indexOf(startPattern) @@ -72,7 +55,7 @@ describe('SmartWorkspaceNameField repo-backed source boundaries', () => { expect(availableModesSection).toContain("item.id === 'jira'") expect(availableModesSection).toContain('return jiraSourceConnected') expect(availableModesSection).toContain('branchesEnabled && !repoBackedSourcesDisabled') - expect(FIELD_SOURCES).toContain('repoBackedSourcesDisabled') + expect(CONTROLLER_SOURCE).toContain('repoBackedSourcesDisabled') expect(CONTROLLER_SOURCE).toContain('foundation.gitlabSourceAvailable') const jiraLookupSection = sourceBetween( @@ -107,8 +90,13 @@ describe('SmartWorkspaceNameField repo-backed source boundaries', () => { }) it('searches repo-backed task sources through implicit repo targets instead of a menu', () => { - expect(FIELD_SOURCES).not.toContain('RepoBackedSourceMenu') - expect(FIELD_SOURCES).not.toContain('repoBackedSourceOptions') + // The menu declared a prop, derived a visibility flag, and rendered a control; implicit repo + // targets replaced all three, so each former host is pinned separately. + expect(MODEL_SOURCE).not.toContain('repoBackedSourceOptions') + expect(CONTROLLER_SOURCE).not.toContain('repoBackedSourceOptions') + expect(FOUNDATION_SOURCE).not.toContain('repoBackedSourceOptions') + expect(SURFACE_SOURCE).not.toContain('RepoBackedSourceMenu') + expect(INPUT_SOURCE).not.toContain('RepoBackedSourceMenu') expect(MODEL_SOURCE).toContain('repoBackedSearchRepos?: readonly RepoOption[]') const targetSection = sourceBetween( @@ -125,26 +113,27 @@ describe('SmartWorkspaceNameField repo-backed source boundaries', () => { expect(CONTROLLER_SOURCE).toContain('foundation.repoBackedSearchTargets.length > 0') expect(GITHUB_SOURCE).toContain('fetchWorkItemsAcrossRepos') expect(GITHUB_SOURCE).toContain('repoBackedSearchTargets.map') + expect(GITLAB_SOURCE).toContain('repoBackedSearchTargets.map') }) it('does not fan decisive Linear URLs out to unrelated providers', () => { const githubGate = sourceBetween( - FIELD_SOURCES, + CONTROLLER_SOURCE, 'const shouldQueryGithub =', 'const shouldQueryLinear =' ) const branchGate = sourceBetween( - FIELD_SOURCES, + SECONDARY_SEARCH_SOURCE, 'const branchSearchRequest = useMemo', 'useEffect(() => {\n if (!branchSearchRequest)' ) const gitlabGate = sourceBetween( - FIELD_SOURCES, + CONTROLLER_SOURCE, 'const shouldQueryGitlab =', 'useSmartWorkspaceGitlabSearch({' ) - expect(FIELD_SOURCES).toContain( + expect(PRESENTATION_SOURCE).toContain( "linearUrlIntent !== null && (mode === 'smart' || mode === 'linear')" ) expect(githubGate).toContain('!linearUrlIntentOwnsInput') diff --git a/src/renderer/src/components/status-bar/ResourceUsageAppSection.tsx b/src/renderer/src/components/status-bar/ResourceUsageAppSection.tsx deleted file mode 100644 index 6f74cba2d5e..00000000000 --- a/src/renderer/src/components/status-bar/ResourceUsageAppSection.tsx +++ /dev/null @@ -1,98 +0,0 @@ -import React from 'react' -import { ChevronDown, ChevronRight } from 'lucide-react' -import type { AppMemory, UsageValues } from '../../../../shared/process-stats-types' -import { translate } from '@/i18n/i18n' -import { - ResourceUsageMetricPair, - ResourceUsageSparkline, - ROW_TRAILING_GUTTER_CLS -} from './ResourceUsageMetrics' - -function AppSubRow({ label, values }: { label: string; values: UsageValues }): React.JSX.Element { - return ( -
- {label} -
- - -
-
- ) -} - -export function ResourceUsageAppSection({ - app, - isCollapsed, - onToggle -}: { - app: AppMemory - isCollapsed: boolean - onToggle: () => void -}): React.JSX.Element { - return ( -
-
- -
- - {translate('auto.components.status.bar.ResourceUsageStatusSegment.288a4dd177', 'Orca')} - -
- - - -
-
-
- {!isCollapsed && ( -
- - - {(app.other.cpu > 0 || app.other.memory > 0) && ( - - )} -
- )} -
- ) -} diff --git a/src/renderer/src/components/status-bar/ResourceUsageKillDialog.tsx b/src/renderer/src/components/status-bar/ResourceUsageKillDialog.tsx deleted file mode 100644 index fcb6beb6f2e..00000000000 --- a/src/renderer/src/components/status-bar/ResourceUsageKillDialog.tsx +++ /dev/null @@ -1,98 +0,0 @@ -import React from 'react' -import { LoaderCircle } from 'lucide-react' -import { Button } from '@/components/ui/button' -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle -} from '@/components/ui/dialog' -import { translate } from '@/i18n/i18n' -import type { ResourceUsageActions } from './use-resource-usage-actions' -import type { ResourceUsageFoundation } from './use-resource-usage-foundation' - -export function ResourceUsageKillDialog({ - foundation, - actions -}: { - foundation: ResourceUsageFoundation - actions: ResourceUsageActions -}): React.JSX.Element { - const { killConfirm, killing, setKillConfirm } = foundation - const { runKillConfirmed } = actions - - return ( - { - if (next) { - return - } - if (killing) { - return - } - setKillConfirm(null) - }} - > - { - if (killing) { - event.preventDefault() - } - }} - onEscapeKeyDown={(event) => { - if (killing) { - event.preventDefault() - } - }} - > - - - {translate( - 'auto.components.status.bar.ResourceUsageStatusSegment.e9a5d3c2b1f0', - 'Kill {{value0}}?', - { - value0: - killConfirm?.label ?? - translate( - 'auto.components.status.bar.ResourceUsageStatusSegment.138b99bd80', - 'this session' - ) - } - )} - - - {translate( - 'auto.components.status.bar.ResourceUsageStatusSegment.67c4ecda49', - "Force-quits this terminal. Any unsaved work in the pane is lost. This can't be undone." - )} - - - - - - - - - ) -} diff --git a/src/renderer/src/components/status-bar/ResourceUsageMetrics.tsx b/src/renderer/src/components/status-bar/ResourceUsageMetrics.tsx deleted file mode 100644 index 6353c41b761..00000000000 --- a/src/renderer/src/components/status-bar/ResourceUsageMetrics.tsx +++ /dev/null @@ -1,130 +0,0 @@ -import React, { memo, useMemo } from 'react' -import { cn } from '@/lib/utils' -import type { Metric } from './resource-usage-merge-types' - -export const METRIC_COLUMNS_CLS = 'flex items-center shrink-0 tabular-nums' -export const CPU_COLUMN_CLS = 'w-12 text-right' -export const MEM_COLUMN_CLS = 'w-16 text-right' -export const ROW_TRAILING_GUTTER_CLS = 'w-5 shrink-0 flex items-center justify-end' - -export function formatMemory(bytes: number): string { - if (bytes < 1024 * 1024) { - return `${Math.round(bytes / 1024)} KB` - } - if (bytes < 1024 * 1024 * 1024) { - return `${(bytes / (1024 * 1024)).toFixed(1)} MB` - } - return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB` -} - -export function formatCpu(percent: number): string { - return `${percent.toFixed(1)}%` -} - -function formatMetricCpu(value: Metric): string { - return value === null ? '—' : formatCpu(value) -} - -function formatMetricMemory(value: Metric): string { - return value === null ? '—' : formatMemory(value) -} - -type SparklineProps = { - samples: number[] - width?: number - height?: number -} - -function SparklineImpl({ samples, width = 48, height = 14 }: SparklineProps): React.JSX.Element { - const points = useMemo(() => { - const safe = Array.isArray(samples) ? samples : [] - if (safe.length < 2) { - const midY = (height / 2).toFixed(1) - return `0,${midY} ${width},${midY}` - } - - let min = safe[0] - let max = safe[0] - for (const value of safe) { - if (value < min) { - min = value - } - if (value > max) { - max = value - } - } - const range = max - min || 1 - const stepX = width / (safe.length - 1) - const out: string[] = [] - for (let index = 0; index < safe.length; index++) { - const x = (index * stepX).toFixed(1) - const y = (height - ((safe[index] - min) / range) * height).toFixed(1) - out.push(`${x},${y}`) - } - return out.join(' ') - }, [samples, width, height]) - - return ( - - - - ) -} - -export const ResourceUsageSparkline = memo(SparklineImpl, (left, right) => { - if (left.width !== right.width || left.height !== right.height) { - return false - } - const leftSamples = Array.isArray(left.samples) ? left.samples : [] - const rightSamples = Array.isArray(right.samples) ? right.samples : [] - if (leftSamples === rightSamples) { - return true - } - if (leftSamples.length !== rightSamples.length) { - return false - } - for (let index = 0; index < leftSamples.length; index++) { - if (leftSamples[index] !== rightSamples[index]) { - return false - } - } - return true -}) - -export function ResourceUsageMetricPair({ - cpu, - memory, - size = 'base' -}: { - cpu: Metric - memory: Metric - size?: 'base' | 'small' -}): React.JSX.Element { - const textClassName = size === 'small' ? 'text-[11px]' : 'text-xs' - const muted = cpu === null && memory === null - return ( -
- {formatMetricCpu(cpu)} - {formatMetricMemory(memory)} -
- ) -} diff --git a/src/renderer/src/components/status-bar/ResourceUsagePopoverContent.tsx b/src/renderer/src/components/status-bar/ResourceUsagePopoverContent.tsx deleted file mode 100644 index 64ca6049363..00000000000 --- a/src/renderer/src/components/status-bar/ResourceUsagePopoverContent.tsx +++ /dev/null @@ -1,376 +0,0 @@ -import React from 'react' -import { AlertTriangle, ChevronRight, MemoryStick, RotateCw, Trash2 } from 'lucide-react' -import { Button } from '@/components/ui/button' -import { PopoverContent } from '@/components/ui/popover' -import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' -import { translate } from '@/i18n/i18n' -import { cn } from '@/lib/utils' -import { WorkspaceSpaceCompactPanel } from './WorkspaceSpaceCompactPanel' -import { ResourceUsageAppSection } from './ResourceUsageAppSection' -import { - CPU_COLUMN_CLS, - formatCpu, - formatMemory, - MEM_COLUMN_CLS, - METRIC_COLUMNS_CLS, - ROW_TRAILING_GUTTER_CLS -} from './ResourceUsageMetrics' -import { ResourceUsageTree } from './ResourceUsageTree' -import { STATUS_BAR_CONTEXT_MENU_EXEMPT_PROPS } from './status-bar-context-menu-policy' -import type { ResourceUsageActions } from './use-resource-usage-actions' -import type { ResourceUsageFoundation } from './use-resource-usage-foundation' -import type { ResourceUsageProjection } from './use-resource-usage-projection' - -export function ResourceUsagePopoverContent({ - foundation, - projection, - actions -}: { - foundation: ResourceUsageFoundation - projection: ResourceUsageProjection - actions: ResourceUsageActions -}): React.JSX.Element { - const { - sortOption, - setSortOption, - daemonActions, - resourceSnapshot, - setPopoverBodyNode, - collapsedRepos, - collapsedWorktrees, - activeWorktreeId, - appCollapsed, - setAppCollapsed - } = foundation - const { - daemonUnreachable, - sessionsOnlyError, - totalCpu, - totalMemory, - memoryMetricCopy, - orphanCount, - unifiedRepos - } = projection - const { - toggleRepo, - toggleWorktree, - navigateToWorktree, - navigateToTab, - deleteWorktree, - handleKillSession, - handleOpenWorkspaceCleanup, - handleKillOrphans, - openSpaceResults - } = actions - - return ( - event.preventDefault()} - // Why: xterm focus must not dismiss the resource manager after tab activation. - onFocusOutside={(event) => event.preventDefault()} - > -
-
- - - {translate('auto.components.status.bar.StatusBar.d1e1a7a6bf', 'Resource Manager')} - -
- -
- - - - - - {translate( - 'auto.components.status.bar.ResourceUsageStatusSegment.c9382662bb', - 'Restart daemon' - )} - - - - - - - - {translate( - 'auto.components.status.bar.ResourceUsageStatusSegment.bd19fd7a59', - 'Kill all sessions' - )} - - -
-
- - {daemonUnreachable && ( -
- -
-
- {translate( - 'auto.components.status.bar.ResourceUsageStatusSegment.f8e0d794b4', - 'Daemon is not responding' - )} -
-
- {translate( - 'auto.components.status.bar.ResourceUsageStatusSegment.f85af9cda6', - 'Resource snapshots and terminal sessions are unavailable.' - )} -
-
- -
- )} - - {!daemonUnreachable && sessionsOnlyError && ( -
- - - {translate( - 'auto.components.status.bar.ResourceUsageStatusSegment.e7cf14ec78', - 'Terminal sessions unavailable. The list may be stale.' - )} - -
- )} - - {resourceSnapshot && ( -
-
- - - - {formatCpu(totalCpu)} - - - - {translate( - 'auto.components.status.bar.ResourceUsageStatusSegment.1fedf94eae', - 'Combined CPU load. Values above 100% mean more than one core is working at once.' - )} - - - · - - - - {formatMemory(totalMemory)}{' '} - - {memoryMetricCopy.summaryLabel} - - - - - {memoryMetricCopy.description} - - -
- {orphanCount > 0 && ( - - {orphanCount === 1 - ? translate( - 'auto.components.status.bar.ResourceUsageStatusSegment.30ff2c3c31', - '{{value0}} orphan', - { value0: orphanCount } - ) - : translate( - 'auto.components.status.bar.ResourceUsageStatusSegment.b8f4a2c1d0e3', - '{{value0}} orphans', - { value0: orphanCount } - )} - - )} -
- )} - - {/* Why: fixed height prevents list expansion and polling from moving the popover. */} -
- {(unifiedRepos.length > 0 || resourceSnapshot) && ( -
- -
-
- - -
- -
-
- )} - -
- {unifiedRepos.length > 0 && ( - - )} - - {unifiedRepos.length === 0 && resourceSnapshot && ( -
- {translate( - 'auto.components.status.bar.ResourceUsageStatusSegment.27a74f91f0', - 'Nothing running right now' - )} -
- )} - - {resourceSnapshot && ( - setAppCollapsed((value) => !value)} - /> - )} - - {!resourceSnapshot && !daemonUnreachable && ( -
- {translate( - 'auto.components.status.bar.ResourceUsageStatusSegment.888dad8c55', - 'Loading…' - )} -
- )} -
-
- -
- - {orphanCount > 0 ? ( - - ) : null} -
- - -
- ) -} diff --git a/src/renderer/src/components/status-bar/ResourceUsageTree.tsx b/src/renderer/src/components/status-bar/ResourceUsageTree.tsx deleted file mode 100644 index 3914bab48df..00000000000 --- a/src/renderer/src/components/status-bar/ResourceUsageTree.tsx +++ /dev/null @@ -1,127 +0,0 @@ -import React, { useMemo } from 'react' -import { ChevronDown, ChevronRight } from 'lucide-react' -import { useWorktreeMap } from '../../store/selectors' -import { translate } from '@/i18n/i18n' -import type { - UnifiedProjectGroup, - UnifiedSessionRow, - UnifiedWorktreeRow -} from './resource-usage-merge-types' -import type { ResourceUsageSortOption } from './resource-usage-sort' -import { sortResourceUsageProjectGroups, sortResourceUsageWorktrees } from './resource-usage-sort' -import { ResourceUsageMetricPair, ROW_TRAILING_GUTTER_CLS } from './ResourceUsageMetrics' -import { ResourceUsageWorktreeRow } from './ResourceUsageWorktreeRow' - -export function ResourceUsageTree({ - repos, - sortOption, - collapsedRepos, - toggleRepo, - collapsedWorktrees, - activeWorktreeId, - toggleWorktree, - navigateToWorktree, - navigateToTab, - onDelete, - onKillSession -}: { - repos: UnifiedProjectGroup[] - sortOption: ResourceUsageSortOption - collapsedRepos: Set - toggleRepo: (repoId: string) => void - collapsedWorktrees: Set - activeWorktreeId: string | null - toggleWorktree: (worktreeId: string) => void - navigateToWorktree: (worktreeId: string) => void - navigateToTab: (tabId: string, paneKey: string | null) => void - onDelete: (worktreeId: string) => void - onKillSession: (session: UnifiedSessionRow) => void -}): React.JSX.Element { - const worktreeById = useWorktreeMap() - const sortedRepos = useMemo(() => { - const grouped = sortResourceUsageProjectGroups(repos, sortOption) - return grouped.map((repo) => ({ - ...repo, - worktrees: sortResourceUsageWorktrees(repo.worktrees, sortOption) - })) - }, [repos, sortOption]) - const renderWorktree = (worktree: UnifiedWorktreeRow): React.JSX.Element => { - const storeRecord = worktreeById.get(worktree.worktreeId) ?? null - return ( - toggleWorktree(worktree.worktreeId)} - onNavigate={() => navigateToWorktree(worktree.worktreeId)} - onDelete={() => onDelete(worktree.worktreeId)} - onKillSession={onKillSession} - navigateToTab={navigateToTab} - /> - ) - } - - if (sortedRepos.length === 1) { - return <>{sortedRepos[0].worktrees.map(renderWorktree)} - } - - return ( - <> - {sortedRepos.map((group) => { - const repoCollapsed = collapsedRepos.has(group.repoId) - return ( -
-
- -
- - - {group.repoName} - - {group.hasRemoteChildren && ( - - {translate( - 'auto.components.status.bar.ResourceUsageStatusSegment.21cacb16d1', - '· remote' - )} - - )} - -
- - -
-
-
- {!repoCollapsed && ( -
{group.worktrees.map(renderWorktree)}
- )} -
- ) - })} - - ) -} diff --git a/src/renderer/src/components/status-bar/ResourceUsageTrigger.tsx b/src/renderer/src/components/status-bar/ResourceUsageTrigger.tsx deleted file mode 100644 index 567be1d8a4b..00000000000 --- a/src/renderer/src/components/status-bar/ResourceUsageTrigger.tsx +++ /dev/null @@ -1,95 +0,0 @@ -import React from 'react' -import { AlertTriangle, MemoryStick, Terminal } from 'lucide-react' -import { PopoverTrigger } from '@/components/ui/popover' -import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' -import { translate } from '@/i18n/i18n' -import { STATUS_BAR_CONTEXT_MENU_EXEMPT_PROPS } from './status-bar-context-menu-policy' -import type { ResourceUsageProjection } from './use-resource-usage-projection' - -export function ResourceUsageTrigger({ - iconOnly, - spaceScanReady, - projection -}: { - iconOnly: boolean - spaceScanReady: boolean - projection: ResourceUsageProjection -}): React.JSX.Element { - const { - daemonUnreachable, - resourceManagerAriaLabel, - memBadgeLabel, - triggerSessionCount, - orphanCount, - resourceManagerTooltipLines - } = projection - - return ( - - - - - - - -
- {resourceManagerTooltipLines.map((line) => ( -
- {line.text} -
- ))} -
-
-
- ) -} diff --git a/src/renderer/src/components/status-bar/ResourceUsageWorktreeRow.tsx b/src/renderer/src/components/status-bar/ResourceUsageWorktreeRow.tsx deleted file mode 100644 index 741863d11f1..00000000000 --- a/src/renderer/src/components/status-bar/ResourceUsageWorktreeRow.tsx +++ /dev/null @@ -1,260 +0,0 @@ -import React from 'react' -import { ChevronDown, ChevronRight, Globe, Trash2, X } from 'lucide-react' -import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' -import { cn } from '@/lib/utils' -import { translate } from '@/i18n/i18n' -import type { BrowserWorkspace } from '../../../../shared/browser-workspace-types' -import type { Worktree } from '../../../../shared/worktree/types' -import { ORPHAN_WORKTREE_ID } from '../../../../shared/constants' -import { UNATTRIBUTED_REPO_ID } from './mergeSnapshotAndSessions' -import type { UnifiedSessionRow, UnifiedWorktreeRow } from './resource-usage-merge-types' -import { isResourceSessionActivationKey } from './resource-session-navigation' -import { - ResourceUsageMetricPair, - ResourceUsageSparkline, - ROW_TRAILING_GUTTER_CLS -} from './ResourceUsageMetrics' - -export function ResourceUsageSessionRow({ - session, - worktreeId, - onNavigate, - onKill -}: { - session: UnifiedSessionRow - worktreeId: string - onNavigate: (tabId: string, paneKey: string | null) => void - onKill: (session: UnifiedSessionRow) => void -}): React.JSX.Element { - const clickable = session.tabId !== null && session.bound - const handleClick = (): void => { - if (clickable && session.tabId) { - onNavigate(session.tabId, session.paneKey) - } - } - - return ( -
{ - if (isResourceSessionActivationKey(event.key)) { - event.preventDefault() - handleClick() - } - } - : undefined - } - data-worktree-id={worktreeId} - > - - - {session.label} - - - {/* Why: the shared gutter aligns columns while keeping orphan kills visible. */} - - - -
- ) -} - -function BrowserRow({ browser }: { browser: BrowserWorkspace }): React.JSX.Element { - const label = browser.title?.trim() || browser.label?.trim() || browser.url - return ( -
- - {label} - - -
- ) -} - -export function ResourceUsageWorktreeRow({ - worktree, - storeRecord, - activeWorktreeId, - isCollapsed, - onToggle, - onNavigate, - onDelete, - onKillSession, - navigateToTab -}: { - worktree: UnifiedWorktreeRow - storeRecord: Worktree | null - activeWorktreeId: string | null - isCollapsed: boolean - onToggle: () => void - onNavigate: () => void - onDelete: () => void - onKillSession: (session: UnifiedSessionRow) => void - navigateToTab: (tabId: string, paneKey: string | null) => void -}): React.JSX.Element { - const hasResources = worktree.sessions.length > 0 || worktree.browsers.length > 0 - const isSynthetic = - worktree.worktreeId === ORPHAN_WORKTREE_ID || worktree.repoId === UNATTRIBUTED_REPO_ID - const isNavigable = !isSynthetic - const showWorktreeActions = - !isSynthetic && storeRecord !== null && worktree.worktreeId !== activeWorktreeId - const isMainWorktree = storeRecord?.isMainWorktree ?? false - const rowLabel = storeRecord?.displayName?.trim() || worktree.worktreeName - - return ( -
-
- {hasResources ? ( - - ) : ( - - )} - -
-
- - - - {showWorktreeActions && ( -
- - - - - - {isMainWorktree - ? translate( - 'auto.components.status.bar.ResourceUsageStatusSegment.946724a70a', - 'The main workspace cannot be deleted.' - ) - : translate( - 'auto.components.status.bar.ResourceUsageStatusSegment.a82253b458', - 'Delete workspace.' - )} - - -
- )} -
- - -
-
- {!isCollapsed && - worktree.sessions.map((session) => ( - - ))} - {!isCollapsed && - worktree.browsers.map((browser) => )} -
- ) -} diff --git a/src/renderer/src/components/status-bar/resource-usage-sort.ts b/src/renderer/src/components/status-bar/resource-usage-sort.ts deleted file mode 100644 index e66e5c8e458..00000000000 --- a/src/renderer/src/components/status-bar/resource-usage-sort.ts +++ /dev/null @@ -1,47 +0,0 @@ -import type { Metric, UnifiedProjectGroup, UnifiedWorktreeRow } from './resource-usage-merge-types' - -export type ResourceUsageSortOption = 'memory' | 'cpu' | 'name' - -function compareMetricDesc(left: Metric, right: Metric): number { - // Why: remote null metrics stay behind sampled rows for every sort direction. - if (left === null && right === null) { - return 0 - } - if (left === null) { - return 1 - } - if (right === null) { - return -1 - } - return right - left -} - -export function sortResourceUsageWorktrees( - list: UnifiedWorktreeRow[], - sort: ResourceUsageSortOption -): UnifiedWorktreeRow[] { - const copy = [...list] - if (sort === 'memory') { - copy.sort((left, right) => compareMetricDesc(left.memory, right.memory)) - } else if (sort === 'cpu') { - copy.sort((left, right) => compareMetricDesc(left.cpu, right.cpu)) - } else { - copy.sort((left, right) => left.worktreeName.localeCompare(right.worktreeName)) - } - return copy -} - -export function sortResourceUsageProjectGroups( - groups: UnifiedProjectGroup[], - sort: ResourceUsageSortOption -): UnifiedProjectGroup[] { - const copy = [...groups] - if (sort === 'memory') { - copy.sort((left, right) => compareMetricDesc(left.memory, right.memory)) - } else if (sort === 'cpu') { - copy.sort((left, right) => compareMetricDesc(left.cpu, right.cpu)) - } else { - copy.sort((left, right) => left.repoName.localeCompare(right.repoName)) - } - return copy -} diff --git a/src/renderer/src/components/status-bar/use-resource-usage-foundation.ts b/src/renderer/src/components/status-bar/use-resource-usage-foundation.ts deleted file mode 100644 index bfb0314cfca..00000000000 --- a/src/renderer/src/components/status-bar/use-resource-usage-foundation.ts +++ /dev/null @@ -1,213 +0,0 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { useMountedRef } from '@/hooks/useMountedRef' -import { useAppStore } from '../../store' -import { useDaemonActions } from '../shared/useDaemonActions' -import type { UnifiedSessionRow } from './resource-usage-merge-types' -import type { ResourceUsageSortOption } from './resource-usage-sort' -import { - getResourceUsageAllWorktrees, - getResourceUsageBrowserTabsByWorktree, - getResourceUsageDeferredSshSessionIdsByTabId, - getResourceUsagePtyIdsByTabId, - getResourceUsageRepos, - getResourceUsageRuntimePaneTitlesByTabId, - getResourceUsageTerminalLayoutsByTabId, - getResourceUsageTabsByWorktree -} from './resource-usage-open-slices' -import { - resolveResourceUsageSpaceScanReady, - type ResourceUsageSpaceScanSnapshot -} from './resource-usage-space-scan-ready' -import type { ResourceSessionBindingInputs } from './resource-session-bindings' -import { useResourceSessionInventory } from './use-resource-session-inventory' - -const POLL_MS = 2_000 - -export function useResourceUsageFoundation() { - const snapshot = useAppStore((state) => state.memorySnapshot) - const memorySnapshotError = useAppStore((state) => state.memorySnapshotError) - const fetchSnapshot = useAppStore((state) => state.fetchMemorySnapshot) - const workspaceSessionReady = useAppStore((state) => state.workspaceSessionReady) - const setActiveView = useAppStore((state) => state.setActiveView) - const openModal = useAppStore((state) => state.openModal) - const openSpacePage = useAppStore((state) => state.openSpacePage) - const recordFeatureInteraction = useAppStore((state) => state.recordFeatureInteraction) - const activeView = useAppStore((state) => state.activeView) - const activeWorktreeId = useAppStore((state) => state.activeWorktreeId) - const workspaceSpaceScannedAt = useAppStore( - (state) => state.workspaceSpaceAnalysis?.scannedAt ?? null - ) - const workspaceSpaceScanning = useAppStore((state) => state.workspaceSpaceScanning) - const [open, setOpen] = useState(false) - const [sortOption, setSortOption] = useState('memory') - const [collapsedRepos, setCollapsedRepos] = useState>(new Set()) - const [collapsedWorktrees, setCollapsedWorktrees] = useState>(new Set()) - const [appCollapsed, setAppCollapsed] = useState(true) - const { - sessionInventory, - sessionsError, - refreshSessions, - clearSessionsError, - removeSession, - removeSessions - } = useResourceSessionInventory(workspaceSessionReady) - const sessions = sessionInventory.sessions - const [killConfirm, setKillConfirm] = useState(null) - const [killing, setKilling] = useState(false) - const [spaceScanSnapshot, setSpaceScanSnapshot] = useState( - () => ({ - ready: false, - previousScanning: workspaceSpaceScanning, - lastSeenScannedAt: workspaceSpaceScannedAt - }) - ) - // Why: title and binding maps churn; the closed trigger selects stable sentinels. - const runtimePaneTitlesByTabId = useAppStore((state) => - getResourceUsageRuntimePaneTitlesByTabId(state, open) - ) - const repos = useAppStore((state) => getResourceUsageRepos(state, open)) - const allWorktrees = useAppStore((state) => getResourceUsageAllWorktrees(state, open)) - const tabsByWorktree = useAppStore((state) => getResourceUsageTabsByWorktree(state, open)) - const browserTabsByWorktree = useAppStore((state) => - getResourceUsageBrowserTabsByWorktree(state, open) - ) - const ptyIdsByTabId = useAppStore((state) => getResourceUsagePtyIdsByTabId(state, open)) - const terminalLayoutsByTabId = useAppStore((state) => - getResourceUsageTerminalLayoutsByTabId(state, open) - ) - const deferredSshSessionIdsByTabId = useAppStore((state) => - getResourceUsageDeferredSshSessionIdsByTabId(state, open) - ) - const resourceSnapshot = snapshot - const resourceSessionBindings = useMemo( - () => ({ - ptyIdsByTabId, - tabsByWorktree, - terminalLayoutsByTabId, - deferredSshSessionIdsByTabId, - workspaceSessionReady - }), - [ - ptyIdsByTabId, - tabsByWorktree, - terminalLayoutsByTabId, - deferredSshSessionIdsByTabId, - workspaceSessionReady - ] - ) - const popoverBodyRef = useRef(null) - const popoverBodyFocusFrameRef = useRef(null) - const mountedRef = useMountedRef() - const cancelPopoverBodyFocusFrame = useCallback((): void => { - if (popoverBodyFocusFrameRef.current === null) { - return - } - cancelAnimationFrame(popoverBodyFocusFrameRef.current) - popoverBodyFocusFrameRef.current = null - }, []) - const setPopoverBodyNode = useCallback( - (node: HTMLDivElement | null): void => { - if (!node) { - cancelPopoverBodyFocusFrame() - } - popoverBodyRef.current = node - }, - [cancelPopoverBodyFocusFrame] - ) - const daemonActions = useDaemonActions({ - onRestartSettled: () => { - clearSessionsError() - void fetchSnapshot() - void refreshSessions() - } - }) - const nextSpaceScanSnapshot = resolveResourceUsageSpaceScanReady({ - snapshot: spaceScanSnapshot, - open, - activeView, - scannedAt: workspaceSpaceScannedAt, - scanning: workspaceSpaceScanning - }) - if ( - nextSpaceScanSnapshot.ready !== spaceScanSnapshot.ready || - nextSpaceScanSnapshot.previousScanning !== spaceScanSnapshot.previousScanning || - nextSpaceScanSnapshot.lastSeenScannedAt !== spaceScanSnapshot.lastSeenScannedAt - ) { - setSpaceScanSnapshot(nextSpaceScanSnapshot) - } - const spaceScanReady = nextSpaceScanSnapshot.ready - - // Why: seed RAM after session restore so the closed badge does not require a click. - useEffect(() => { - if (workspaceSessionReady) { - void fetchSnapshot() - } - }, [workspaceSessionReady, fetchSnapshot]) - - useEffect(() => { - if (!open) { - return - } - void fetchSnapshot() - void refreshSessions() - const memTimer = window.setInterval(() => { - void fetchSnapshot() - }, POLL_MS) - return () => { - window.clearInterval(memTimer) - } - }, [open, fetchSnapshot, refreshSessions]) - - useEffect(() => { - if (!open) { - clearSessionsError() - } - }, [open, clearSessionsError]) - - return { - snapshot, - memorySnapshotError, - workspaceSessionReady, - setActiveView, - openModal, - openSpacePage, - recordFeatureInteraction, - activeWorktreeId, - open, - setOpen, - sortOption, - setSortOption, - collapsedRepos, - setCollapsedRepos, - collapsedWorktrees, - setCollapsedWorktrees, - appCollapsed, - setAppCollapsed, - sessionInventory, - sessionsError, - refreshSessions, - removeSession, - removeSessions, - sessions, - killConfirm, - setKillConfirm, - killing, - setKilling, - runtimePaneTitlesByTabId, - repos, - allWorktrees, - tabsByWorktree, - browserTabsByWorktree, - resourceSnapshot, - resourceSessionBindings, - popoverBodyRef, - popoverBodyFocusFrameRef, - mountedRef, - cancelPopoverBodyFocusFrame, - setPopoverBodyNode, - daemonActions, - spaceScanReady - } -} - -export type ResourceUsageFoundation = ReturnType diff --git a/src/renderer/src/components/status-bar/use-resource-usage-projection.ts b/src/renderer/src/components/status-bar/use-resource-usage-projection.ts deleted file mode 100644 index 5b8145d58e0..00000000000 --- a/src/renderer/src/components/status-bar/use-resource-usage-projection.ts +++ /dev/null @@ -1,150 +0,0 @@ -import { useEffect, useMemo, useState } from 'react' -import { getRepoExecutionHostId, parseExecutionHostId } from '../../../../shared/execution-host' -import { countEstimatedInactiveWorkspaces } from '../workspace-cleanup/inactive-workspace-estimate' -import { mergeSnapshotAndSessions } from './mergeSnapshotAndSessions' -import { countUnboundDaemonSessions } from './resource-session-bindings' -import { - getResourceManagerAriaLabel, - getResourceManagerTooltipLines -} from './resource-manager-terminal-copy' -import { getResourceMemoryMetricCopy } from './resource-memory-metric-copy' -import { formatMemory } from './ResourceUsageMetrics' -import type { ResourceUsageFoundation } from './use-resource-usage-foundation' - -export function useResourceUsageProjection(foundation: ResourceUsageFoundation) { - const { - repos, - allWorktrees, - open, - resourceSnapshot, - sessions, - resourceSessionBindings, - runtimePaneTitlesByTabId, - browserTabsByWorktree, - workspaceSessionReady, - sessionInventory, - sessionsError, - memorySnapshotError, - snapshot, - spaceScanReady - } = foundation - - const repoDisplayNameById = useMemo(() => { - const map = new Map() - for (const repo of repos) { - const display = repo.displayName?.trim() - if (display) { - map.set(repo.id, display) - } - } - return map - }, [repos]) - - // Why: connectionId is the only honest signal that a repo runs over SSH. - const repoConnectionIdById = useMemo(() => { - const map = new Map() - for (const repo of repos) { - map.set(repo.id, repo.connectionId ?? null) - } - return map - }, [repos]) - - const repoRuntimeScopedById = useMemo(() => { - const map = new Map() - for (const repo of repos) { - const parsed = parseExecutionHostId(getRepoExecutionHostId(repo)) - map.set(repo.id, parsed?.kind === 'runtime') - } - return map - }, [repos]) - - const repoById = useMemo(() => new Map(repos.map((repo) => [repo.id, repo])), [repos]) - const worktreeById = useMemo( - () => new Map(allWorktrees.map((worktree) => [worktree.id, worktree])), - [allWorktrees] - ) - const [oldWorkspaceCount, setOldWorkspaceCount] = useState(0) - useEffect(() => { - setOldWorkspaceCount(countEstimatedInactiveWorkspaces(allWorktrees, repoById, Date.now())) - }, [allWorktrees, repoById]) - - // Why: the closed segment must not merge on keystroke-driven store updates. - const unifiedRepos = useMemo( - () => - open - ? mergeSnapshotAndSessions(resourceSnapshot, sessions, { - ...resourceSessionBindings, - runtimePaneTitlesByTabId, - repoDisplayNameById, - repoConnectionIdById, - repoRuntimeScopedById, - browserTabsByWorktree, - worktreeById - }) - : [], - [ - open, - resourceSnapshot, - sessions, - resourceSessionBindings, - runtimePaneTitlesByTabId, - repoDisplayNameById, - repoConnectionIdById, - repoRuntimeScopedById, - browserTabsByWorktree, - worktreeById - ] - ) - - const orphanCount = useMemo(() => { - if (!open || !workspaceSessionReady) { - return 0 - } - return countUnboundDaemonSessions(sessions, resourceSessionBindings) - }, [open, sessions, resourceSessionBindings, workspaceSessionReady]) - - const triggerSessionCount = sessionInventory.count - const memoryMetricCopy = getResourceMemoryMetricCopy( - resourceSnapshot?.processMemoryMetric ?? 'rss' - ) - const { totalMemory, totalCpu, memBadgeLabel } = useMemo(() => { - const memory = resourceSnapshot?.totalMemory ?? 0 - const cpu = resourceSnapshot?.totalCpu ?? 0 - return { - totalMemory: memory, - totalCpu: cpu, - memBadgeLabel: resourceSnapshot ? formatMemory(memory) : '—' - } - }, [resourceSnapshot]) - - const daemonUnreachable = sessionsError && (memorySnapshotError !== null || snapshot === null) - const sessionsOnlyError = sessionsError && memorySnapshotError === null - const resourceManagerTooltipLines = getResourceManagerTooltipLines({ - memoryLabel: resourceSnapshot - ? `${memBadgeLabel} · ${memoryMetricCopy.summaryLabel}` - : memBadgeLabel, - sessionCount: triggerSessionCount, - spaceScanReady - }) - const resourceManagerAriaLabel = getResourceManagerAriaLabel({ - sessionCount: triggerSessionCount, - spaceScanReady - }) - - return { - oldWorkspaceCount, - unifiedRepos, - orphanCount, - triggerSessionCount, - memoryMetricCopy, - totalMemory, - totalCpu, - memBadgeLabel, - daemonUnreachable, - sessionsOnlyError, - resourceManagerTooltipLines, - resourceManagerAriaLabel - } -} - -export type ResourceUsageProjection = ReturnType diff --git a/src/renderer/src/components/status-bar/workspace-space-breakdown-list.test.tsx b/src/renderer/src/components/status-bar/workspace-space-breakdown-list.test.tsx new file mode 100644 index 00000000000..52f1f167e50 --- /dev/null +++ b/src/renderer/src/components/status-bar/workspace-space-breakdown-list.test.tsx @@ -0,0 +1,111 @@ +// @vitest-environment happy-dom + +import { cleanup, render } from '@testing-library/react' +import { afterEach, describe, expect, it } from 'vitest' +import { WorkspaceSpaceBreakdownList } from './workspace-space-breakdown-list' +import type { + WorkspaceSpaceItem, + WorkspaceSpaceWorktree +} from '../../../../shared/workspace-space-types' + +function item(name: string, sizeBytes: number): WorkspaceSpaceItem { + return { name, path: `/workspace/${name}`, kind: 'directory', sizeBytes } +} + +function worktree(overrides: Partial): WorkspaceSpaceWorktree { + return { + worktreeId: 'wt', + repoId: 'repo', + repoDisplayName: 'repo', + repoPath: '/repo', + displayName: 'workspace', + path: '/workspace', + branch: 'refs/heads/main', + isMainWorktree: false, + isRemote: false, + isSparse: false, + canDelete: true, + lastActivityAt: 0, + status: 'ok', + error: null, + scannedAt: 0, + sizeBytes: 0, + reclaimableBytes: 0, + skippedEntryCount: 0, + topLevelItems: [], + omittedTopLevelItemCount: 0, + omittedTopLevelSizeBytes: 0, + ...overrides + } +} + +function renderedRowNames(container: HTMLElement): string[] { + return Array.from(container.querySelectorAll('span.font-medium')).map( + (node) => node.textContent ?? '' + ) +} + +afterEach(cleanup) + +describe('WorkspaceSpaceBreakdownList', () => { + it('renders one row per counted top-level item, including the omitted aggregate', () => { + const { container } = render( + + ) + + const names = renderedRowNames(container) + expect(names).toEqual(['node_modules', 'src', 'Other top-level items (7)']) + // The header count labels this list: the 7 omitted items are one aggregate row. + expect(container.textContent).toContain('9 top-level items') + expect(names.length - 1 + 7).toBe(9) + }) + + it('scales the size bars against the omitted aggregate when it is the largest item', () => { + const { container } = render( + + ) + + const widths = Array.from(container.querySelectorAll('div[style]')).map( + (node) => node.style.width + ) + expect(widths).toEqual(['25%', '100%']) + }) + + it('omits the aggregate row when nothing was omitted', () => { + const { container } = render( + + ) + + expect(renderedRowNames(container)).toEqual(['src']) + expect(container.textContent).toContain('1 top-level items') + }) + + it('shows the omitted aggregate rather than an empty state when every item was omitted', () => { + const { container } = render( + + ) + + expect(container.textContent).not.toContain('No files found.') + expect(renderedRowNames(container)).toEqual(['Other top-level items (4)']) + }) +}) diff --git a/src/renderer/src/components/status-bar/workspace-space-breakdown-list.tsx b/src/renderer/src/components/status-bar/workspace-space-breakdown-list.tsx index cda1a638c04..01530e17ba8 100644 --- a/src/renderer/src/components/status-bar/workspace-space-breakdown-list.tsx +++ b/src/renderer/src/components/status-bar/workspace-space-breakdown-list.tsx @@ -48,8 +48,24 @@ export function WorkspaceSpaceBreakdownList({ ) } - const maxChildSize = getLargestWorkspaceSpaceItemSize(worktree.topLevelItems) + const maxChildSize = Math.max( + getLargestWorkspaceSpaceItemSize(worktree.topLevelItems), + worktree.omittedTopLevelSizeBytes + ) const topLevelItemCount = worktree.topLevelItems.length + worktree.omittedTopLevelItemCount + const omittedItem: WorkspaceSpaceItem | null = + worktree.omittedTopLevelItemCount > 0 + ? { + name: translate( + 'components.status.bar.workspaceSpace.otherTopLevelItems', + 'Other top-level items ({{value0}})', + { value0: worktree.omittedTopLevelItemCount } + ), + path: '', + kind: 'other', + sizeBytes: worktree.omittedTopLevelSizeBytes + } + : null return (
@@ -86,7 +102,7 @@ export function WorkspaceSpaceBreakdownList({ )}
- ) : worktree.topLevelItems.length === 0 ? ( + ) : topLevelItemCount === 0 ? (
{translate( 'auto.components.status.bar.WorkspaceSpaceManagerPanel.16988df079', @@ -99,6 +115,7 @@ export function WorkspaceSpaceBreakdownList({ {worktree.topLevelItems.slice(0, 12).map((item) => ( ))} + {omittedItem ? : null}
)} diff --git a/src/renderer/src/components/status-bar/workspace-space-presentation.test.ts b/src/renderer/src/components/status-bar/workspace-space-presentation.test.ts index 666db63968f..08b4b4bc9d9 100644 --- a/src/renderer/src/components/status-bar/workspace-space-presentation.test.ts +++ b/src/renderer/src/components/status-bar/workspace-space-presentation.test.ts @@ -19,7 +19,7 @@ import { sortWorkspaceSpaceRows } from './workspace-space-presentation' import { getWorkspaceSpaceGitStatusRefreshCandidates } from './workspace-space-git-status-order' -import { getWorkspaceDecisionDetails } from './WorkspaceSpaceManagerPanel' +import { getWorkspaceDecisionDetails } from './workspace-space-decision-details' import { getWorkspaceSpaceDeleteState, getWorkspaceSpaceGitStatusForScan @@ -452,6 +452,117 @@ describe('workspace space presentation helpers', () => { expect(details.reviewLabel).toBeNull() }) + it('hides only a matching suppressed GitHub review from workspace decisions', () => { + const matching = getWorkspaceDecisionDetails( + row({ branch: 'refs/heads/feature/local' }), + decisionInputs({ + hostedReviewCache: { + 'local::repo::feature/local': { + data: { + provider: 'github', + number: 12, + state: 'open', + status: 'success', + title: 'Suppressed PR' + } + } + }, + worktreeMap: new Map([ + [ + 'wt', + worktreeRecord({ + branch: 'refs/heads/feature/local', + linkedPR: null, + suppressedGitHubPR: 12 + }) + ] + ]) + }) + ) + const different = getWorkspaceDecisionDetails( + row({ branch: 'refs/heads/feature/local' }), + decisionInputs({ + hostedReviewCache: { + 'local::repo::feature/local': { + data: { + provider: 'github', + number: 13, + state: 'open', + status: 'success', + title: 'Different PR' + } + } + }, + worktreeMap: new Map([ + [ + 'wt', + worktreeRecord({ + branch: 'refs/heads/feature/local', + linkedPR: null, + suppressedGitHubPR: 12 + }) + ] + ]) + }) + ) + + expect(matching.reviewLabel).toBeNull() + expect(different.reviewLabel).toBe('PR #13 Open, success') + }) + + it('preserves explicit links and non-GitHub reviews in workspace decisions', () => { + const cachedReview = { + number: 12, + state: 'open', + status: 'success', + title: 'Review' + } + const explicit = getWorkspaceDecisionDetails( + row({ branch: 'refs/heads/feature/local' }), + decisionInputs({ + hostedReviewCache: { + 'local::repo::feature/local': { + data: { ...cachedReview, provider: 'github' } + } + }, + worktreeMap: new Map([ + [ + 'wt', + worktreeRecord({ + branch: 'refs/heads/feature/local', + linkedPR: 12, + suppressedGitHubPR: 12 + }) + ] + ]) + }) + ) + const gitLab = getWorkspaceDecisionDetails( + row({ branch: 'refs/heads/feature/local' }), + decisionInputs({ + hostedReviewCache: { + 'local::repo::feature/local': { + data: { ...cachedReview, provider: 'gitlab' } + } + }, + worktreeMap: new Map([ + [ + 'wt', + worktreeRecord({ + branch: 'refs/heads/feature/local', + linkedPR: null, + suppressedGitHubPR: 12, + linkedGitLabMR: 12 + }) + ] + ]) + }) + ) + + expect(explicit.reviewLabel).toBe('PR #12 Open, success') + expect(gitLab.reviewLabel).toBe('PR #12 Open, success') + }) + it('counts migration-unsupported agent entries by worktree id', () => { const count = countWorkspaceSpaceActiveAgents({ worktreeId: 'wt', @@ -484,6 +595,27 @@ describe('workspace space presentation helpers', () => { ).toEqual(rows.map((item) => item.worktreeId)) }) + it('orders git-status refreshes active first, then visible, then the rest', () => { + const rows = [ + row({ worktreeId: 'rest-a', executionHostId: 'local' }), + row({ worktreeId: 'visible-a', executionHostId: 'local' }), + row({ worktreeId: 'active', executionHostId: 'ssh:builder' }), + row({ worktreeId: 'visible-b', executionHostId: 'local' }), + row({ worktreeId: 'rest-b', executionHostId: 'local' }) + ] + const visibleWorktreeIdentities = new Set( + [rows[1], rows[3]].map(getWorkspaceSpaceWorktreeIdentity) + ) + + expect( + getWorkspaceSpaceGitStatusRefreshCandidates(rows, { + activeWorktreeId: 'active', + activeExecutionHostId: 'ssh:builder', + visibleWorktreeIdentities + }).map((item) => item.worktreeId) + ).toEqual(['active', 'visible-a', 'visible-b', 'rest-a', 'rest-b']) + }) + it('resolves inspected worktree ids from the current scan rows', () => { const rows = [ row({ worktreeId: 'errored', status: 'error' }), diff --git a/src/renderer/src/components/tab-bar/SortableTabContextMenu.test.tsx b/src/renderer/src/components/tab-bar/SortableTabContextMenu.test.tsx index 76d5d8d145a..e51c599f669 100644 --- a/src/renderer/src/components/tab-bar/SortableTabContextMenu.test.tsx +++ b/src/renderer/src/components/tab-bar/SortableTabContextMenu.test.tsx @@ -55,6 +55,7 @@ vi.mock('lucide-react', () => ({ ArrowRight: () => null, ArrowUp: () => null, Columns2: () => null, + Copy: () => null, ListX: () => null, MessageSquare: () => null, PanelBottomClose: () => null, @@ -71,6 +72,8 @@ vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback })) +vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } })) + vi.mock('../../store', () => ({ useAppStore: Object.assign( (selector: (state: Record) => unknown) => selector(storeMock.state), @@ -289,4 +292,60 @@ describe('SortableTabContextMenu', () => { expect(container.textContent).not.toContain('Move Tab to Split') expect(container.textContent).toContain('Split terminal right') }) + + describe('copy session id', () => { + const LEAF = '11111111-1111-4111-8111-111111111111' + + function withLiveAgent(sessionId: string | null): void { + storeMock.state = { + ...storeMock.state, + terminalLayoutsByTabId: { + 'term-1': { root: { type: 'leaf', leafId: LEAF }, activeLeafId: LEAF } + }, + agentStatusByPaneKey: { + [`term-1:${LEAF}`]: { + state: 'done', + prompt: '', + updatedAt: 1, + stateStartedAt: 1, + paneKey: `term-1:${LEAF}`, + agentType: 'claude', + stateHistory: [], + ...(sessionId ? { providerSession: { key: 'session_id', id: sessionId } } : {}) + } + }, + paneForegroundAgentByPaneKey: {} + } + } + + it('omits the item for a tab with no agent', () => { + const { container } = renderMenu() + + expect(container.textContent).not.toContain('Copy Session ID') + }) + + it('omits the item until the active agent reports a session id', () => { + withLiveAgent(null) + const { container } = renderMenu() + + expect(container.textContent).not.toContain('Copy Session ID') + }) + + it('copies the active pane session id', async () => { + const writeClipboardText = vi.fn().mockResolvedValue(undefined) + Object.assign(window, { api: { ui: { writeClipboardText } } }) + withLiveAgent('session-abc') + const { container } = renderMenu() + + act(() => getButton(container, 'Copy Session ID').click()) + await vi.waitFor(() => expect(writeClipboardText).toHaveBeenCalledWith('session-abc')) + }) + + it('does not resolve a session id while the menu is closed', () => { + withLiveAgent('session-abc') + const { container } = renderMenu({ open: false }) + + expect(container.textContent).not.toContain('Copy Session ID') + }) + }) }) diff --git a/src/renderer/src/components/tab-bar/SortableTabContextMenu.tsx b/src/renderer/src/components/tab-bar/SortableTabContextMenu.tsx index 53b31012d39..b298072f9f6 100644 --- a/src/renderer/src/components/tab-bar/SortableTabContextMenu.tsx +++ b/src/renderer/src/components/tab-bar/SortableTabContextMenu.tsx @@ -11,6 +11,8 @@ import type { TerminalTab } from '../../../../shared/terminal-tab-types' import { useAppStore } from '../../store' import { formatShortcutLabel, useOptionalShortcutLabel } from '@/hooks/useShortcutLabel' import { translate } from '@/i18n/i18n' +import { TabAgentSessionIdMenuItem } from './TabAgentSessionIdMenuItem' +import { resolveTabAgentSessionId } from './tab-agent-session-id' import { TerminalTabSplitMenuSection } from './TerminalTabSplitMenuSection' import { TAB_CONTEXT_MENU_CONTENT_CLASS } from './tab-context-menu-sizing' @@ -121,6 +123,10 @@ export function SortableTabContextMenu({ onTogglePin }: SortableTabContextMenuProps): React.JSX.Element { const keybindings = useAppStore((state) => state.keybindings) + // The id is a primitive, so unchanged sessions stay referentially stable without a cache. + const agentSessionId = useAppStore((state) => + open ? resolveTabAgentSessionId(state, tab.id) : null + ) const splitRightShortcut = formatShortcutLabel('terminal.splitRight', keybindings) const splitDownShortcut = formatShortcutLabel('terminal.splitDown', keybindings) @@ -188,6 +194,7 @@ export function SortableTabContextMenu({ {translate('auto.components.tab.bar.SortableTabContextMenu.2f697b3c31', 'Change Title')} {renameShortcut ? {renameShortcut} : null} +
{translate('auto.components.tab.bar.SortableTabContextMenu.35e8892fd0', 'Tab Color')} diff --git a/src/renderer/src/components/tab-bar/TabAgentSessionIdMenuItem.test.tsx b/src/renderer/src/components/tab-bar/TabAgentSessionIdMenuItem.test.tsx new file mode 100644 index 00000000000..da857ec57bf --- /dev/null +++ b/src/renderer/src/components/tab-bar/TabAgentSessionIdMenuItem.test.tsx @@ -0,0 +1,79 @@ +/** + * @vitest-environment happy-dom + */ +import { act, type ReactNode } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { TabAgentSessionIdMenuItem } from './TabAgentSessionIdMenuItem' + +const toastMock = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn() })) + +vi.mock('@/components/ui/dropdown-menu', () => ({ + DropdownMenuItem: ({ + children, + disabled, + onSelect, + 'aria-label': ariaLabel + }: { + children?: ReactNode + disabled?: boolean + onSelect?: () => void + 'aria-label'?: string + }) => ( + + ) +})) + +vi.mock('lucide-react', () => ({ Copy: () => null })) +vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback })) +vi.mock('sonner', () => ({ toast: toastMock })) + +const mounted: { container: HTMLDivElement; root: Root }[] = [] + +function render(sessionId: string | null): HTMLDivElement { + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + act(() => root.render()) + mounted.push({ container, root }) + return container +} + +afterEach(() => { + for (const { container, root } of mounted.splice(0)) { + act(() => root.unmount()) + container.remove() + } + toastMock.success.mockReset() + toastMock.error.mockReset() +}) + +describe('TabAgentSessionIdMenuItem', () => { + it('renders nothing when no session id is available', () => { + expect(render(null).textContent).toBe('') + }) + + it('copies on select when an id is known', async () => { + const writeClipboardText = vi.fn().mockResolvedValue(undefined) + Object.assign(window, { api: { ui: { writeClipboardText } } }) + const container = render('abc-123') + + const button = container.querySelector('button') + expect(button?.disabled).toBe(false) + act(() => button?.click()) + await vi.waitFor(() => expect(writeClipboardText).toHaveBeenCalledWith('abc-123')) + }) + + it('reports clipboard failures', async () => { + const writeClipboardText = vi.fn().mockRejectedValue(new Error('clipboard unavailable')) + Object.assign(window, { api: { ui: { writeClipboardText } } }) + const button = render('abc-123').querySelector('button') + + act(() => button?.click()) + await vi.waitFor(() => + expect(toastMock.error).toHaveBeenCalledWith('Failed to copy Session ID') + ) + }) +}) diff --git a/src/renderer/src/components/tab-bar/TabAgentSessionIdMenuItem.tsx b/src/renderer/src/components/tab-bar/TabAgentSessionIdMenuItem.tsx new file mode 100644 index 00000000000..73f3f128e5d --- /dev/null +++ b/src/renderer/src/components/tab-bar/TabAgentSessionIdMenuItem.tsx @@ -0,0 +1,48 @@ +import { Copy } from 'lucide-react' +import { toast } from 'sonner' +import { DropdownMenuItem } from '@/components/ui/dropdown-menu' +import { translate } from '@/i18n/i18n' + +async function copySessionId(sessionId: string): Promise { + try { + await window.api.ui.writeClipboardText(sessionId) + toast.success( + translate( + 'components.tab.bar.SortableTabContextMenu.copySessionIdSuccess', + 'Session ID copied' + ) + ) + } catch { + toast.error( + translate( + 'components.tab.bar.SortableTabContextMenu.copySessionIdError', + 'Failed to copy Session ID' + ) + ) + } +} + +/** Copies the active pane's provider session id when one is available. */ +export function TabAgentSessionIdMenuItem({ + sessionId +}: { + sessionId: string | null +}): React.JSX.Element | null { + if (sessionId === null) { + return null + } + const label = translate( + 'components.tab.bar.SortableTabContextMenu.copySessionId', + 'Copy Session ID' + ) + return ( + { + void copySessionId(sessionId) + }} + > + + {label} + + ) +} diff --git a/src/renderer/src/components/tab-bar/TabBarCreateEntry.history.test.tsx b/src/renderer/src/components/tab-bar/TabBarCreateEntry.history.test.tsx new file mode 100644 index 00000000000..a1ec5fad293 --- /dev/null +++ b/src/renderer/src/components/tab-bar/TabBarCreateEntry.history.test.tsx @@ -0,0 +1,255 @@ +// @vitest-environment happy-dom + +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' +import { TooltipProvider } from '@/components/ui/tooltip' +import type { BrowserHistoryEntry } from '../../../../shared/browser-workspace-types' +import type { OpenTabSearchResult } from './open-tab-search' +import type * as ReactModule from 'react' +import type { TabCreateEntryArgs, TabEntryOption } from './tab-create-entry-action' + +const entryOptionsMock = vi.hoisted(() => ({ options: [] as TabEntryOption[] })) +const pathLikeMock = vi.hoisted(() => ({ value: false })) +vi.mock('./tab-create-entry-action', () => ({ + getTabEntryOptions: () => entryOptionsMock.options, + createTabEntryAllowAbsolutePathsSelector: () => () => true, + isTabEntryAbsolutePathLike: () => pathLikeMock.value +})) +vi.mock('../quick-open-file-list', () => ({ + useRuntimeFileListForWorktree: () => ({ + files: [], + loading: false, + loadError: null, + truncated: false + }) +})) +vi.mock('@/lib/agent-catalog', () => ({ getAgentCatalog: () => [], AgentIcon: () => null })) + +// `hold` pins deferred rows to the query they were built from, standing in for +// the hook's useDeferredValue so later keystrokes leave them stale. +const historyStoreMock = vi.hoisted(() => ({ + entries: [] as BrowserHistoryEntry[], + hold: null as string | null, + listeners: new Set<() => void>() +})) +vi.mock('@/store', () => ({ + useAppStore: Object.assign( + (selector: (state: Record) => unknown) => + selector({ + browserUrlHistory: historyStoreMock.entries, + browserDefaultSearchEngine: 'google', + getKnownWorktreeById: () => ({ path: '/tmp/wt' }) + }), + { getState: () => ({ browserUrlHistory: historyStoreMock.entries }) } + ) +})) +vi.mock('react', async () => { + const react = await vi.importActual('react') + return { + ...react, + useDeferredValue: (value: string) => historyStoreMock.hold ?? value + } +}) + +const tabResultsMock = vi.hoisted(() => ({ results: [] as OpenTabSearchResult[] })) +vi.mock('./use-tab-create-entry-search-results', () => ({ + useTabCreateEntrySearchResults: ({ enabled }: { enabled: boolean }) => + enabled ? tabResultsMock.results : [] +})) + +import TabBarCreateEntry from './TabBarCreateEntry' + +;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + +function historyEntry(overrides: Partial & { url: string }) { + return { + normalizedUrl: overrides.url.replace(/\/$/, ''), + title: 'Linear', + lastVisitedAt: Date.now(), + visitCount: 4, + ...overrides + } +} + +const linear = historyEntry({ + url: 'https://linear.app/acme/team/ORC/active', + title: 'ORC · Active issues' +}) + +let container: HTMLDivElement +let root: Root +let onOpenEntry: Mock<(args: TabCreateEntryArgs) => Promise> + +function mount(): void { + act(() => { + root.render( + + + + ) + }) +} + +function setQuery(value: string): void { + const input = container.querySelector('input') + if (!input) { + throw new Error('input not found') + } + const nativeSetter = Object.getOwnPropertyDescriptor( + window.HTMLInputElement.prototype, + 'value' + )?.set + act(() => { + nativeSetter?.call(input, value) + input.dispatchEvent(new window.Event('input', { bubbles: true })) + }) +} + +function pressKey(key: string): void { + const form = container.querySelector('form') + if (!form) { + throw new Error('form not found') + } + act(() => { + form.dispatchEvent( + new window.KeyboardEvent('keydown', { key, bubbles: true, cancelable: true }) + ) + }) +} + +function submitForm(): void { + const form = container.querySelector('form') + if (!form) { + throw new Error('form not found') + } + act(() => { + form.dispatchEvent(new window.Event('submit', { bubbles: true, cancelable: true })) + }) +} + +function rowTexts(): string[] { + return [...container.querySelectorAll('[role="option"]')].map((row) => row.textContent ?? '') +} + +beforeEach(() => { + vi.clearAllMocks() + entryOptionsMock.options = [] + pathLikeMock.value = false + tabResultsMock.results = [] + historyStoreMock.entries = [linear] + historyStoreMock.hold = null + onOpenEntry = vi.fn().mockResolvedValue(undefined) + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) +}) + +afterEach(() => { + act(() => root.unmount()) + container.remove() +}) + +describe('TabBarCreateEntry browser history rows', () => { + it('holds history back until the query is at least two characters', () => { + mount() + + setQuery('l') + expect(rowTexts().some((text) => text.includes('Open page'))).toBe(false) + + setQuery('li') + expect(rowTexts()[0]).toContain('Open page') + expect(rowTexts()[0]).toContain('ORC · Active issues') + expect(rowTexts()[0]).toContain('linear.app/acme/team/ORC/active') + }) + + it('shows no history for an empty omnibox', () => { + mount() + + expect(rowTexts().some((text) => text.includes('Open page'))).toBe(false) + }) + + it('skips history for a path-shaped query and for a forced search', () => { + pathLikeMock.value = true + mount() + setQuery('/Users/jane/linear') + expect(rowTexts().some((text) => text.includes('Open page'))).toBe(false) + + pathLikeMock.value = false + setQuery('?linear') + expect(rowTexts().some((text) => text.includes('Open page'))).toBe(false) + }) + + it('yields to the switch row when the page is already open in a browser tab', () => { + tabResultsMock.results = [ + { + executionHostId: 'local', + source: 'browser', + id: 'open-tab:browser:page-1', + title: 'ORC · Active issues', + matchedText: null, + worktreeId: 'wt', + contentType: 'browser', + pageId: 'page-1', + workspaceId: 'ws-1', + url: 'https://linear.app/acme/team/ORC/active' + } + ] + mount() + + setQuery('linear') + + const rows = rowTexts() + expect(rows[0]).toContain('Switch to tab') + expect(rows.some((text) => text.includes('Open page'))).toBe(false) + }) + + it('opens the visited url through the entry path when the row is submitted', () => { + mount() + setQuery('linear') + pressKey('ArrowDown') + + submitForm() + + expect(onOpenEntry).toHaveBeenCalledWith( + expect.objectContaining({ + classification: { kind: 'explicit-url', url: 'https://linear.app/acme/team/ORC/active' }, + worktreeId: 'wt', + groupId: 'g' + }) + ) + }) + + it('keeps arrow-key selection across a background history write', () => { + historyStoreMock.entries = [linear, historyEntry({ url: 'https://linear.app/acme/inbox' })] + mount() + setQuery('linear') + pressKey('ArrowDown') + pressKey('ArrowDown') + const selectedBefore = container.querySelector('[aria-selected="true"]')?.textContent + + // A committed navigation elsewhere replaces the store array mid-session. + act(() => { + historyStoreMock.entries = [ + historyEntry({ url: 'https://unrelated.example/page' }), + ...historyStoreMock.entries + ] + }) + setQuery('linear ') + + expect(container.querySelector('[aria-selected="true"]')?.textContent).toBe(selectedBefore) + }) + + it('refuses to submit a row the visible query no longer matches', () => { + mount() + setQuery('linear') + pressKey('ArrowDown') + // The deferred pass is still describing "linear" while the input reads "zzz". + historyStoreMock.hold = 'linear' + setQuery('zzz') + + expect(rowTexts().some((text) => text.includes('Open page'))).toBe(false) + submitForm() + expect(onOpenEntry).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/tab-bar/TabBarCreateEntry.keyboard.test.tsx b/src/renderer/src/components/tab-bar/TabBarCreateEntry.keyboard.test.tsx index 2f02c7901d9..035cba0c0c1 100644 --- a/src/renderer/src/components/tab-bar/TabBarCreateEntry.keyboard.test.tsx +++ b/src/renderer/src/components/tab-bar/TabBarCreateEntry.keyboard.test.tsx @@ -306,7 +306,7 @@ describe('TabBarCreateEntry keyboard navigation', () => { const input = container.querySelector('input')! const placeholder = input.getAttribute('placeholder') - expect(placeholder).toBe('Search open tabs, files, URLs, agents\u2026') + expect(placeholder).toBe('Search open tabs, history, files, URLs, agents\u2026') expect(input.getAttribute('aria-label')).toBe(placeholder) }) diff --git a/src/renderer/src/components/tab-bar/TabBarCreateEntry.tsx b/src/renderer/src/components/tab-bar/TabBarCreateEntry.tsx index c22b3ce77f9..ccc985379d7 100644 --- a/src/renderer/src/components/tab-bar/TabBarCreateEntry.tsx +++ b/src/renderer/src/components/tab-bar/TabBarCreateEntry.tsx @@ -8,11 +8,7 @@ import { } from './tab-create-entry-action' import { findMatchingTabAgentLaunchOptions } from './tab-agent-launch-options' import { findMatchingTabCreateMenuOptions } from './tab-create-menu-options' -import { - getActiveOptionId, - isActiveEntryOption, - type ActiveOption -} from './tab-create-entry-active-option' +import { getActiveOptionId, type ActiveOption } from './tab-create-entry-active-option' import { EntryActionRow, EntryStatusRow, @@ -20,6 +16,9 @@ import { resultOptionDomId } from './TabBarCreateEntryRow' import { dropFileEntriesCoveredByTabResults } from './open-tab-entry-dedupe' +import { insertHistoryRowsBelowFileMatches } from './tab-create-entry-history-placement' +import { useOmniboxBrowserHistory } from './use-omnibox-browser-history' +import { useTabEntryMenuReturnFocus } from './use-tab-entry-menu-return-focus' import { activateOpenTabSearchResult } from './open-tab-selection-routing' import { useTabCreateEntrySearchResults } from './use-tab-create-entry-search-results' import { DEFAULT_SEARCH_ENGINE } from '../../../../shared/browser-url' @@ -34,6 +33,7 @@ import { getTabEntryOmniboxPlaceholder } from './tab-create-entry-copy' import { EMPTY_AGENT_OPTIONS, EMPTY_MENU_OPTIONS } from './tab-create-entry-empty-options' +import type { TabEntryActionClassification } from './tab-create-entry-classifier' import type { TabBarCreateEntryProps } from './tab-create-entry-props' export default function TabBarCreateEntry(props: TabBarCreateEntryProps): React.JSX.Element { @@ -98,34 +98,7 @@ function TabBarCreateEntrySession({ (state) => state.browserDefaultSearchEngine ?? DEFAULT_SEARCH_ENGINE ) - // Why: once ArrowDown moves focus into the static menu list, ArrowUp on the - // first item should return to the search box so the keyboard trip isn't - // one-way. Capture phase beats Radix's roving-focus handler. - useEffect(() => { - if (!menuOpen) { - return - } - const input = inputRef.current - const menu = input?.closest('[role="menu"]') - if (!input || !menu) { - return - } - const handleMenuKeyDown = (event: KeyboardEvent): void => { - if (event.key !== 'ArrowUp') { - return - } - const firstItem = menu.querySelector( - '[role="menuitem"]:not([data-disabled]):not([aria-disabled="true"])' - ) - if (firstItem && document.activeElement === firstItem) { - event.preventDefault() - event.stopPropagation() - input.focus() - } - } - menu.addEventListener('keydown', handleMenuKeyDown, true) - return () => menu.removeEventListener('keydown', handleMenuKeyDown, true) - }, [menuOpen]) + useTabEntryMenuReturnFocus(inputRef, menuOpen) useEffect(() => { if (!menuOpen) { @@ -165,6 +138,11 @@ function TabBarCreateEntrySession({ tabResults, worktreePath ]) + const historyRows = useOmniboxBrowserHistory({ + enabled: menuOpen && !terminalQueryMode, + query, + tabResults + }) const matchingAgentOptions = useMemo( () => terminalQueryMode @@ -188,10 +166,7 @@ function TabBarCreateEntrySession({ kind: 'agent' as const, option })), - ...options.filter(isActiveEntryOption).map((option) => ({ - kind: 'entry' as const, - option - })) + ...insertHistoryRowsBelowFileMatches(options, historyRows) ] const { activeSelectedIndex, selectedActiveOption } = useNetworkSafeTabEntrySelection({ activeOptions, @@ -255,16 +230,16 @@ function TabBarCreateEntrySession({ onDidOpenEntry?.() return } + // A history row is a navigation, so it rides the entry-open path the typed-URL + // row already uses — routing, worktree targeting and SSH resolution included. + const classification: TabEntryActionClassification = + selectedOption.kind === 'history' + ? { kind: 'explicit-url', url: selectedOption.option.entry.url } + : selectedOption.option.classification setPending(true) setError(null) const submissionId = ++submissionIdRef.current - void onOpenEntry({ - query, - worktreeId, - groupId, - fileList, - classification: selectedOption.option.classification - }) + void onOpenEntry({ query, worktreeId, groupId, fileList, classification }) .then(() => { if (submissionIdRef.current === submissionId) { onDidOpenEntry?.() diff --git a/src/renderer/src/components/tab-bar/TabBarCreateEntryRow.tsx b/src/renderer/src/components/tab-bar/TabBarCreateEntryRow.tsx index 3137162d96e..a55b9d82974 100644 --- a/src/renderer/src/components/tab-bar/TabBarCreateEntryRow.tsx +++ b/src/renderer/src/components/tab-bar/TabBarCreateEntryRow.tsx @@ -14,6 +14,7 @@ import { cn } from '@/lib/utils' import { FilePathCursorTooltip, splitTrailingSegment } from '@/components/file-path-cursor-tooltip' import { translate } from '@/i18n/i18n' import { SEARCH_ENGINE_LABELS } from '../../../../shared/browser-url' +import { formatBrowserHistoryUrl } from '@/lib/browser-history-match' import type { ActiveOption } from './tab-create-entry-active-option' export const RESULT_LISTBOX_ID = 'tab-create-entry-results' @@ -183,6 +184,18 @@ function getActionPresentation(option: ActiveOption): { showDetail: true } } + if (option.kind === 'history') { + const { entry } = option.option + const url = formatBrowserHistoryUrl(entry.url) + return { + // Why the title is detail, not label: the label span is shrink-0 whenever a + // detail shows, so a variable-length title there would refuse to truncate. + detail: entry.title ? `${entry.title} · ${url}` : url, + icon: