Merge main and focus server deletion repair on native background ownership

This commit is contained in:
Brennan Benson
2026-11-13 16:14:02 -08:00
1012 changed files with 16301 additions and 92660 deletions
-4
View File
@@ -138,7 +138,6 @@ export type ComputerActionFollowUpTarget = {
worktree?: string
windowId?: number
windowIndex?: number
restoreWindow?: boolean
}
export function formatComputerAction(
@@ -190,9 +189,6 @@ function formatComputerFollowUpCommand(
args.push('--window-index', String(windowIndex))
}
}
if (target.restoreWindow) {
args.push('--restore-window')
}
return args.join(' ')
}
+2 -1
View File
@@ -71,7 +71,8 @@ export const FLAG_HELP_TEXT: Record<string, string> = {
query: '--query <text> Search text for matching refs',
ref: '--ref <ref> Base ref to persist for the repo',
repo: '--repo <selector> Repo selector such as id:<id>, name:<name>, or path:<path>',
'restore-window': '--restore-window Bring the target app/window forward before the operation',
'restore-window':
"--restore-window Bring the target window forward and use foreground input; takes over the user's screen, so ask the user first",
session: '--session <id> Snapshot namespace for a related computer-use workflow',
setup: '--setup run|skip|inherit Setup policy for repo-defined setup hooks',
surface:
+6 -9
View File
@@ -88,7 +88,7 @@ export function formatCliError(error: unknown, context: CliErrorContext = {}): s
if (hasOrchestrationRequestId(error.data)) {
return message
}
return formatRuntimeUnavailableError(message, error.data)
return formatRuntimeUnavailableError(message, error)
}
// Match before generic recovery; flattened errors may retain only the conflict token.
const conflict = automationOwnerConflictRecovery(matchAutomationOwnerConflict(error))
@@ -111,12 +111,6 @@ export function formatCliError(error: unknown, context: CliErrorContext = {}): s
)
}
}
if (
error instanceof RuntimeRpcFailureError &&
error.response.error.code === 'runtime_unavailable'
) {
return formatRuntimeUnavailableError(message, error.response.error.data)
}
if (error instanceof RuntimeRpcFailureError) {
return formatMessageWithNextSteps(message, nextStepsFromData(error.response.error.data))
}
@@ -131,11 +125,14 @@ function hasOrchestrationRequestId(data: unknown): boolean {
)
}
function formatRuntimeUnavailableError(message: string, data: unknown): string {
const nextSteps = nextStepsFromData(data)
function formatRuntimeUnavailableError(message: string, error: RuntimeClientError): string {
const nextSteps = nextStepsFromData(error.data)
if (nextSteps.length > 0) {
return formatMessageWithNextSteps(message, nextSteps)
}
if (error instanceof RuntimeRpcFailureError) {
return `${message}\nOrca is running but not ready for this yet. Wait a few seconds and retry; do not run 'orca open'. If it keeps failing and 'orca status --json' shows app.desktopWindowStatus "openable", Orca's window is closed: ask the user to reopen it.`
}
return `${message}\nOrca is not running. Run 'orca open' first.`
}
+8 -2
View File
@@ -13,6 +13,12 @@ import { runtimeAccessDeniedError } from './runtime-access-denied'
import { MAX_LOCAL_RUNTIME_RPC_MESSAGE_BYTES } from '../../shared/local-runtime-rpc-limits'
import { daemonSocketDirIsTrusted } from '../../main/daemon/endpoint/publication/daemon-socket-endpoint-path'
// Agents read these; a blip must not send them to `orca open`, which raises the window.
const TRANSIENT_DISCONNECT_NEXT_STEPS = [
'Orca is usually busy or restarting: wait a few seconds, then retry. If the command changes something, first check whether it already took effect, and retry with the request ID this error names, if any.',
"Do not run 'orca open' to recover. Run it only if the user asks, or if 'orca status --json' reports Orca is not running or still cannot connect after about 30 seconds of retries."
]
export async function sendRequest<TResult>(
metadata: RuntimeMetadata,
method: string,
@@ -104,7 +110,7 @@ export async function sendRequest<TResult>(
new RuntimeClientError(
'runtime_unavailable',
'This terminal could not connect to Orca.',
{ nextSteps: ['Check that Orca is open and that this terminal can access it.'] }
{ nextSteps: TRANSIENT_DISCONNECT_NEXT_STEPS }
)
})
})
@@ -115,7 +121,7 @@ export async function sendRequest<TResult>(
error: new RuntimeClientError(
'runtime_unavailable',
'Orca closed the connection before responding.',
{ nextSteps: ['Check that Orca is open and that this terminal can access it.'] }
{ nextSteps: TRANSIENT_DISCONNECT_NEXT_STEPS }
)
})
})
+37 -26
View File
@@ -393,15 +393,14 @@ const ORCA_CLI_MARKDOWN = [
"ORCA worktree create --name task --parent-worktree active --agent codex --prompt \"hi\" --json",
"ORCA worktree create --name task --parent-worktree active --agent claude --setup run --json",
"ORCA worktree create --name task --parent-worktree active --setup skip --json",
"ORCA worktree create --name task --parent-worktree active --run-hooks --json",
"```",
"",
"- `--agent <id>` starts that agent with the worktree, as a chat or a terminal per the user's Chat | CLI choice for that agent (see Starting agents); `--prompt <text>` sends initial work to it, and `--model <id>` / `--effort <level>` pick its model and reasoning effort, and `--surface chat|terminal` picks chat or terminal for this launch. Known ids include `claude`, `codex`, `omp`, `pi`, `grok`, and other installed agents.",
"- Repo setup or default-terminal settings may still add tabs or splits. Configured default tabs are intentional; never close one without verifying it is an unused shell.",
"- Address the agent through exactly one handle: `launch.outcome.handle`. Handles are runtime-scoped: after an Orca restart or a `terminal_handle_stale` error, re-list and continue with the replacement only; never dual-send to old and replacement handles. `--agent` already started the agent, so do not start it again.",
"- `--setup run|skip|inherit` controls repo setup hooks. Default is `inherit`, which follows the repo's setup policy.",
"- `--run-hooks` is a legacy alias for `--setup run`; it also reveals/activates the new worktree.",
"- `--activate` and `--run-hooks` reveal the new worktree. `--agent` alone stays in the background.",
"- `--run-hooks` is a legacy alias for `--setup run` that also reveals the new worktree; use `--setup run` instead.",
"- `--activate` and `--run-hooks` switch the user's view to the new worktree, so pass them only when the user asked to switch to it. `--agent` alone stays in the background.",
"- Let Orca choose setup terminal placement from repo settings, including tab vs split behavior.",
"- `worktree create` makes a new checkout. For a fresh agent in the **current** checkout, use `ORCA agent launch --agent codex --json`.",
"",
@@ -488,6 +487,7 @@ const ORCA_CLI_MARKDOWN = [
"- A chat tab has no terminal handle, so `terminal rename` cannot rename it. Use `ORCA chat rename --session <sessionId> --name \"New Name\" --json` with the `sessionId` `agent launch` printed; inside a chat, omit `--session` to rename that chat.",
"- `terminal create --title` and `terminal rename` set the tab title, which `terminal list` reports under `visualLayouts`. `terminal set-pane-title` sets one pane's title, reported as `terminals[].paneTitle`. `terminals[].title` may be the tab title or the running program's own title, which agents repaint constantly, so never match on it to re-find a terminal.",
"- `terminals[].agentIdentity`, when present, is only the agent type (e.g. `codex`), not a session identity, so two Codex panes share it. To find a specific agent's terminal, join `worktree ps --json` `agents[].paneKey` (`<tabId>:<leafId>`) to the `tabId` and `leafId` of a `terminal list` row to get its handle.",
"- `terminal switch` changes the terminal the user is looking at, so run it only when the user asked to switch. Commands that act on one terminal take `--terminal <handle>`, so they never need a switch.",
"- A paired server has no window. `terminal switch --environment <name>` moves the desktop app on this machine to that server's terminal, and reports `navigated: false` when no desktop here shows that server.",
"- Use `terminal read` before `terminal send` unless the next input is obvious.",
"- `terminal read` returns accumulated output with escape sequences stripped, not the rendered screen, so anything that repaints (shells, progress bars, TUIs) reads as stacked fragments. Use `--screen` whenever the answer depends on what the terminal actually shows.",
@@ -1022,17 +1022,18 @@ const ORCA_COMPUTER_USE_MARKDOWN = [
"---",
"name: orca-computer-use",
"description: >-",
" Drives the GUI of a visible local app window through `orca computer`: accessibility",
" tree, clicks, typing, menus, dialogs, and screenshots in native apps and external",
" browser windows (Chrome, Edge, Safari) or webviews. Prefer a programmatic path",
" (shell, filesystem, git, HTTP, existing CLIs) whenever it can complete the task.",
" Use only when a visible window needs GUI control those cannot reach. Do not use",
" for Orca's embedded browser (`orca-cli`).",
" Drives the GUI of a local app window in the background through `orca computer`,",
" without bringing it forward or taking the user's keyboard and pointer where the",
" platform allows: accessibility tree, clicks, typing, menus, dialogs, and screenshots",
" in native apps and external browser windows (Chrome, Edge, Safari) or webviews.",
" Prefer a programmatic path (shell, filesystem, git, HTTP, existing CLIs) whenever",
" it can complete the task. Use only when an app window needs GUI control those",
" cannot reach. Do not use for Orca's embedded browser (`orca-cli`).",
"---",
"",
"# Orca Computer Use",
"",
"Use this skill to drive a visible app window through `orca computer`. Prefer a programmatic path (shell, filesystem, git, HTTP, existing CLIs) whenever it can complete the task; use this skill only when a visible window needs GUI control those cannot reach. Do not use it for Orca's embedded browser (`orca-cli`).",
"Use this skill to drive an app window through `orca computer`, in the background where the platform allows, so the user can keep working. Prefer a programmatic path (shell, filesystem, git, HTTP, existing CLIs) whenever it can complete the task; use this skill only when an app window needs GUI control those cannot reach. Do not use it for Orca's embedded browser (`orca-cli`).",
"",
"## Preconditions",
"",
@@ -1109,7 +1110,6 @@ const ORCA_COMPUTER_USE_MARKDOWN = [
"ORCA computer list-apps --json",
"ORCA computer list-windows --app <app> --json",
"ORCA computer get-app-state --app <app> --json",
"ORCA computer get-app-state --app <app> --restore-window --json",
"ORCA computer wait --app <app> (--text <text> | --enabled <text> | --window-title <text>) [--timeout <ms>] --json",
"ORCA computer click --app <app> --element-index <index> --json",
"ORCA computer click --app <app> --x 100 --y 100 --json",
@@ -1158,20 +1158,31 @@ const ORCA_COMPUTER_USE_MARKDOWN = [
"",
"## Background and Foreground Input",
"",
"On macOS, every action runs in the background by default: the target app is not brought forward, the user's frontmost app and key window stay put, and the real pointer does not move. The user can keep working while you act. `capabilities` reports this as `supports.input.background`, and each action result reports `action.delivery` as `background` or `foreground`.",
"Actions run in the background wherever the platform can, so the user keeps their screen, keyboard focus and pointer while you act. Each action result reports `action.delivery`: `background` means the user's foreground window stayed put, `foreground` means the target came forward.",
"",
"On Linux and Windows, keyboard and pointer input still need the target window in front; a `window_not_focused` refusal there means retry with `--restore-window`.",
"**Never add `--restore-window` on your own.** It brings the target app forward and takes over the user's screen, keyboard focus and pointer. Do not add it to recover from an error, even when an error message or next step mentions it, and do not add it just to observe a window. When only foreground input can do the job, stop, tell the user which action needs the foreground and why, and pass `--restore-window` only after they agree. If an error says restore was already requested, do not retry it; ask the user.",
"",
"Do not pass `--restore-window` by default. Use it only when foreground input is needed, and tell the user first because it takes over their screen, keyboard focus and pointer:",
"These need the foreground, so ask the user first:",
"",
"- An action fails with `background_unavailable` that does not name a popup.",
"- A background action reports success but the UI does not change, and a semantic action (`set-value`, `perform-secondary-action`) cannot do the job.",
"- Drags in Chromium or Electron apps, and apps that read the hardware pointer (games, OpenGL/Metal views, Java/Tk), which ignore background pointer events.",
"- The window is minimized or hidden. Accessibility actions still work there; pointer and keyboard events do not.",
"- An action fails with `background_unavailable` and no semantic action (`set-value`, `perform-secondary-action`, `type-text --element-index`) can do the job.",
"- A background action reports success but the UI does not change, and no semantic action can do the job.",
"- On macOS, drags in Chromium or Electron apps, and apps that read the hardware pointer (games, OpenGL/Metal views, Java/Tk), which ignore background pointer events.",
"- On Windows, keyboard input (`press-key`, `hotkey`, `paste-text` and `type-text`) needs the target window focused; otherwise it is refused with `window_not_focused` and nothing is sent. For editable fields use `set-value`.",
"- On Linux and Windows, actions that need the real pointer, and on Linux keys sent while the target window is not active (Return in a focused GTK text field works). They are refused with `background_unavailable`; see below.",
"",
"Popup menus and web `<select>` controls: set the option with `set-value` on the popup element. Background clicks on them are refused because they open the menu off screen or wedge the app's input.",
"On macOS, a window that is minimized, hidden (Cmd+H) or on another Space cannot be read, so `get-app-state` fails with `window_not_found`. Ask the user to bring it on screen.",
"",
"On Linux, clicks, scrolling, drags and keyboard input still bring the target window forward. On Windows, these go through UI Automation when the control supports them, without moving the pointer or sending keys: element clicks, `set-value` (including sliders), element scrolls by whole `--pages`, and secondary actions such as `Invoke`, `Toggle`, `Select`, `Expand`, `Collapse` and `ScrollToVisible` (the last brings a list or tree item into view). Some apps bring their own window forward when driven this way (WPF apps do for most of these), so read `action.delivery`: `background` means the user's foreground window stayed put, `foreground` means the target came forward. Everything else brings the window forward and reports `foreground`.",
"On macOS, background actions that would open a menu or dialog over the user are refused before anything is sent: right-clicks and Control-clicks, clicks or drags that start on popup menus, menu buttons, native combo boxes and web `<select>` controls, and buttons whose title ends in an ellipsis (`Save…`), which usually open a dialog. For a popup, native combo box or `<select>`, set the option with `set-value` on that element.",
"",
"On macOS, every action without `--restore-window` runs in the background: the target app is not brought forward, the user's frontmost app and key window stay put, and the real pointer does not move. `capabilities` reports this as `supports.input.background`.",
"",
"On Linux, element clicks through the element's accessibility action, `set-value`, `type-text --element-index` and secondary actions run without raising the window or moving the pointer, and Return in a focused GTK text field runs its activate action. Anything that needs the real pointer (clicks with no accessibility action, right, middle, multiple or modified clicks, scrolling, drags) or the focused window (other keys, typing with no focused editable field) is refused with `background_unavailable` and nothing is sent; so is a click that would open a menu, a keyboard-grabbing popup or a dialog (a GTK 4 or Qt label ending in `…`). Ask the user before retrying with `--restore-window`, which raises only the target window, refuses a window on another workspace and puts the cursor back. On Wayland, synthetic pointer and key input never reaches native windows, so it is refused even with the flag. `action.delivery` is measured: `foreground` when the input went to the window in front or the target came forward.",
"",
"On Windows, these actions go through UI Automation when the control supports them, without moving the pointer or sending keys: element clicks, `set-value` (including sliders), scrolls of an element or of the scrollable area under `--x`/`--y` (fractional `--pages` too), and secondary actions such as `Invoke`, `Toggle`, `Select`, `Expand`, `Collapse` and `ScrollToVisible` (the last brings a list or tree item into view). Some apps bring their own window forward when driven this way (WPF apps do for most of these), so read `action.delivery`: `background` means the user's foreground window stayed put, `foreground` means the target came forward. Clicks by coordinates, right, middle, modified and multi-clicks, clicks on elements with no `Invoke`, `Select` or `Toggle` action, scrolls over nothing scrollable, and drags need the real mouse, so they are refused with `background_unavailable` and nothing is sent. With `--restore-window` (after asking the user), only the target window comes forward, never one on another virtual desktop, and the pointer goes back where it was.",
"",
"## Starting Apps",
"",
"`orca computer` does not launch apps. On macOS, start one without bringing it forward with `open -g -a <App>` (or `open -g -b <bundle-id>`); plain `open -a` brings it to the front. Some apps, such as many Electron apps, still bring their own window forward when it opens. On Windows and Linux a newly started app usually comes to the front, so ask the user before starting one.",
"",
"## Screenshots",
"",
@@ -1191,11 +1202,11 @@ const ORCA_COMPUTER_USE_MARKDOWN = [
"",
"Prefer element indexes or element frames from the tree when available. Use raw screenshot-derived coordinates only after checking the latest screenshot scale and window size.",
"",
"On Linux and Windows, screenshots may come from the visible desktop region for the target window bounds. If visual pixels matter, use `--restore-window` so another window does not cover the target region; if you cannot take focus, trust the tree over potentially occluded pixels.",
"On Linux, screenshots on X11, Hyprland and Sway copy the screen region under the target window, so a window covering it shows in the image; KDE returns a screenshot error instead. On Windows, screenshots come from the window itself even when it is covered, unless the window renders blank that way; then they fall back to the screen region, which can show what covers it. Trust the tree over pixels that may be covered; never add `--restore-window` just to get clean pixels.",
"",
"## App Notes",
"",
"Browsers: for Edge, Chrome, Safari, and similar browser windows, set the address/search field directly, then press Return. Do not assume raw typing went to the address bar. On Linux and Windows, use `--restore-window` when the browser is not already frontmost; on macOS this works in the background. Large tab strips may show only the active tab plus an \"inactive browser tabs omitted\" marker; treat that as intentional noise reduction and operate on the current page/address bar unless the user asked to manage tabs.",
"Browsers: for Edge, Chrome, Safari, and similar browser windows, set the address/search field directly, then press Return. Do not assume raw typing went to the address bar. On Linux and Windows, pressing Return needs the browser window focused (see Background and Foreground Input); on macOS it works in the background. Large tab strips may show only the active tab plus an \"inactive browser tabs omitted\" marker; treat that as intentional noise reduction and operate on the current page/address bar unless the user asked to manage tabs.",
"",
"For browser-hosted forms such as Gmail compose, verify the focused UI element after each field action. Page text fields can expose accessibility actions without moving DOM focus; if a click or `set-value` does not change the focused receiver, use `Tab` / `Shift+Tab` from a known focused field or window-local coordinates from a fresh screenshot. Prefer `paste-text` into the verified focused field for draft bodies, then inspect the returned state before continuing.",
"",
@@ -1214,9 +1225,9 @@ const ORCA_COMPUTER_USE_MARKDOWN = [
"- `app_not_found`: run `list-apps` and retry with the bundle ID. If the target is a web app such as Gmail, choose the desktop browser app/window that contains it; do not retry `ORCA computer ... --app Gmail` unchanged because `orca computer` app selectors refer to desktop apps, not website names.",
"- `app_blocked`: stop; the target is intentionally blocked from computer-use.",
"- `system_shortcut_blocked`: the error message says what the shortcut would do. Reach the same goal with a semantic action, such as a button or menu item, or ask the user; retry with `--allow-system-shortcut` only when the user asked for that exact shortcut.",
"- `window_not_found` / `window_stale`: run `list-windows`, choose a current selector, then rerun `get-app-state`.",
"- `background_unavailable`: for a popup or `<select>`, use `set-value` on it; otherwise retry once with `--restore-window` after telling the user it will take over their screen.",
"- `window_not_focused`: retry once with `--restore-window`; if the message says restore was already requested, stop retrying restore and bring the app forward manually or check permissions. For editable fields prefer `set-value`, then inspect before assuming keyboard input worked.",
"- `window_not_found` / `window_stale`: run `list-windows`, choose a current selector, then rerun `get-app-state`. If the app has no usable window, ask the user to open or show one; do not add `--restore-window` to observe it.",
"- `background_unavailable`: nothing was sent. For a popup or `<select>`, use `set-value` on it. Otherwise use a semantic action if one fits; if only foreground input can do it, tell the user and ask before retrying with `--restore-window`.",
"- `window_not_focused`: unless the message says input may already have been delivered (then inspect state first), nothing was sent. For editable fields use `set-value` or `type-text --element-index`; otherwise ask the user before retrying with `--restore-window`. If the message says restore was already requested, do not retry it; ask the user to bring the window forward or check permissions.",
"- `element_not_found`: index is stale; run `get-app-state` again.",
"- `unsupported_capability`: the provider or desktop environment cannot do that action; use a semantic alternative or install the missing dependency if the message names one.",
"- `action_not_supported`: inspect the element's listed actions and retry with one of those names, or use click/set-value when appropriate.",
@@ -4373,7 +4384,7 @@ export const BUNDLED_SKILL_GUIDES = [
},
{
name: "orca-computer-use",
description: "Drives the GUI of a visible local app window through `orca computer`: accessibility tree, clicks, typing, menus, dialogs, and screenshots in native apps and external browser windows (Chrome, Edge, Safari) or webviews. Prefer a programmatic path (shell, filesystem, git, HTTP, existing CLIs) whenever it can complete the task. Use only when a visible window needs GUI control those cannot reach. Do not use for Orca's embedded browser (`orca-cli`).",
description: "Drives the GUI of a local app window in the background through `orca computer`, without bringing it forward or taking the user's keyboard and pointer where the platform allows: accessibility tree, clicks, typing, menus, dialogs, and screenshots in native apps and external browser windows (Chrome, Edge, Safari) or webviews. Prefer a programmatic path (shell, filesystem, git, HTTP, existing CLIs) whenever it can complete the task. Use only when an app window needs GUI control those cannot reach. Do not use for Orca's embedded browser (`orca-cli`).",
markdown: ORCA_COMPUTER_USE_MARKDOWN,
aliases: ["computer-use"],
references: []
+4
View File
@@ -15,6 +15,9 @@ export const CORE_COMMAND_SPECS: CommandSpec[] = [
summary: 'Launch Orca and wait for the runtime to be reachable',
usage: 'orca open [--json]',
allowedFlags: [...GLOBAL_FLAGS],
notes: [
"It can bring Orca's window to the front. Agents should run it only when the user asks or `orca status --json` reports Orca is not running or still cannot connect after about 30 seconds of retries, never to retry a failed command."
],
examples: ['orca open', 'orca open --json']
},
...SERVE_COMMAND_SPECS,
@@ -202,6 +205,7 @@ export const CORE_COMMAND_SPECS: CommandSpec[] = [
usage: 'orca terminal switch [--terminal <handle>] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'terminal'],
notes: [
'It changes the terminal the user is looking at; agents should run it only when the user asked to switch.',
"With --environment, the desktop app on this machine switches to that server's terminal; a server has no window of its own."
],
examples: ['orca terminal switch --terminal term_abc123']
+1 -1
View File
@@ -56,7 +56,7 @@ export const WORKTREE_CREATE_COMMAND_SPEC: CommandSpec = {
'With --agent --json, result.launch says what started: launch.outcome.kind (structured chat or terminal), launch.outcome.handle, and launch.receipt.detail for why. An Orca host older than this returns the terminal agent handle in result.agentTerminalHandle instead.',
'Repo-defined setup hooks follow the repository setup policy; pass --setup run to force them.',
'Pass --activate when the CLI caller intentionally wants to reveal the new worktree in the app.',
'Passing --run-hooks is kept as a legacy alias for --setup run and reveals the worktree.',
'Passing --run-hooks is kept as a legacy alias for --setup run and reveals the worktree; agents should pass --setup run instead unless the user asked to switch to it.',
'Use --pr for GitHub pull requests; --gitlab-issue and --gitlab-mr write separate GitLab links. GitLab URLs must match the stored source project or remote; they cannot select a foreign project.'
],
examples: [
@@ -100,7 +100,8 @@ export async function openAgentLaunch(
}
const launch =
intent.target.kind === 'create-worktree' && preflight.mode === 'structured'
(intent.target.kind === 'create-worktree' && preflight.mode === 'structured') ||
intent.target.kind === 'create-folder-workspace'
? beginCreatedWorkspaceLaunch(runtime, intent.agent)
: undefined
let placed: Awaited<ReturnType<typeof resolveWorkspace>> | undefined
@@ -233,14 +234,14 @@ async function resolveWorkspace(
throw new Error('agent_launch_workspace_factory_required')
}
execution.onStage?.('worktree_create')
if (intent.target.kind === 'create-folder-workspace') {
if (!workspaces.createFolderWorkspace) {
throw new Error('agent_launch_workspace_factory_required')
}
const created = await workspaces.createFolderWorkspace({ create: intent.target.create })
return { ...created, startupTerminalHandle: undefined }
const createWorkspace =
intent.target.kind === 'create-folder-workspace'
? workspaces.createFolderWorkspace?.bind(workspaces)
: workspaces.createWorktree.bind(workspaces)
if (!createWorkspace) {
throw new Error('agent_launch_workspace_factory_required')
}
const created = await workspaces.createWorktree({
const created = await createWorkspace({
// Reserved fields must come from the launch intent, not a stale create payload.
create: withoutReservedAgentCreateFields(intent.target.create),
...(onWorkspaceCreated ? { onWorkspaceCreated } : {}),
@@ -123,10 +123,7 @@ export type AgentLaunchWorkspaceFactory = {
}>
/** Leave a usable workspace when chat creation has an indeterminate outcome. */
createRecoveryShell?: (worktreeId: string) => Promise<void>
/** Folders have no startup terminal; the executor launches their agent after creation. */
createFolderWorkspace?(args: {
create: Readonly<Record<string, unknown>>
}): Promise<{ worktreeId: string; workspacePath?: string; connectionId: string | null }>
createFolderWorkspace?: AgentLaunchWorkspaceFactory['createWorktree']
}
/** The options a terminal leg starts with: a caller's saved chat choice applies to it too. */
@@ -5,7 +5,7 @@ import { sessionCandidatesFromDiscoveries } from '../../ai-vault/discovery/sessi
import { discoverAiVaultSessionSources } from '../../ai-vault/discovery/session-scanner-source-discovery'
import type {
AiVaultScanOptions,
SessionFileCandidate,
SessionFileChoice,
SessionFileDiscovery
} from '../../ai-vault/session-scanner-types'
@@ -19,7 +19,7 @@ export type SessionSearchScanRoots = Omit<
export type SessionSearchDiscovery = {
/** Newest first with Codex hardlink aliases collapsed, matching session-list discovery. */
candidates: SessionFileCandidate[]
candidates: SessionFileChoice[]
discoveries: SessionFileDiscovery[]
issues: AiVaultScanIssue[]
}
@@ -0,0 +1,211 @@
import type * as TranscriptFs from '../../native-chat/transcript-filesystem/wsl-transcript-fs-access'
import { copyFile, mkdir, writeFile } from 'node:fs/promises'
import { dirname, join } from 'node:path'
import { Readable } from 'node:stream'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import { resetSessionParseCacheForTests } from '../../ai-vault/parse-cache/session-parse-cache-store'
import {
resetTranscriptConsumersForTests,
registerTranscriptConsumer
} from '../../ai-vault/transcripts/session-transcript-consumers'
import { physicalSessionCandidates } from '../../ai-vault/session-scanner-types'
import { isolatedScanRoots } from '../../ai-vault/scanner-fixtures/session-scanner-test-fixtures'
import {
discoverSessionSearchCandidates,
type SessionSearchScanRoots
} from '../discovery/session-search-scan-roots'
import { SessionSearchDirectoryListings } from '../discovery/session-search-directory-listings'
import { SessionSearchStore } from '../storage/session-search-store'
import { registerSessionSearchIndexConsumer } from './session-search-index-consumer'
import { runSessionSearchPass } from './session-search-pass'
import {
openSessionSearchIndexFile,
type SessionSearchIndexFile
} from '../storage/session-search-index-test-fixture'
const io = vi.hoisted((): { failed: string; paths: string[] } => ({ failed: '', paths: [] }))
vi.mock(
'../../native-chat/transcript-filesystem/wsl-transcript-fs-access',
async (importOriginal) => {
const actual = await importOriginal<typeof TranscriptFs>()
return {
...actual,
openTranscriptReadStream: (...args: Parameters<typeof actual.openTranscriptReadStream>) => {
io.paths.push(args[0])
if (args[0] === io.failed) {
return new Readable({
read() {
this.destroy(Object.assign(new Error('unreadable'), { code: 'EACCES' }))
}
})
}
return actual.openTranscriptReadStream(...args)
}
}
}
)
let index: SessionSearchIndexFile
let roots: SessionSearchScanRoots
let store: SessionSearchStore
let unregister: () => void
let real: string
let managed: string
beforeEach(async () => {
index = await openSessionSearchIndexFile('ss-codex-aliases')
const root = dirname(index.path)
roots = isolatedScanRoots(root)
const home = join(root, 'real')
const managedHome = join(root, 'codex-accounts', 'account', 'home')
const name = 'rollout-2026-07-01T10-00-00-019f0000-1111-7222-8333-444444444444.jsonl'
real = join(home, 'sessions', name)
managed = join(managedHome, 'sessions', name)
roots = {
...roots,
codexSessionsDir: dirname(real),
defaultCodexHomeDir: home,
additionalCodexSessionsDirs: [dirname(managed)]
}
await mkdir(dirname(real), { recursive: true })
await mkdir(dirname(managed), { recursive: true })
await writeFile(
real,
`${[
{
type: 'session_meta',
payload: { id: '019f0000-1111-7222-8333-444444444444', cwd: '/repo/app' }
},
{
type: 'response_item',
payload: {
type: 'message',
role: 'user',
content: [{ type: 'input_text', text: 'readablealiasneedle' }]
}
}
]
.map((line) => JSON.stringify(line))
.join('\n')}\n`
)
await copyFile(real, managed)
io.failed = real
io.paths = []
store = new SessionSearchStore(index.path, (error) => {
throw error
})
unregister = registerSessionSearchIndexConsumer(store)
})
afterEach(async () => {
unregister()
resetTranscriptConsumersForTests()
resetSessionParseCacheForTests()
store.close()
await index.close()
io.failed = ''
})
const sweep = () =>
runSessionSearchPass({
store,
roots,
full: true,
recentPerAgent: 100,
listings: new SessionSearchDirectoryListings(),
overdue: () => true
})
function matches() {
return index.db
.prepare("SELECT user_text FROM messages_fts WHERE messages_fts MATCH 'readablealiasneedle'")
.all()
}
it('indexes a readable alias despite a failed preferred open and an overdue pass, then reuses it after restart', async () => {
await sweep()
expect(matches()).toEqual([{ user_text: 'readablealiasneedle' }])
const discovered = await discoverSessionSearchCandidates(roots, {
limitPerAgent: Infinity
})
const candidate = discovered.candidates
.flatMap(physicalSessionCandidates)
.find((item) => item.file.path === managed)!
expect(store.coversTranscript(candidate)).toBe(true)
// Hold out the failed real path without suppressing the committed managed copy.
for (let i = 0; i < 3; i++) {
store.setFileState(real, 'failed', discovered.candidates[0].file.mtimeMs)
}
io.paths = []
await sweep()
expect(io.paths).toEqual([])
expect(matches()).toEqual([{ user_text: 'readablealiasneedle' }])
unregister()
store.close()
resetSessionParseCacheForTests()
store = new SessionSearchStore(index.path, (error) => {
throw error
})
unregister = registerSessionSearchIndexConsumer(store)
await sweep()
expect(io.paths).toEqual([])
expect(matches()).toEqual([{ user_text: 'readablealiasneedle' }])
})
it('does not try another source when a consumer drain fails', async () => {
io.failed = ''
const stop = registerTranscriptConsumer({
beginRead: () => ({
message() {},
finish() {},
drain: async () => {
throw new Error('store declined')
}
})
})
try {
await sweep()
expect(io.paths).toEqual([real])
} finally {
stop()
}
})
it('does not try another source when flushing the writer fails', async () => {
io.failed = ''
const flush = vi
.fn<() => Promise<void>>()
.mockRejectedValueOnce(new Error('flush failed'))
.mockResolvedValue(undefined)
Object.assign(store, { flush })
await sweep()
expect(io.paths).toEqual([real])
})
it('keeps a successfully excluded canonical rollout excluded on unchanged sweeps', async () => {
const body = [
{
type: 'session_meta',
payload: {
id: '019f0000-1111-7222-8333-444444444444',
source: { internal: 'guardian_review' },
cwd: '/repo/app'
}
},
{
type: 'response_item',
payload: {
type: 'message',
role: 'user',
content: [{ type: 'input_text', text: 'readablealiasneedle' }]
}
}
]
.map((line) => JSON.stringify(line))
.join('\n')
await writeFile(real, `${body}\n`)
await copyFile(real, managed)
io.failed = ''
await sweep()
expect(matches()).toEqual([])
io.paths = []
await sweep()
expect(io.paths).toEqual([])
expect(matches()).toEqual([])
})
@@ -4,7 +4,11 @@ import {
parseAgentSessionFileCached,
type SessionParseStats
} from '../../ai-vault/parse-cache/session-scanner-parse-cache'
import type { SessionFileCandidate } from '../../ai-vault/session-scanner-types'
import { isTranscriptSourceFailure } from '../../ai-vault/parsing/session-scanner-jsonl-reader'
import {
physicalSessionCandidates,
type SessionFileChoice
} from '../../ai-vault/session-scanner-types'
import { fileIdentity } from '../storage/session-search-file-cursor'
import { sessionSearchReadDecision } from './session-search-read-decision'
import type {
@@ -23,57 +27,93 @@ export type SessionSearchIndexPassOptions = {
/** Deferred candidates stay owed by their stored state; left counts backlog outside the due-row count. */
export async function runSessionSearchIndexPass(
store: SessionSearchIndexStore,
candidates: readonly SessionFileCandidate[],
candidates: readonly SessionFileChoice[],
options: SessionSearchIndexPassOptions
): Promise<{ stats: SessionParseStats; outOfTime: boolean; left: number }> {
const stats = createSessionParseStats()
const cutoffMs = store.retentionCutoff
const acceptsReads = () => store.acceptingReads !== false
let read = 0
let outOfTime = false
let left = 0
for (const candidate of candidates) {
for (const choice of candidates) {
throwIfAiVaultScanCancelled(options.signal)
const path = candidate.file.path
const row = options.rows.get(path)
const decision = sessionSearchReadDecision({
candidate,
row,
// New paths already require reading; avoid a redundant cursor query per file.
cursor: row ? store.indexedFile(path, fileIdentity(candidate.file)) : null,
cutoffMs
})
if (decision === 'skip') {
continue
if (!acceptsReads()) {
break
}
// Continue decisions after the read deadline to count remaining backlog.
outOfTime ||= read > 0 && options.overdue?.() === true
if (outOfTime) {
// Status adds left to due-row counts; exclude due rows here to avoid double counting.
if (row?.state !== 'due') {
left += 1
}
continue
}
// Deadline callbacks may close the owner; recheck cancellation before touching the store.
throwIfAiVaultScanCancelled(options.signal)
read += 1
try {
await parseAgentSessionFileCached(
candidate,
process.platform,
stats,
decision,
options.signal,
true
)
await store.flush?.()
} catch (error) {
let deferred = false
let due = false
let admitted = false
for (const candidate of physicalSessionCandidates(choice)) {
throwIfAiVaultScanCancelled(options.signal)
console.warn(
'[ai-vault-search] indexing skipped',
candidate.agent,
error instanceof Error ? error.name : 'ParseError'
)
if (!acceptsReads()) {
break
}
const path = candidate.file.path
const row = options.rows.get(path)
const cursor = row ? store.indexedFile(path, fileIdentity(candidate.file)) : null
const decision = sessionSearchReadDecision({ candidate, row, cursor, cutoffMs })
if (decision === 'skip') {
if (
(cutoffMs === null || candidate.file.mtimeMs >= cutoffMs) &&
row?.state === 'current' &&
cursor !== null
) {
deferred = false
break
}
continue
}
if (!admitted) {
outOfTime ||= read > 0 && options.overdue?.() === true
}
if (outOfTime && !admitted) {
deferred = true
due ||= row?.state === 'due'
continue
}
throwIfAiVaultScanCancelled(options.signal)
if (!acceptsReads()) {
break
}
admitted = true
read += 1
try {
await parseAgentSessionFileCached(
candidate,
process.platform,
stats,
decision,
options.signal,
true
)
} catch (error) {
throwIfAiVaultScanCancelled(options.signal)
console.warn(
'[ai-vault-search] indexing skipped',
candidate.agent,
error instanceof Error ? error.name : 'ParseError'
)
if (isTranscriptSourceFailure(error)) {
continue
}
break
}
// Consumer/store failures cannot authorize reading another physical alias.
try {
await store.flush?.()
} catch (error) {
throwIfAiVaultScanCancelled(options.signal)
console.warn(
'[ai-vault-search] indexing skipped',
candidate.agent,
error instanceof Error ? error.name : 'FlushError'
)
}
break
}
if (deferred && !due) {
left += 1
}
}
return { stats, outOfTime, left }
@@ -1,3 +1,7 @@
import {
physicalSessionCandidates,
type SessionFileChoice
} from '../../ai-vault/session-scanner-types'
import type { AiVaultScanIssue } from '../../../shared/ai-vault/contracts/ai-vault-types'
import {
cursorChatMetaRefusals,
@@ -108,7 +112,10 @@ export async function runSessionSearchPass(
roots,
// Only unlimited discovery can prove a synthetic session is absent from its container.
enumeratedContainers: args.full
? sessionSearchEnumeratedContainers(swept.candidates, issues)
? sessionSearchEnumeratedContainers(
swept.candidates.flatMap(physicalSessionCandidates),
issues
)
: undefined,
emptiedRoots: previousRootsWithFiles
? sessionSearchEmptiedRoots(previousRootsWithFiles, rootsWithFiles)
@@ -153,11 +160,13 @@ export async function runSessionSearchPass(
/** Retire undiscovered rows; recent cycles check only the newest rows under discovered roots. */
function retirementCandidates(
rows: ReadonlyMap<string, SessionSearchFileRow>,
swept: { candidates: readonly { file: { path: string } }[] },
swept: { candidates: readonly SessionFileChoice[] },
roots: readonly string[],
full: boolean
): string[] {
const discovered = new Set(swept.candidates.map((candidate) => candidate.file.path))
const discovered = new Set(
swept.candidates.flatMap(physicalSessionCandidates).map((candidate) => candidate.file.path)
)
const undiscovered = [...rows.values()].filter((row) => !discovered.has(row.path))
if (full) {
return undiscovered.map((row) => row.path)
@@ -2,7 +2,9 @@ import { prioritizeAntigravityTranscriptCandidates } from '../scanners/antigravi
import { readCodexRolloutSessionMetaId } from '../../codex/history/rollouts/codex-rollout-session-meta'
import {
codexRolloutHardlinkIdentity,
dedupeCodexRolloutAliases,
canonicalCodexRolloutKey,
codexSessionRootRank,
dedupeCodexRolloutCopyAliases,
dedupeCodexRolloutFileAliases
} from '../scanners/codex/codex-session-root-dedup'
import { antigravityHistoryPathForBrainDir } from '../scanners/antigravity/session-scanner-antigravity-paths'
@@ -12,6 +14,7 @@ import type {
AiVaultScanOptions,
FileWithMtime,
SessionFileCandidate,
SessionFileChoice,
SessionFileDiscovery
} from '../session-scanner-types'
@@ -69,17 +72,46 @@ function dropOmpCandidatesListedByPi(
export async function sessionCandidatesFromDiscoveries(
discoveries: SessionFileDiscovery[],
options: AiVaultScanOptions
): Promise<SessionFileCandidate[]> {
const candidates = await dedupeCodexRolloutAliases(
): Promise<SessionFileChoice[]> {
const physical = dropCodexHardlinkAliases(
dropOmpCandidatesListedByPi(
discoveries.flatMap((discovery) =>
discoveryFileCandidates(discovery, discovery.files, options)
)
).sort((left, right) => right.file.mtimeMs - left.file.mtimeMs),
).sort((left, right) => right.file.mtimeMs - left.file.mtimeMs)
)
const canonical = new Map<string, SessionFileCandidate[]>()
const legacy: SessionFileCandidate[] = []
for (const candidate of physical) {
const key = candidate.agent === 'codex' ? canonicalCodexRolloutKey(candidate.file.path) : null
if (key === null) {
legacy.push(candidate)
} else {
const group = canonical.get(key)
if (group) {
group.push(candidate)
} else {
canonical.set(key, [candidate])
}
}
}
const candidates: SessionFileChoice[] = await dedupeCodexRolloutCopyAliases(
legacy,
CODEX_ALIAS_ACCESSORS,
(filePath) => readCodexRolloutSessionMetaId(filePath, options.signal, 'scan'),
options.signal
)
for (const group of canonical.values()) {
group.sort(
(a, b) =>
Number(a.file.sizeBytes === 0) - Number(b.file.sizeBytes === 0) ||
codexSessionRootRank(a.codexHome) - codexSessionRootRank(b.codexHome) ||
Number(a.file.path.endsWith('.zst')) - Number(b.file.path.endsWith('.zst')) ||
(a.file.path < b.file.path ? -1 : a.file.path > b.file.path ? 1 : 0)
)
candidates.push(group.length === 1 ? group[0] : { ...group[0], alternatives: group })
}
candidates.sort((a, b) => b.file.mtimeMs - a.file.mtimeMs)
return prioritizeAntigravityTranscriptCandidates(
candidates,
(candidate) => candidate.agent === 'antigravity'
@@ -1,3 +1,10 @@
import { isTranscriptSourceFailure } from './session-scanner-jsonl-reader'
import {
physicalSessionCandidates,
type SessionFileCandidate,
type SessionFileChoice,
type SessionParseResult
} from '../session-scanner-types'
import { yieldToEventLoop } from '../../../shared/event-loop-yield'
import { isTextTaskRequestSession } from './text-task-request-sessions'
import { candidateFileTime } from '../scanners/antigravity/antigravity-transcript-candidates'
@@ -24,14 +31,13 @@ import {
sessionParseCacheKey
} from '../parse-cache/session-parse-cache-store'
import { canStopParsingSessions } from './session-scan-cutoff'
import type { SessionFileCandidate, SessionParseResult } from '../session-scanner-types'
import { throwIfAiVaultScanCancelled } from '../scan-lifecycle/ai-vault-scan-cancellation'
import { noteAiVaultScanWorkChanged } from '../scan-lifecycle/ai-vault-scan-progress'
const SESSION_PARSE_CONCURRENCY = 8
export async function parseSessionCandidates(args: {
candidates: SessionFileCandidate[]
candidates: SessionFileChoice[]
limit: number
platform: NodeJS.Platform
executionHostId: ExecutionHostId
@@ -79,44 +85,72 @@ export async function parseSessionCandidates(args: {
)
const batch = args.candidates.slice(index, index + batchSize)
index += batchSize
const verdicts = args.selectCandidate
? await Promise.all(batch.map(args.selectCandidate))
: batch.map(() => true)
throwIfAiVaultScanCancelled(args.signal)
const selected = batch.filter((_, batchIndex) => verdicts[batchIndex])
const results = await Promise.all(
selected.map((candidate) =>
parseSessionCandidate(candidate, {
platform: args.platform,
executionHostId: args.executionHostId,
parseStats: args.parseStats,
antigravityWorkspaceResolver: args.antigravityWorkspaceResolver,
listed: args.listed ?? true,
signal: args.signal
})
)
batch.map(async (choice) => {
const issues: AiVaultScanIssue[] = []
let selected = false
let provisional: { candidate: SessionFileCandidate; session: AiVaultSession } | undefined
let accepted: typeof provisional
for (const candidate of physicalSessionCandidates(choice)) {
throwIfAiVaultScanCancelled(args.signal)
if (args.selectCandidate && !(await args.selectCandidate(candidate))) {
continue
}
selected = true
const result = await parseSessionCandidate(candidate, {
platform: args.platform,
executionHostId: args.executionHostId,
parseStats: args.parseStats,
antigravityWorkspaceResolver: args.antigravityWorkspaceResolver,
listed: args.listed ?? true,
signal: args.signal
})
throwIfAiVaultScanCancelled(args.signal)
if (result.issue) {
issues.push(result.issue)
}
if (result.sourceFailed) {
continue
}
if (!result.session && result.issue) {
break
}
if (!result.session || isTextTaskRequestSession(result.session)) {
provisional = undefined
break
}
if (args.admitSession && !args.admitSession(result.session)) {
continue
}
const parsed = { candidate, session: result.session }
if (
isAiVaultSessionResumableContent(result.session) ||
isAiVaultSessionRecoverableEmpty(result.session)
) {
accepted = parsed
break
}
provisional ??= parsed
}
throwIfAiVaultScanCancelled(args.signal)
return { winner: accepted ?? provisional, selected, issues }
})
)
// Cancellation during a batch must propagate before partial results become issues or listed sessions.
throwIfAiVaultScanCancelled(args.signal)
batch.forEach((candidate, index) => args.settled?.(candidate, verdicts[index]))
noteAiVaultScanWorkChanged(args.signal)
results.forEach((result, resultIndex) => {
if (result.issue) {
recordSessionScanIssue(args.issues, result.issue)
results.forEach(({ winner, selected, issues }, index) => {
for (const issue of issues) {
recordSessionScanIssue(args.issues, issue)
}
if (
result.session &&
!isTextTaskRequestSession(result.session) &&
(!args.admitSession || args.admitSession(result.session))
) {
if (winner) {
if (args.listed === false) {
const { agent, file } = selected[resultIndex]
const { agent, file } = winner.candidate
noteSessionParseCacheListed(sessionParseCacheKey(agent, file.path))
}
sessions.add(result.session)
sessions.add(winner.session)
}
args.settled?.(batch[index], selected)
})
noteAiVaultScanWorkChanged(args.signal)
}
for (; index < args.candidates.length; index++) {
@@ -157,6 +191,7 @@ export async function parseSessionCandidate(
} catch (err) {
return {
session: null,
sourceFailed: isTranscriptSourceFailure(err),
issue: {
executionHostId,
agent: candidate.agent,
@@ -1,4 +1,5 @@
import { afterEach, expect, it, vi } from 'vitest'
import { Readable } from 'node:stream'
import { isAiVaultScanCancelledError } from '../../../shared/ai-vault/contracts/ai-vault-types'
import { consumeCompleteJsonlLines } from './session-scanner-jsonl-reader'
import { MAX_SESSION_TRANSCRIPT_RECORD_BYTES } from '../transcripts/session-transcript-record-budget'
@@ -11,16 +12,18 @@ type ChunkSource = {
const source = vi.hoisted((): ChunkSource => ({ chunks: [], closed: false, opens: 0 }))
vi.mock('../../native-chat/transcript-filesystem/wsl-transcript-fs-access', () => ({
openTranscriptReadStream: async function* () {
openTranscriptReadStream: () => {
source.opens += 1
source.closed = false
try {
for (const chunk of source.chunks) {
yield chunk
}
} finally {
source.closed = true
}
return Readable.from(
(function* () {
try {
yield* source.chunks
} finally {
source.closed = true
}
})()
)
}
}))
@@ -13,6 +13,18 @@ import {
type SkippedTranscriptRecord
} from '../transcripts/session-transcript-record-budget'
const sourceFailures = new WeakSet<object>()
export function noteTranscriptSourceFailure(error: unknown): void {
if (typeof error === 'object' && error !== null) {
sourceFailures.add(error)
}
}
export function isTranscriptSourceFailure(error: unknown): boolean {
return typeof error === 'object' && error !== null && sourceFailures.has(error)
}
const NEWLINE_BYTE = 0x0a
const CARRIAGE_RETURN_BYTE = 0x0d
@@ -72,13 +84,24 @@ export async function consumeCompleteJsonlLines(args: {
if (compressed && args.start !== 0) {
throw new Error(`compressed transcript cannot resume at byte ${args.start}: ${args.path}`)
}
const rawStream = openTranscriptReadStream(
args.path,
{ start: compressed ? 0 : args.start },
'scan',
args.signal
)
const stream = compressed ? zstdTranscriptStream(rawStream) : rawStream
let rawStream: ReturnType<typeof openTranscriptReadStream>
let stream: ReturnType<typeof zstdTranscriptStream>
try {
rawStream = openTranscriptReadStream(
args.path,
{ start: compressed ? 0 : args.start },
'scan',
args.signal
)
rawStream.on('error', noteTranscriptSourceFailure)
stream = compressed ? zstdTranscriptStream(rawStream) : rawStream
if (stream !== rawStream) {
stream.on('error', noteTranscriptSourceFailure)
}
} catch (error) {
noteTranscriptSourceFailure(error)
throw error
}
let chunkStart = args.start
try {
for await (const chunk of stream as AsyncIterable<Buffer>) {
@@ -155,6 +178,11 @@ export async function consumeCompleteJsonlLines(args: {
} catch (error) {
throwIfAiVaultScanCancelled(args.signal)
throw error
} finally {
rawStream.off('error', noteTranscriptSourceFailure)
if (stream !== rawStream) {
stream.off('error', noteTranscriptSourceFailure)
}
}
if (discarding) {
@@ -7,7 +7,7 @@ import { sessionSortTime } from '../../parsing/session-scanner-accumulator'
// Bridging and backfill expose one rollout through multiple roots; list only its canonical alias (#7521).
// Rollout names narrow candidates; inode or parsed session ID must still prove identity.
// Legacy names require content proof; canonical names encode immutable rollout identity.
const CODEX_ROLLOUT_FILE_NAME_PATTERN = /^rollout-.+\.jsonl(?:\.zst)?$/
// Hosts can scan foreign-platform paths, so handle both separators independently of node:path.
@@ -26,6 +26,25 @@ function codexLiveAliasBeats(filePath: string, bestPath: string): boolean | null
return archived === bestPath.endsWith('.zst') ? null : !archived
}
const UUID = '[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}'
const CANONICAL_ROLLOUT = new RegExp(
`^rollout-(\\d{4}-\\d{2}-\\d{2})T(\\d{2})-(\\d{2})-(\\d{2})-${UUID}(?:_${UUID})?\\.jsonl$`
)
export function canonicalCodexRolloutKey(filePath: string): string | null {
const name = codexRolloutAliasName(lastPathSegment(filePath))
const match = CANONICAL_ROLLOUT.exec(name)
if (!match) {
return null
}
const iso = `${match[1]}T${match[2]}:${match[3]}:${match[4]}.000Z`
const time = Date.parse(iso)
if (!Number.isFinite(time) || new Date(time).toISOString() !== iso) {
return null
}
return `${sessionFileExecutionNamespace(filePath)}\0${name}`
}
export function codexRolloutHardlinkIdentity(file: {
dev?: number
ino?: number
@@ -48,7 +67,7 @@ export function codexRolloutHardlinkIdentity(file: {
}
/** Prefer the real host home, then managed homes, so resume uses the canonical authentication lane. */
function codexSessionRootRank(codexHome: string | null): number {
export function codexSessionRootRank(codexHome: string | null): number {
if (codexHome === null) {
return 0
}
@@ -1,15 +1,65 @@
import { copyFile, link, mkdtemp, mkdir, rm, utimes, writeFile } from 'node:fs/promises'
import type * as TranscriptFs from '../../../native-chat/transcript-filesystem/wsl-transcript-fs-access'
import { appendFile, copyFile, link, mkdtemp, mkdir, rm, utimes, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { Readable } from 'node:stream'
import { resetSessionParseCacheForTests } from '../../parse-cache/session-parse-cache-store'
import { sessionCandidatesFromDiscoveries } from '../../discovery/session-scanner-candidates'
import { scanAiVaultSessions } from '../../session-scanner'
import { isolatedScanRoots, jsonLines } from '../../scanner-fixtures/session-scanner-test-fixtures'
const reads = vi.hoisted(
(): {
failed: string
failedSlice: string
cancel: AbortController | null
paths: string[]
} => ({
failed: '',
failedSlice: '',
cancel: null,
paths: []
})
)
vi.mock(
'../../../native-chat/transcript-filesystem/wsl-transcript-fs-access',
async (importOriginal) => {
const actual = await importOriginal<typeof TranscriptFs>()
return {
...actual,
readTranscriptSlice: async (...args: Parameters<typeof actual.readTranscriptSlice>) => {
if (args[0] === reads.failedSlice) {
throw Object.assign(new Error('unreadable'), { code: 'EACCES' })
}
return actual.readTranscriptSlice(...args)
},
openTranscriptReadStream: (...args: Parameters<typeof actual.openTranscriptReadStream>) => {
reads.paths.push(args[0])
if (args[0] === reads.failed) {
reads.cancel?.abort()
return new Readable({
read() {
this.destroy(Object.assign(new Error('unreadable'), { code: 'EACCES' }))
}
})
}
return actual.openTranscriptReadStream(...args)
}
}
}
)
let tempRoots: string[] = []
afterEach(async () => {
await Promise.all(tempRoots.map((root) => rm(root, { recursive: true, force: true })))
tempRoots = []
reads.failed = ''
reads.failedSlice = ''
reads.cancel = null
reads.paths = []
resetSessionParseCacheForTests()
})
describe('scanAiVaultSessions codex dual-root dedup', () => {
@@ -255,3 +305,150 @@ describe('scanAiVaultSessions codex dual-root dedup', () => {
)
})
})
async function canonicalCopies(nonUser = false) {
const root = await mkdtemp(join(tmpdir(), 'orca-canonical-choice-'))
tempRoots.push(root)
const realHome = join(root, 'real-home')
const managedHome = join(root, 'codex-accounts', 'account', 'home')
const name = 'rollout-2026-07-01T10-00-00-019f0000-1111-7222-8333-444444444444.jsonl'
const real = join(realHome, 'sessions', name)
const managed = join(managedHome, 'sessions', name)
await mkdir(join(realHome, 'sessions'), { recursive: true })
await mkdir(join(managedHome, 'sessions'), { recursive: true })
await writeFile(
real,
jsonLines([
{
type: 'session_meta',
payload: {
id: '019f0000-1111-7222-8333-444444444444',
cwd: '/repo/app',
...(nonUser ? { source: { internal: 'guardian_review' } } : {})
}
},
{
type: 'response_item',
payload: {
type: 'message',
role: 'user',
content: [{ type: 'input_text', text: 'readablealiasneedle' }]
}
}
])
)
await appendFile(real, '\n')
await copyFile(real, managed)
return {
real,
managed,
managedHome,
options: {
...isolatedScanRoots(root),
codexSessionsDir: join(realHome, 'sessions'),
defaultCodexHomeDir: realHome,
additionalCodexSessionsDirs: [join(managedHome, 'sessions')],
platform: 'darwin' as const,
limit: 1
}
}
}
it('falls back to a readable canonical copy with its own resume ownership, then recovers the real home', async () => {
const fixture = await canonicalCopies()
reads.failed = fixture.real
const first = await scanAiVaultSessions(fixture.options)
expect(first.sessions).toHaveLength(1)
expect(first.sessions[0]).toMatchObject({
filePath: fixture.managed,
codexHome: fixture.managedHome,
title: 'readablealiasneedle'
})
expect(first.sessions[0].resumeCommand).toContain(`CODEX_HOME='${fixture.managedHome}'`)
reads.failed = ''
const recovered = await scanAiVaultSessions(fixture.options)
expect(recovered.sessions).toHaveLength(1)
expect(recovered.sessions[0]).toMatchObject({ filePath: fixture.real, codexHome: null })
})
it('does not open an alternate after cancellation', async () => {
const fixture = await canonicalCopies()
reads.failed = fixture.real
reads.cancel = new AbortController()
await expect(
scanAiVaultSessions({ ...fixture.options, signal: reads.cancel.signal })
).rejects.toThrow()
expect(reads.paths).not.toContain(fixture.managed)
})
it('excludes a canonical non-user rollout after one physical attempt', async () => {
const fixture = await canonicalCopies(true)
expect((await scanAiVaultSessions(fixture.options)).sessions).toEqual([])
expect(reads.paths).toEqual([fixture.real])
})
it('tries readable content after a provisional filename-only row', async () => {
const fixture = await canonicalCopies()
await writeFile(fixture.real, '{}\n')
const result = await scanAiVaultSessions(fixture.options)
expect(result.sessions).toHaveLength(1)
expect(result.sessions[0]).toMatchObject({
filePath: fixture.managed,
title: 'readablealiasneedle'
})
})
it('keeps distinct canonical rollouts and execution namespaces, preferring nonempty copies', async () => {
const name = 'rollout-2026-07-01T10-00-00-019f0000-1111-7222-8333-444444444444'
const paths = [
`/real/${name}.jsonl`,
`/managed/${name}.jsonl`,
`/real/${name}_019f0000-2222-7222-8333-444444444444.jsonl`,
`\\\\wsl.localhost\\Ubuntu\\home\\neil\\${name}.jsonl`
]
const candidates = await sessionCandidatesFromDiscoveries(
[
{
agent: 'codex',
rootDir: '/real',
files: paths.map((path, i) => ({
path,
mtimeMs: 1,
modifiedAt: '2026-07-01T10:00:00Z',
sizeBytes: i === 0 ? 0 : 100
}))
}
],
{}
)
expect(candidates.map((choice) => choice.file.path).sort()).toEqual(paths.slice(1).sort())
expect(
candidates
.find((choice) => choice.file.path === paths[1])
?.alternatives?.map((candidate) => candidate.file.path)
).toEqual([paths[1], paths[0]])
})
it('falls back when a growing cached preferred file fails its resume boundary read', async () => {
const fixture = await canonicalCopies()
expect((await scanAiVaultSessions(fixture.options)).sessions[0].filePath).toBe(fixture.real)
await appendFile(
fixture.real,
jsonLines([
{
type: 'response_item',
payload: {
type: 'message',
role: 'user',
content: [{ type: 'input_text', text: 'newaliasmessage' }]
}
}
])
)
await appendFile(fixture.real, '\n')
await copyFile(fixture.real, fixture.managed)
reads.failedSlice = fixture.real
const result = await scanAiVaultSessions(fixture.options)
expect(result.sessions).toHaveLength(1)
expect(result.sessions[0]).toMatchObject({ filePath: fixture.managed, messageCount: 2 })
})
@@ -88,6 +88,17 @@ export type SessionFileCandidate = {
antigravityHistoryPath?: string
}
// Pass-local wrapper; only its original physical candidates may reach parsers or IPC.
export type SessionFileChoice = SessionFileCandidate & {
alternatives?: readonly SessionFileCandidate[]
}
export function physicalSessionCandidates(
choice: SessionFileChoice
): readonly SessionFileCandidate[] {
return choice.alternatives ?? [choice]
}
export type SessionFileDiscovery = {
agent: AiVaultAgent
rootDir: string
@@ -99,6 +110,7 @@ export type SessionFileDiscovery = {
}
export type SessionParseResult = {
sourceFailed?: boolean
session: AiVaultSession | null
issue: AiVaultScanIssue | null
}
@@ -6,7 +6,10 @@ import { createHash } from 'node:crypto'
import { readTranscriptSlice } from '../../native-chat/transcript-filesystem/wsl-transcript-fs-access'
import type { AiVaultSession } from '../../../shared/ai-vault/contracts/ai-vault-types'
import { parseAgentSessionFile } from '../parsing/session-scanner-agent-parser'
import { consumeCompleteJsonlLines } from '../parsing/session-scanner-jsonl-reader'
import {
consumeCompleteJsonlLines,
noteTranscriptSourceFailure
} from '../parsing/session-scanner-jsonl-reader'
import { isZstdTranscriptPath } from '../../native-chat/transcript-streaming/transcript-zstd-decompression'
import type { ResumableSessionParseState, SessionFileCandidate } from '../session-scanner-types'
import {
@@ -241,9 +244,14 @@ async function endsWithNewlineAt(
offset: number,
signal?: AbortSignal
): Promise<boolean> {
const slice = await waitForAiVaultScan(
readTranscriptSlice(path, offset - 1, 1, 'scan', signal),
signal
)
return slice.length === 1 && slice[0] === NEWLINE_BYTE
try {
const slice = await waitForAiVaultScan(
readTranscriptSlice(path, offset - 1, 1, 'scan', signal),
signal
)
return slice.length === 1 && slice[0] === NEWLINE_BYTE
} catch (error) {
noteTranscriptSourceFailure(error)
throw error
}
}
@@ -20,7 +20,8 @@ vi.mock('fs', () => ({
vi.mock('os', () => ({ platform: () => 'darwin', arch: () => 'arm64' }))
vi.mock('electron', () => ({
app: { getPath: vi.fn(() => '/app'), getAppPath: vi.fn(() => '/project'), isPackaged: false },
webContents: { fromId: webContentsFromIdMock }
webContents: { fromId: webContentsFromIdMock },
BrowserWindow: { fromWebContents: () => null }
}))
const { CdpWsProxyMock } = vi.hoisted(() => {
const instances: unknown[] = []
@@ -1,3 +1,4 @@
import { BrowserWindow } from 'electron'
import type { ResolvedBrowserCommandTarget } from '../session-routing/agent-browser-command-target'
import { BrowserError } from '../../browser-error'
import { normalizeCdpPointerButton } from '../../cdp/input/cdp-pointer-targets'
@@ -91,17 +92,20 @@ export abstract class AgentBrowserBridgePointerCommands extends AgentBrowserBrid
)
}
// Guard embedded-guest presses to restore user focus; emulation prevents page blur.
// Offscreen pages need native focus for caret and IME input.
// Emulation keeps the page focused for the agent without taking the user's keyboard.
protected async focusPageForPress(
wc: Electron.WebContents,
webContentsId: number
): Promise<() => void> {
await enablePageFocusEmulation((method, params) => wc.debugger.sendCommand(method, params))
if (!isEmbeddedGuest(wc)) {
wc.focus()
// Native focus activates and raises a popup or serve window, so only use it inside a focused one.
// fromWebContents also returns the BaseWindow that hosts a popup's view.
if (BrowserWindow.fromWebContents(wc)?.isFocused()) {
wc.focus()
}
return () => {}
}
await enablePageFocusEmulation((method, params) => wc.debugger.sendCommand(method, params))
return await this.browserManager.guardUserFocusForAgentInput(webContentsId)
}
@@ -0,0 +1,109 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
const { execFileMock, webContentsFromIdMock, existsSyncMock, readFileSyncMock, stdinWrites } =
vi.hoisted(() => {
const stdinWrites: string[] = []
return {
execFileMock: vi.fn(),
webContentsFromIdMock: vi.fn(),
existsSyncMock: vi.fn(() => false),
readFileSyncMock: vi.fn(() => Buffer.from('')),
stdinWrites
}
})
vi.mock('child_process', () => ({ execFile: execFileMock }))
vi.mock('fs', () => ({
existsSync: existsSyncMock,
readFileSync: readFileSyncMock,
accessSync: vi.fn(),
chmodSync: vi.fn(),
constants: { X_OK: 1 }
}))
vi.mock('os', () => ({ platform: () => 'darwin', arch: () => 'arm64' }))
vi.mock('electron', () => {
return {
app: { getPath: vi.fn(() => '/app'), getAppPath: vi.fn(() => '/project'), isPackaged: false },
webContents: { fromId: webContentsFromIdMock },
// The popup's own window is not the focused window.
BrowserWindow: { fromWebContents: () => ({ isFocused: () => false }) }
}
})
const { CdpWsProxyMock } = vi.hoisted(() => {
const instances: unknown[] = []
const MockClass = vi.fn().mockImplementation(function (
this: Record<string, unknown>,
_wc: unknown
) {
this._wc = _wc
this.start = vi.fn(async () => 'ws://127.0.0.1:9222')
this.stop = vi.fn(async () => {})
this.getPort = vi.fn(() => 9222)
instances.push(this)
})
return { CdpWsProxyMock: Object.assign(MockClass, { instances }) }
})
vi.mock('../../cdp/transport/cdp-ws-proxy', () => ({
CdpWsProxy: CdpWsProxyMock
}))
import { AgentBrowserBridge } from '../agent-browser-bridge'
import {
mockBrowserManager,
mockWebContents,
overrideBridgeWebContentsLookup,
resetAgentBrowserBridgeMocks,
type MockWebContents
} from '../testing/agent-browser-bridge-test-harness'
overrideBridgeWebContentsLookup(AgentBrowserBridge.prototype, webContentsFromIdMock)
describe('AgentBrowserBridge presses on pages outside an Orca pane', () => {
let bridge: AgentBrowserBridge
let wc: MockWebContents
beforeEach(() => {
resetAgentBrowserBridgeMocks({
webContentsFromIdMock,
existsSyncMock,
readFileSyncMock,
stdinWrites,
cdpWsProxyInstances: CdpWsProxyMock.instances
})
bridge = new AgentBrowserBridge(mockBrowserManager())
bridge.setActiveTab(100)
wc = mockWebContents(100)
wc.debugger.sendCommand.mockImplementation(async (method: string) =>
method === 'Runtime.evaluate'
? { result: { value: { x: 10, y: 20, adjusted: false, handled: false } } }
: {}
)
webContentsFromIdMock.mockImplementation((id: number) => (id === 100 ? wc : null))
})
function sentBeforePress(): string[] {
const methods = wc.debugger.sendCommand.mock.calls.map(([method, params]) =>
method === 'Input.dispatchMouseEvent' &&
typeof params === 'object' &&
params !== null &&
'type' in params
? `${method}:${String(params.type)}`
: method
)
return methods.slice(0, methods.indexOf('Input.dispatchMouseEvent:mousePressed'))
}
it.each([
['click', () => bridge.mouseClick(10, 20, 'left', undefined, 'tab-1')],
['mouse down', () => bridge.mouseDown('left', undefined, 'tab-1')]
])(
'%s on a popup whose window is not focused emulates focus instead of taking it',
async (_name, press) => {
await press()
expect(wc.focus).not.toHaveBeenCalled()
expect(sentBeforePress()).toContain('Emulation.setFocusEmulationEnabled')
}
)
})
@@ -66,54 +66,10 @@ function fakeHost(
},
admit: vi.fn()
}
const emit = (event: BrowserPageEvent): void => pageEvents.forEach((listener) => listener(event))
return { host, tabs, emit }
return { host }
}
describe('runWithActionEffects', () => {
it('waits for a navigation the action started and reports where it landed', async () => {
const guest = fakeGuest()
const appTree = '- heading "Welcome ada" [level=1, ref=e9]\n- button "Submit ticket" [ref=e10]'
const { host } = fakeHost(guest, [UNCHANGED, { snapshot: { kind: 'full', tree: appTree } }])
const result = await runWithActionEffects(host, async () => {
guest.emit('did-start-navigation', { isMainFrame: true, isSameDocument: false })
// A redirecting submit replaces its first navigation, which then fails as ERR_ABORTED.
guest.emit('did-start-navigation', { isMainFrame: true, isSameDocument: false })
guest.emit('did-fail-load', {}, -3, 'ERR_ABORTED', 'http://127.0.0.1/submit', true)
setTimeout(() => {
guest.url = 'http://127.0.0.1/app.html'
guest.title = 'App'
guest.emit('dom-ready')
}, 120)
return { clicked: '@e6' }
})
expect(readBrowserActionEffects(result)).toEqual({
settled: true,
url: 'http://127.0.0.1/app.html',
title: 'App',
page: { lines: appTree.split('\n'), removedLines: 0, full: true, truncated: false }
})
})
it('reports a new tab once it opens and a child window separately', async () => {
const guest = fakeGuest()
const { host, tabs, emit } = fakeHost(guest, [UNCHANGED, UNCHANGED])
const result = await runWithActionEffects(host, async () => {
emit({ kind: 'popup', action: 'window', origin: 'https://accounts.example' })
emit({ kind: 'tab', url: 'http://127.0.0.1/basic2.html' })
setTimeout(() => tabs.set('page-2', 'http://127.0.0.1/basic2.html'), 100)
return { clicked: '@e18' }
})
expect(readBrowserActionEffects(result)).toMatchObject({
newTabs: [{ browserPageId: 'page-2', url: 'http://127.0.0.1/basic2.html' }],
popups: [{ action: 'window', origin: 'https://accounts.example' }]
})
})
it('does not read a replacement guest the caller was never admitted to', async () => {
const guest = fakeGuest()
const replacement = Object.assign(fakeGuest('http://127.0.0.1/app.html', 'App'), { id: 8 })
@@ -1,371 +0,0 @@
import { guestNavigationMethods } from '../../guest-lifecycle/manager/browser-manager-test-harness'
import type * as WorktreeScanCacheModule from '../../../git/worktrees/catalog/worktree-scan-cache'
import type * as WorktreeListingModule from '../../../git/worktrees/catalog/worktree-listing'
import { mkdtempSync } from 'node:fs'
import { RuntimeBrowserCommands } from '../../../runtime/browser/commands/browser-commands'
import { setRuntimeBrowserCommandsFactory } from '../../../runtime/browser/installation/runtime-browser-commands-factory'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { createConnection } from 'node:net'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { BrowserManager } from '../../guest-lifecycle/manager/browser-manager'
import { CdpBridge } from './cdp-bridge'
import { OrcaRuntimeService } from '../../../runtime/orca-runtime'
import { OrcaRuntimeRpcServer } from '../../../runtime/rpc/server/runtime-rpc'
import { readRuntimeMetadata } from '../../../runtime/rpc/server/runtime-metadata'
const { webContentsFromIdMock } = vi.hoisted(() => ({
webContentsFromIdMock: vi.fn()
}))
vi.mock('../../../git/worktrees/catalog/worktree-scan-cache', async (importOriginal) => {
const facade = getWorktreeListingMocks()
return {
...(await importOriginal<typeof WorktreeScanCacheModule>()),
listWorktrees: facade.listWorktrees
}
})
vi.mock('../../../git/worktrees/catalog/worktree-listing', async (importOriginal) => {
const facade = getWorktreeListingMocks()
return {
...(await importOriginal<typeof WorktreeListingModule>()),
listWorktreesStrict: facade.listWorktreesStrict
}
})
vi.mock('electron', () => ({
webContents: { fromId: webContentsFromIdMock },
shell: { openExternal: vi.fn() },
ipcMain: { handle: vi.fn(), removeHandler: vi.fn(), on: vi.fn() },
app: { getPath: vi.fn(() => '/tmp'), isPackaged: false }
}))
const { getWorktreeListingMocks } = vi.hoisted(() => {
const createWorktreeListingMocks = () => ({
listWorktrees: vi.fn().mockResolvedValue([]),
listWorktreesStrict: vi.fn().mockResolvedValue([])
})
let listings: ReturnType<typeof createWorktreeListingMocks> | undefined
return { getWorktreeListingMocks: () => (listings ??= createWorktreeListingMocks()) }
})
type AXNode = {
nodeId: string
backendDOMNodeId?: number
role?: { type: string; value: string }
name?: { type: string; value: string }
properties?: { name: string; value: { type: string; value: unknown } }[]
childIds?: string[]
ignored?: boolean
}
function axNode(
id: string,
role: string,
name: string,
opts?: { childIds?: string[]; backendDOMNodeId?: number }
): AXNode {
return {
nodeId: id,
backendDOMNodeId: opts?.backendDOMNodeId ?? Number.parseInt(id, 10) * 100,
role: { type: 'role', value: role },
name: { type: 'computedString', value: name },
childIds: opts?.childIds
}
}
const EXAMPLE_COM_TREE: AXNode[] = [
axNode('1', 'WebArea', 'Example Domain', { childIds: ['2', '3', '4'] }),
axNode('2', 'heading', 'Example Domain'),
axNode('3', 'staticText', 'This domain is for use in illustrative examples.'),
axNode('4', 'link', 'More information...', { backendDOMNodeId: 400 })
]
const SEARCH_PAGE_TREE: AXNode[] = [
axNode('1', 'WebArea', 'Search', { childIds: ['2', '3', '4', '5'] }),
axNode('2', 'navigation', 'Main Nav', { childIds: ['3'] }),
axNode('3', 'link', 'Home', { backendDOMNodeId: 300 }),
axNode('4', 'textbox', 'Search query', { backendDOMNodeId: 400 }),
axNode('5', 'button', 'Search', { backendDOMNodeId: 500 })
]
function createMockGuest(
id: number,
url: string,
title: string,
options?: { readyState?: string | (() => string) }
) {
let currentUrl = url
let currentTitle = title
let currentTree = EXAMPLE_COM_TREE
let navHistoryId = 1
let debuggerAttached = false
const sendCommandMock = vi.fn(async (method: string, params?: Record<string, unknown>) => {
switch (method) {
case 'Page.enable':
case 'DOM.enable':
case 'Accessibility.enable':
return {}
case 'Accessibility.getFullAXTree':
return { nodes: currentTree }
case 'Page.getNavigationHistory':
return {
entries: [{ id: navHistoryId, url: currentUrl }],
currentIndex: 0
}
case 'Page.navigate': {
const targetUrl = (params as { url: string }).url
if (targetUrl.includes('nonexistent.invalid')) {
return { errorText: 'net::ERR_NAME_NOT_RESOLVED' }
}
navHistoryId++
currentUrl = targetUrl
if (targetUrl.includes('search.example.com')) {
currentTitle = 'Search'
currentTree = SEARCH_PAGE_TREE
} else {
currentTitle = 'Example Domain'
currentTree = EXAMPLE_COM_TREE
}
return {}
}
case 'Runtime.evaluate': {
const expr = (params as { expression: string }).expression
if (expr === 'document.readyState') {
return {
result: {
value:
typeof options?.readyState === 'function'
? options.readyState()
: (options?.readyState ?? 'complete')
}
}
}
if (expr === 'location.origin') {
return { result: { value: new URL(currentUrl).origin } }
}
if (expr.includes('innerWidth')) {
return { result: { value: JSON.stringify({ w: 1280, h: 720 }) } }
}
if (expr.includes('scrollBy')) {
return { result: { value: undefined } }
}
if (expr.includes('dispatchEvent')) {
return { result: { value: undefined } }
}
// eslint-disable-next-line no-eval
return { result: { value: String(eval(expr)), type: 'string' } }
}
case 'DOM.scrollIntoViewIfNeeded':
return {}
case 'DOM.getBoxModel':
return { model: { content: [100, 200, 300, 200, 300, 250, 100, 250] } }
case 'Input.dispatchMouseEvent':
return {}
case 'Input.insertText':
return {}
case 'Input.dispatchKeyEvent':
return {}
case 'DOM.focus':
return {}
case 'DOM.describeNode':
return { node: { nodeId: 1 } }
case 'DOM.requestNode':
return { nodeId: 1 }
case 'DOM.resolveNode':
return { object: { objectId: 'obj-1' } }
case 'Runtime.callFunctionOn':
return { result: { value: undefined } }
case 'DOM.setFileInputFiles':
return {}
case 'Page.captureScreenshot':
return {
data: 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=='
}
case 'Page.reload':
return {}
case 'Network.enable':
return {}
case 'Target.setAutoAttach':
return {}
case 'Page.addScriptToEvaluateOnNewDocument':
return { identifier: 'mock-script-id' }
case 'Runtime.enable':
return {}
default:
throw new Error(`Unexpected CDP method: ${method}`)
}
})
const debuggerListeners = new Map<string, ((...args: unknown[]) => void)[]>()
const guest = {
...guestNavigationMethods(),
id,
isDestroyed: vi.fn(() => false),
isCurrentlyAudible: vi.fn(() => false),
setIgnoreMenuShortcuts: vi.fn(),
getType: vi.fn(() => 'webview'),
getURL: vi.fn(() => currentUrl),
getTitle: vi.fn(() => currentTitle),
setBackgroundThrottling: vi.fn(),
setWindowOpenHandler: vi.fn(),
on: vi.fn(),
off: vi.fn(),
debugger: {
isAttached: vi.fn(() => debuggerAttached),
attach: vi.fn(() => {
if (debuggerAttached) {
throw new Error('Another debugger is already attached')
}
debuggerAttached = true
}),
detach: vi.fn(),
sendCommand: sendCommandMock,
on: vi.fn((event: string, handler: (...args: unknown[]) => void) => {
const handlers = debuggerListeners.get(event) ?? []
handlers.push(handler)
debuggerListeners.set(event, handlers)
}),
removeListener: vi.fn((event: string, handler: (...args: unknown[]) => void) => {
const handlers = debuggerListeners.get(event) ?? []
const idx = handlers.indexOf(handler)
if (idx !== -1) {
handlers.splice(idx, 1)
}
}),
removeAllListeners: vi.fn((event: string) => {
debuggerListeners.set(event, [])
}),
off: vi.fn()
}
}
return {
guest,
sendCommandMock,
setTree(tree: AXNode[]) {
currentTree = tree
},
emitDebugger(event: string, ...args: unknown[]) {
for (const handler of debuggerListeners.get(event) ?? []) {
handler(...args)
}
},
emitDebuggerMessage(method: string, params?: Record<string, unknown>) {
for (const handler of debuggerListeners.get('message') ?? []) {
handler({}, method, params)
}
}
}
}
async function sendRequest(
endpoint: string,
request: Record<string, unknown>
): Promise<Record<string, unknown>> {
return await new Promise((resolve, reject) => {
const socket = createConnection(endpoint)
let buffer = ''
socket.setEncoding('utf8')
socket.once('error', reject)
socket.on('data', (chunk: string) => {
buffer += chunk
const newlineIndex = buffer.indexOf('\n')
if (newlineIndex === -1) {
return
}
const message = buffer.slice(0, newlineIndex)
socket.end()
resolve(JSON.parse(message) as Record<string, unknown>)
})
socket.on('connect', () => {
socket.write(`${JSON.stringify(request)}\n`)
})
})
}
describe('Browser automation pipeline (integration)', () => {
let server: OrcaRuntimeRpcServer
let endpoint: string
let authToken: string
let activeGuestHarness: ReturnType<typeof createMockGuest>
let cdpBridge: CdpBridge
const GUEST_WC_ID = 5001
const RENDERER_WC_ID = 1
beforeEach(async () => {
// The Electron entry installs this factory; Node hosts omit browser commands.
setRuntimeBrowserCommandsFactory((host) => new RuntimeBrowserCommands(host))
activeGuestHarness = createMockGuest(GUEST_WC_ID, 'https://example.com', 'Example Domain')
const { guest } = activeGuestHarness
webContentsFromIdMock.mockImplementation((id: number) => {
if (id === GUEST_WC_ID) {
return guest
}
return null
})
const browserManager = new BrowserManager()
// Production applies these policies in will-attach-webview.
browserManager.attachGuestPolicies(guest as never)
browserManager.registerGuest({
browserPageId: 'page-1',
webContentsId: GUEST_WC_ID,
rendererWebContentsId: RENDERER_WC_ID
})
cdpBridge = new CdpBridge(browserManager)
cdpBridge.setActiveTab(GUEST_WC_ID)
const userDataPath = mkdtempSync(join(tmpdir(), 'browser-e2e-'))
const runtime = new OrcaRuntimeService()
// eslint-disable-next-line @typescript-eslint/no-explicit-any
runtime.setAgentBrowserBridge(cdpBridge as any)
server = new OrcaRuntimeRpcServer({ runtime, userDataPath })
await server.start()
const metadata = readRuntimeMetadata(userDataPath)!
endpoint = metadata.transports[0]!.endpoint
authToken = metadata.authToken!
})
afterEach(async () => {
await server.stop()
})
async function rpc(method: string, params?: Record<string, unknown>) {
const response = await sendRequest(endpoint, {
id: `req_${method}`,
authToken,
method,
...(params ? { params } : {})
})
return response
}
it('navigates to a URL and invalidates refs', async () => {
await rpc('browser.snapshot')
const gotoRes = await rpc('browser.goto', { url: 'https://search.example.com' })
expect(gotoRes.ok).toBe(true)
const gotoResult = gotoRes.result as { url: string; title: string }
expect(gotoResult.url).toBe('https://search.example.com')
expect(gotoResult.title).toBe('Search')
const clickRes = await rpc('browser.click', { element: '@e1' })
expect(clickRes.ok).toBe(false)
expect((clickRes.error as { code: string }).code).toBe('browser_stale_ref')
const snapRes = await rpc('browser.snapshot')
expect(snapRes.ok).toBe(true)
const snapResult = snapRes.result as { snapshot: string; refs: { name: string }[] }
expect(snapResult.snapshot).toContain('Search')
expect(snapResult.refs.map((r) => r.name)).toContain('Search')
expect(snapResult.refs.map((r) => r.name)).toContain('Home')
})
})
@@ -7,17 +7,6 @@ describe('CdpPdfStreamStore', () => {
vi.useRealTimers()
})
it('claims only its own handles', () => {
const store = new CdpPdfStreamStore()
const handle = store.create(Buffer.from('pdf'))
expect(store.ownsHandle({ handle })).toBe(true)
expect(handle).toMatch(/^orca-pdf-[\da-f-]{36}-\d+$/)
expect(store.ownsHandle({ handle: 'trace-stream' })).toBe(false)
expect(store.ownsHandle({ handle: 'orca-pdf-forged-1' })).toBe(false)
expect(store.ownsHandle({})).toBe(false)
})
it('reads sequential chunks and reports eof', () => {
const store = new CdpPdfStreamStore()
const handle = store.create(Buffer.from('abcdef'))
@@ -32,23 +21,6 @@ describe('CdpPdfStreamStore', () => {
})
})
it('honors an explicit read offset', () => {
const store = new CdpPdfStreamStore()
const handle = store.create(Buffer.from('abcdef'))
expect(store.read({ handle, offset: 4 })).toEqual({
data: Buffer.from('ef').toString('base64'),
eof: true
})
})
it('returns an empty eof chunk when reading past the end', () => {
const store = new CdpPdfStreamStore()
const handle = store.create(Buffer.from('abc'))
expect(store.read({ handle, offset: 99 })).toEqual({ data: '', eof: true })
})
it('evicts an abandoned stream after the TTL', () => {
vi.useFakeTimers()
const store = new CdpPdfStreamStore()
@@ -1,11 +0,0 @@
import { describe, expect, it } from 'vitest'
import { splitBrowserTextInsertionChunks } from '../../../../shared/browser-text-insertion-chunks'
describe('browser text insertion chunking', () => {
it('keeps a single multibyte character intact when the byte cap is smaller', () => {
const chunks = splitBrowserTextInsertionChunks('😀a', 1)
expect(chunks).toEqual(['😀', 'a'])
expect(chunks.join('')).toBe('😀a')
})
})
@@ -116,6 +116,10 @@ export class CdpPageInputCommands {
return await dispatchPageKeyEvent(this.webContents, params, this.sendRoot, this.guardFocus)
}
if (!isEmbeddedGuest(this.webContents)) {
// Popup and serve pages never borrow native focus, so emulate it as the bridge does.
if (isFocusTakingCdpInput(method, params)) {
await enablePageFocusEmulation(this.sendRoot)
}
return await this.debuggerChannel.sendDebuggerCommand(method, params, sessionId)
}
const send = (): Promise<unknown> =>
@@ -1,63 +0,0 @@
import { Buffer } from 'node:buffer'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { startBrowserScreencast } from './browser-screencast-stream'
import type { BrowserScreencastOptions } from './browser-screencast-stream-types'
import { createMockScreencastWebContents } from './browser-screencast-web-contents-test-double'
function start(
webContents: ReturnType<typeof createMockScreencastWebContents>,
options: BrowserScreencastOptions
) {
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the stream touches only the debugger, isDestroyed and capturePage the doubles provide.
return startBrowserScreencast(webContents as never, options)
}
function startOptions(viewport: boolean) {
return {
format: 'jpeg' as const,
quality: 70,
maxWidth: 1440,
maxHeight: 1200,
everyNthFrame: 2,
minFrameIntervalMs: 0,
holdPaint: () => () => {},
...(viewport ? { viewportWidth: 390, viewportHeight: 844 } : {}),
onFrame: vi.fn(() => true),
onError: vi.fn()
}
}
describe('browser screencast first-frame deadline', () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
it('drops a capture that settles after the limit', async () => {
let settleCapture!: (image: unknown) => void
const webContents = Object.assign(createMockScreencastWebContents(), {
capturePage: vi.fn(
() =>
new Promise((resolve) => {
settleCapture = resolve
})
)
})
const options = startOptions(true)
const session = await start(webContents, options)
await vi.advanceTimersByTimeAsync(10_000)
settleCapture({
getSize: () => ({ width: 390, height: 844 }),
toJPEG: () => Buffer.from('late'),
toPNG: () => Buffer.from('late')
})
await vi.advanceTimersByTimeAsync(1_000)
expect(options.onFrame).not.toHaveBeenCalled()
session.stop()
await session.done
})
})
@@ -1,8 +1,6 @@
import { Buffer } from 'node:buffer'
import { describe, expect, it, vi } from 'vitest'
import { decodeBrowserScreencastFrame } from '../../../../shared/browser/screencast/browser-screencast-protocol'
import { startBrowserScreencast } from './browser-screencast-stream'
import { createMockScreencastWebContents } from './browser-screencast-web-contents-test-double'
@@ -76,55 +74,6 @@ async function startOversizedStream(guest: ReturnType<typeof createGuest>, onFra
}
describe('screencast of a guest smaller than the requested viewport (#30341)', () => {
it('re-emulates at the size the guest can paint and labels frames with it', async () => {
const guest = createGuest('webview')
const onFrame = vi.fn()
const session = await startOversizedStream(guest, onFrame)
await vi.waitFor(() =>
expect(guest.debugger.sendCommand).toHaveBeenCalledWith(
'Page.captureScreenshot',
expect.anything()
)
)
expect(emulatedSizes(guest)).toEqual(['1833x1179'])
emitGuestFrame(guest, 1, { width: 1463, height: 849 })
await vi.waitFor(() => expect(emulatedSizes(guest)).toEqual(['1833x1179', '1463x849']))
expect(guest.debugger.sendCommand).toHaveBeenCalledWith('Page.screencastFrameAck', {
sessionId: 1
})
expect(onFrame).not.toHaveBeenCalled()
emitGuestFrame(guest, 2, { width: 1463, height: 849 })
await vi.waitFor(() => expect(onFrame).toHaveBeenCalledTimes(1))
expect(decodeBrowserScreencastFrame(onFrame.mock.calls[0][0])?.metadata).toMatchObject({
deviceWidth: 1463,
deviceHeight: 849
})
session.stop()
await session.done
})
it('gives the requested size back once the guest grows, and follows a new request', async () => {
const guest = createGuest('webview')
const session = await startOversizedStream(guest)
emitGuestFrame(guest, 1, { width: 1463, height: 849 })
await vi.waitFor(() => expect(emulatedSizes(guest)).toHaveLength(2))
emitGuestFrame(guest, 2, { width: 2000, height: 1300 })
await vi.waitFor(() => expect(emulatedSizes(guest).at(-1)).toBe('1833x1179'))
await session.updateViewport({ viewportWidth: 1028, viewportHeight: 1179 })
expect(emulatedSizes(guest).at(-1)).toBe('1028x1179')
emitGuestFrame(guest, 3, { width: 1463, height: 849 })
await vi.waitFor(() => expect(emulatedSizes(guest).at(-1)).toBe('1028x849'))
session.stop()
await session.done
})
it('keeps dropping guest frames until the fitted size is actually emulated', async () => {
const guest = createGuest('webview')
let releaseSnapshot!: () => void
@@ -183,20 +132,4 @@ describe('screencast of a guest smaller than the requested viewport (#30341)', (
session.stop()
})
it('keeps the full request for a window guest, which can grow to it', async () => {
const guest = createGuest('window')
const session = await startOversizedStream(guest)
emitGuestFrame(guest, 1, { width: 1463, height: 849 })
await vi.waitFor(() =>
expect(guest.debugger.sendCommand).toHaveBeenCalledWith('Page.screencastFrameAck', {
sessionId: 1
})
)
expect(emulatedSizes(guest)).toEqual(['1833x1179'])
session.stop()
await session.done
})
})
@@ -142,15 +142,6 @@ describe('BrowserCertificateTrustController', () => {
controller.installSessionRequestGuard(browserSession)
})
it('keeps challenge ownership and expiry when reading a specific guest', () => {
certificateEvent({ controller, guest })
expect(controller.hasFailureForGuest(guest.id)).toBe(true)
expect(controller.hasFailureForGuest(otherGuest.id)).toBe(false)
now += 300_000
expect(controller.hasFailureForGuest(guest.id)).toBe(false)
expect(controller.proceed('page-1', 'challenge-1')).toEqual({ ok: false, reason: 'missing' })
})
it('blocks a session-cached certificate for sibling requests until that guest approves it', () => {
certificateEvent({ controller, guest })
expect(controller.proceed('page-1', 'challenge-1')).toEqual({ ok: true })
@@ -1,4 +1,4 @@
import { describe, expect, it, vi } from 'vitest'
import { expect, it, vi } from 'vitest'
import type { BrowserClientHostCommandEvent } from '../../../../shared/browser/client-hosting/browser-client-host-protocol'
import { BrowserClientPageCommandExecutor } from '../execution/browser-client-page-command-executor'
import {
@@ -11,21 +11,17 @@ import type { BrowserRouteElectronSession } from '../../routing/sessions/browser
import type { BrowserRoutePageGuestIdentity } from '../../routing/guest-authority/browser-route-page-authority'
const SSH_HOST_STORAGE = 'storage-ssh-target-a'
const OTHER_HOST_STORAGE = 'storage-ssh-target-b'
const GENERATION_1 = 'execution-host-ssh-a-gen-1'
const GENERATION_2 = 'execution-host-ssh-a-gen-2'
const OTHER_HOST = 'execution-host-ssh-b-gen-1'
const proxyPortByKey: Record<string, number> = {
[GENERATION_1]: 43123,
[GENERATION_2]: 43124,
[OTHER_HOST]: 43125
[GENERATION_2]: 43124
}
const storageIdentityByKey: Record<string, string> = {
[GENERATION_1]: SSH_HOST_STORAGE,
[GENERATION_2]: SSH_HOST_STORAGE,
[OTHER_HOST]: OTHER_HOST_STORAGE
[GENERATION_2]: SSH_HOST_STORAGE
}
function createHarness(options: { failedRouteRelease?: string } = {}) {
@@ -142,52 +138,6 @@ function createPage(
)
}
describe('client-hosted pages across an execution host generation change', () => {
it('retires the superseded pages so the reconnected route can bind the partition', async () => {
const harness = createHarness()
expect(await createPage(harness.executor, 'page-a', GENERATION_1)).toEqual({
status: 'completed'
})
expect(await createPage(harness.executor, 'page-b', GENERATION_2)).toEqual({
status: 'completed'
})
expect(harness.executor.hasPage('page-a', 7)).toBe(false)
expect(harness.executor.hasPage('page-b', 7)).toBe(true)
expect(harness.releasedRoutes).toEqual([GENERATION_1])
expect(harness.proxyRulesFor(SSH_HOST_STORAGE)).toBe('socks5://127.0.0.1:43124')
})
it('keeps the sibling pages that share the current route key', async () => {
const harness = createHarness()
await createPage(harness.executor, 'page-a', GENERATION_1)
expect(await createPage(harness.executor, 'page-b', GENERATION_1)).toEqual({
status: 'completed'
})
expect(harness.executor.hasPage('page-a', 7)).toBe(true)
expect(harness.executor.hasPage('page-b', 7)).toBe(true)
expect(harness.releasedRoutes).toEqual([])
})
it('keeps the pages held on a different execution host', async () => {
const harness = createHarness()
await createPage(harness.executor, 'page-a', OTHER_HOST)
expect(await createPage(harness.executor, 'page-b', GENERATION_2)).toEqual({
status: 'completed'
})
expect(harness.executor.hasPage('page-a', 7)).toBe(true)
expect(harness.executor.hasPage('page-b', 7)).toBe(true)
expect(harness.releasedRoutes).toEqual([])
expect(harness.proxyRulesFor(OTHER_HOST_STORAGE)).toBe('socks5://127.0.0.1:43125')
expect(harness.proxyRulesFor(SSH_HOST_STORAGE)).toBe('socks5://127.0.0.1:43124')
})
})
it('allows a different legacy page to retarget only after exact native and network retirement despite a missing renderer acknowledgement', async () => {
const h = createHarness()
const destroyed = Promise.withResolvers<void>()
@@ -51,26 +51,6 @@ async function fixture(
}
describe('staged client page profile switching', () => {
it('finishes failed old-generation renderer cleanup on teardown without repeating its releases', async () => {
const h = await fixture()
h.renderer.retirePage.mockRejectedValueOnce(new Error('old-retirement-reply-lost'))
const warning = vi.spyOn(console, 'warn').mockImplementation(() => {})
try {
expect(await h.executor.handle(switchCommand(), new AbortController().signal)).toMatchObject({
status: 'completed'
})
expect(h.executor.snapshotPageInventory()).toMatchObject([
{ pageHostGeneration: 8, state: 'active' }
])
expect(h.routeSession.release).toHaveBeenCalledTimes(1)
await h.executor.close()
expect(h.renderer.retirePage).toHaveBeenCalledTimes(3)
expect(h.routeSession.release).toHaveBeenCalledTimes(1)
} finally {
warning.mockRestore()
}
})
it('finishes independent pages, retained profile cleanup and uploads after one initializer cleanup fails', async () => {
const h = await fixture()
const signal = new AbortController().signal
@@ -81,33 +81,4 @@ describe('readDocPreviewFile — paired runtime owner', () => {
status: 404
})
})
it('requires approval for a sibling directory before touching the runtime', async () => {
const grant = mintDocPreviewGrant({
owner: {
kind: 'runtime',
environmentId: 'env-1',
worktreeSelector: 'id:wt-1',
worktreeRoot: '/srv/repo'
},
requestBase: '/srv/repo',
root: '/srv/repo/docs',
entryRelativePath: 'docs/index.html',
browserPageId: 'page-1'
})
await expect(readDocPreviewFile(grant, 'assets/app.js')).resolves.toMatchObject({
ok: false,
status: 403,
reason: 'authorization-required'
})
authorizeDocPreviewDirectory(grant.id, 'assets/app.js')
mocks.callRuntimeEnvironment.mockResolvedValue({
ok: true,
result: { content: 'console.log(1)', isBinary: false }
})
await expect(readDocPreviewFile(grant, 'assets/app.js')).resolves.toMatchObject({ ok: true })
})
})
@@ -1,107 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { readDocPreviewFile } from './doc-preview-file-reader'
import {
authorizeDocPreviewDirectory,
mintDocPreviewGrant,
revokeAllDocPreviewGrants
} from './doc-preview-grant-registry'
const mocks = vi.hoisted(() => ({ call: vi.fn() }))
vi.mock('../../ipc/runtime-environments/requests/runtime-environment-transport-routing', () => ({
callRuntimeEnvironment: mocks.call
}))
vi.mock('../../ipc/runtime-environments/catalog/runtime-environment-registry-root', () => ({
getRuntimeEnvironmentRegistryRoot: () => '/user-data'
}))
vi.mock('../../../shared/remote-runtime/host-store/runtime-environment-store', () => ({
resolveEnvironment: () => ({ id: 'vps', createdAt: 1, pairingRevision: 12 })
}))
function grant(root = '/tmp/reports'): ReturnType<typeof mintDocPreviewGrant> {
return mintDocPreviewGrant({
owner: {
kind: 'runtime',
environmentId: 'vps',
worktreeSelector: 'id:source-workspace',
worktreeRoot: '/srv/repo',
fileAccess: 'terminal-artifact'
},
requestBase: root,
root,
entryRelativePath: 'report.html',
browserPageId: 'page-1'
})
}
type HostEntry = { canonical?: string; openTarget?: Record<string, unknown> }
/** The paired host: resolveTerminalPath answers per path, reads answer with `read`. */
function host(entries: Record<string, HostEntry>, read?: unknown): void {
mocks.call.mockImplementation(
async (_root: string, _env: string, method: string, params: { pathText?: string }) => {
if (method !== 'files.resolveTerminalPath') {
return read
}
const path = params.pathText ?? ''
const entry = entries[path] ?? {}
const canonical = entry.canonical ?? path
return {
ok: true,
result: {
absolutePath: canonical,
openTarget: entry.openTarget ?? {
kind: 'absolute-file',
absolutePath: canonical,
grantId: 'host-grant'
}
}
}
}
)
}
beforeEach(() => {
vi.resetAllMocks()
revokeAllDocPreviewGrants()
})
describe('outside-workspace HTML preview', () => {
it('does not resolve or read neighboring assets until their directory is approved', async () => {
const page = grant()
await expect(readDocPreviewFile(page, 'secret.json')).resolves.toMatchObject({
ok: false,
status: 403,
reason: 'authorization-required'
})
authorizeDocPreviewDirectory(page.id, 'assets/logo.png')
host(
{},
{
ok: true,
result: {
content: Buffer.from([137, 80, 78, 71]).toString('base64'),
isBinary: true,
mimeType: 'image/png'
}
}
)
await expect(readDocPreviewFile(page, 'assets/logo.png')).resolves.toMatchObject({
ok: true,
bytes: Buffer.from([137, 80, 78, 71]),
contentType: 'image/png'
})
})
it('refuses symlink redirection outside an approved asset directory', async () => {
const page = grant()
authorizeDocPreviewDirectory(page.id, 'assets/data.json')
host({ '/tmp/reports/assets/data.json': { canonical: '/home/dev/.config/credentials.json' } })
await expect(readDocPreviewFile(page, 'assets/data.json')).resolves.toMatchObject({
ok: false
})
})
// The host canonicalizes these roots, so a nested symlink cannot leave the approved folder.
})
@@ -11,7 +11,6 @@ const input = {
control: process.platform !== 'darwin',
shift: true
}
const primaryDoubleTap = process.platform === 'darwin' ? 'Cmd' : 'Ctrl'
function fixture(local = true) {
const send = vi.fn()
@@ -120,34 +119,6 @@ describe('Plugins shortcut from a browser guest', () => {
})
})
it('follows the typed letter on a German layout where Y and Z swap keys on Linux', () => {
withPlatform('linux', () => {
const { context, event, send } = fixture()
context.getKeybindings = () => ({})
const chord = { control: true, shift: true }
expect(forwardGuestShortcutInput(context, event, { ...chord, key: 'Z', code: 'KeyY' })).toBe(
false
)
expect(send).not.toHaveBeenCalled()
expect(forwardGuestShortcutInput(context, event, { ...chord, key: 'Y', code: 'KeyZ' })).toBe(
true
)
expect(send).toHaveBeenCalledOnce()
})
})
it('replays a configured modifier double tap', () => {
const { context, event, send } = fixture()
context.getKeybindings = () => ({ 'view.plugins': ['DoubleTap+Mod'] })
expect(forwardGuestShortcutInput(context, event, { doubleTapModifier: primaryDoubleTap })).toBe(
true
)
expect(send).toHaveBeenCalledWith(
'ui:browserPageTabShortcut',
expect.objectContaining({ doubleTapModifier: primaryDoubleTap })
)
})
it('leaves composing input and nonlocal renderers alone', () => {
const { context, event, send } = fixture(false)
expect(forwardGuestShortcutInput(context, event, input)).toBe(false)
@@ -43,7 +43,6 @@ vi.mock('../../popups/presentation/popup-origin-bar-window', () => ({
import { browserManager } from '../browser-manager'
import { respondToBrowserPagePrompt } from '../../prompts/browser-page-prompts'
import { handleBrowserGuestLogin } from '../../prompts/browser-http-auth-prompt'
import {
guestNavigationMethods,
rendererWebContentsId,
@@ -159,74 +158,4 @@ describe('browserManager page prompt routing', () => {
expect(prompts().at(-1)).toMatchObject({ origin: null })
answerLatest(false)
})
it("asks for a new tab's first sign-in once its window registers the tab", async () => {
const rendererSendMock = vi.fn()
const guest = {
...guestNavigationMethods(),
id: 109,
isDestroyed: vi.fn(() => false),
isCurrentlyAudible: vi.fn(() => false),
getType: vi.fn(() => 'webview'),
getURL: vi.fn(() => ''),
setBackgroundThrottling: guestSetBackgroundThrottlingMock,
setWindowOpenHandler: guestSetWindowOpenHandlerMock,
on: guestOnMock,
prependListener: vi.fn(),
once: vi.fn(),
off: guestOffMock,
openDevTools: guestOpenDevToolsMock
}
const renderer = {
id: rendererWebContentsId,
isDestroyed: () => false,
send: rendererSendMock,
on: vi.fn(),
off: vi.fn()
}
webContentsFromIdMock.mockImplementation((id: number) =>
id === guest.id ? guest : id === rendererWebContentsId ? renderer : null
)
// Guests can start loading before renderer registration.
browserManager.attachGuestPolicies(webContentsFromIdMock(guest.id))
const event = { preventDefault: vi.fn() }
const callback = vi.fn()
handleBrowserGuestLogin(
event,
webContentsFromIdMock(guest.id),
{
url: 'https://staging.acme.com/',
pid: 1,
isRequestForNavigation: true,
firstAuthAttempt: true
},
{ isProxy: false, scheme: 'basic', host: 'staging.acme.com', port: 443, realm: 'Staging' },
callback
)
// Keep the challenge pending until registration supplies a prompt window.
expect(event.preventDefault).toHaveBeenCalled()
expect(rendererSendMock).not.toHaveBeenCalledWith(
'browser:page-prompt-requested',
expect.anything()
)
browserManager.registerGuest({
browserPageId: 'browser-3',
webContentsId: guest.id,
rendererWebContentsId
})
const request = rendererSendMock.mock.calls.find(
([channel]) => channel === 'browser:page-prompt-requested'
)?.[1]
expect(request).toMatchObject({
kind: 'http-auth',
details: { origin: 'https://staging.acme.com' }
})
await respondToBrowserPagePrompt(webContentsFromIdMock(rendererWebContentsId), {
requestId: request.requestId,
answer: { username: 'qa', password: 'hunter2' }
})
expect(callback).toHaveBeenCalledWith('qa', 'hunter2')
})
})
@@ -1,20 +1,7 @@
import { describe, expect, it } from 'vitest'
import {
createPageInitiatedTabBudget,
MAX_PAGE_INITIATED_TABS_PER_WINDOW,
PAGE_INITIATED_TAB_WINDOW_MS
} from './browser-page-initiated-tab-budget'
import { createPageInitiatedTabBudget } from './browser-page-initiated-tab-budget'
describe('page-initiated tab budget', () => {
it('refills once the rolling window has passed, so real browsing is unaffected', () => {
const budget = createPageInitiatedTabBudget()
for (let i = 0; i < MAX_PAGE_INITIATED_TABS_PER_WINDOW; i++) {
expect(budget.tryConsume(1_000)).toBe(true)
}
expect(budget.tryConsume(1_000 + PAGE_INITIATED_TAB_WINDOW_MS - 1)).toBe(false)
expect(budget.tryConsume(1_000 + PAGE_INITIATED_TAB_WINDOW_MS)).toBe(true)
})
it('slides rather than resetting, so a paced flood cannot outrun the cap', () => {
const budget = createPageInitiatedTabBudget(2, 1_000)
@@ -1,132 +0,0 @@
import { EventEmitter } from 'node:events'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
resolvePagePromptTarget: vi.fn()
}))
vi.mock('../manager/browser-manager', () => ({
browserManager: {
resolvePagePromptTarget: mocks.resolvePagePromptTarget
}
}))
import type { BrowserPagePromptRequest } from '../../../../shared/browser/prompts/browser-page-prompt'
import { handleBrowserGuestLogin } from './browser-http-auth-prompt'
import { BROWSER_PAGE_PROMPT_TIMEOUT_MS, respondToBrowserPagePrompt } from './browser-page-prompts'
type MockGuest = Electron.WebContents & EventEmitter
const guests: MockGuest[] = []
function mockGuest(pageUrl = 'https://staging.acme.com/'): MockGuest {
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the prompt only reads id, isDestroyed, getURL and the input, navigation and destroyed events.
const guest = new EventEmitter() as MockGuest
Object.assign(guest, {
id: 100 + guests.length,
isDestroyed: () => false,
getURL: () => pageUrl
})
guests.push(guest)
return guest
}
function mockRenderer(id: number): Electron.WebContents {
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: prompts only read id, isDestroyed, send and subscribe to the window's lifecycle events.
return {
id,
isDestroyed: () => false,
send: vi.fn(),
on: vi.fn(),
off: vi.fn()
} as unknown as Electron.WebContents
}
function showTabIn(renderer: Electron.WebContents, browserPageId = 'page-1'): void {
mocks.resolvePagePromptTarget.mockReturnValue({ browserPageId, renderer })
}
function challenge(
guest: MockGuest,
{
url = 'https://staging.acme.com/',
isRequestForNavigation = true,
firstAuthAttempt = true,
isProxy = false,
realm = 'Staging'
} = {}
) {
const event = { preventDefault: vi.fn() }
const callback = vi.fn()
handleBrowserGuestLogin(
event,
guest,
{ url, pid: 1, isRequestForNavigation, firstAuthAttempt },
{ isProxy, scheme: 'basic', host: new URL(url).hostname, port: 443, realm },
callback
)
return { asked: event.preventDefault.mock.calls.length > 0, callback }
}
function sentRequests(renderer: Electron.WebContents): BrowserPagePromptRequest<'http-auth'>[] {
return vi
.mocked(renderer.send)
.mock.calls.filter(([channel]) => channel === 'browser:page-prompt-requested')
.map(([, request]) => request)
}
const answer = (renderer: Electron.WebContents, requestId: string, value: unknown) =>
respondToBrowserPagePrompt(renderer, { requestId, answer: value })
const CREDENTIALS = { username: 'qa', password: 'hunter2' }
describe('browser HTTP auth prompt', () => {
beforeEach(() => {
vi.useFakeTimers()
mocks.resolvePagePromptTarget.mockReset()
})
afterEach(() => {
vi.advanceTimersByTime(BROWSER_PAGE_PROMPT_TIMEOUT_MS)
for (const guest of guests.splice(0)) {
guest.emit('destroyed')
}
vi.useRealTimers()
})
it.each([
['a proxy challenge', { isProxy: true }],
[
'an image from another site',
{ url: 'https://cdn.other.com/a.png', isRequestForNavigation: false }
]
])('leaves %s to Electron, which cancels it', (_name, options) => {
const renderer = mockRenderer(10)
showTabIn(renderer)
const { asked } = challenge(mockGuest(), options)
expect(asked).toBe(false)
expect(sentRequests(renderer)).toEqual([])
})
it('shares one prompt per realm and cancels a different challenge meanwhile', async () => {
const renderer = mockRenderer(10)
showTabIn(renderer)
const guest = mockGuest()
const first = challenge(guest)
const sameRealm = challenge(guest, {
url: 'https://staging.acme.com/logo.png',
isRequestForNavigation: false
})
const otherRealm = challenge(guest, { realm: 'Admin' })
expect([first.asked, sameRealm.asked, otherRealm.asked]).toEqual([true, true, false])
const [request] = sentRequests(renderer)
expect(sentRequests(renderer)).toHaveLength(1)
await answer(renderer, request.requestId, CREDENTIALS)
expect(first.callback).toHaveBeenCalledWith('qa', 'hunter2')
expect(sameRealm.callback).toHaveBeenCalledWith('qa', 'hunter2')
})
})
@@ -7,7 +7,6 @@ import {
getBrowserJavaScriptDialog,
installBrowserJavaScriptDialogs
} from './browser-javascript-dialogs'
import { respondToBrowserPagePrompt } from './browser-page-prompts'
type MockContents = Electron.WebContents & EventEmitter & { debugger: EventEmitter }
@@ -58,39 +57,6 @@ function setup() {
}
describe('browser JavaScript dialogs', () => {
it('asks in the page prompt instead of Electron’s window-wide sheet', () => {
const { guest, electronSheet, openDialog, shownPrompts } = setup()
const callback = openDialog('confirm', 'Delete everything?')
expect(electronSheet).not.toHaveBeenCalled()
expect(callback).not.toHaveBeenCalled()
expect(getBrowserJavaScriptDialog(guest.id)).toEqual({
type: 'confirm',
message: 'Delete everything?',
defaultPromptText: ''
})
expect(shownPrompts()).toEqual([
expect.objectContaining({
kind: 'javascript-dialog',
details: {
type: 'confirm',
message: 'Delete everything?',
defaultPromptText: '',
origin: 'https://shop.example'
}
})
])
})
it('names an embedded page that asks, not the site around it', () => {
const { openDialog, shownPrompts } = setup()
openDialog('prompt', 'Re-enter your password', '', { origin: 'https://ads.example' })
expect(shownPrompts()[0]?.details).toMatchObject({ origin: 'https://ads.example' })
})
it('takes an agent’s prompt answer and closes the page prompt', () => {
const { guest, openDialog, closedPrompts } = setup()
const callback = openDialog('prompt', 'Your name?', 'def')
@@ -103,19 +69,6 @@ describe('browser JavaScript dialogs', () => {
expect(answerBrowserJavaScriptDialog(guest.id, { accept: true, promptText: '' })).toBe(false)
})
it('answers the page with what the user chose in the page prompt', async () => {
const { renderer, guest, openDialog, shownPrompts } = setup()
const callback = openDialog('confirm', 'Leave?')
await respondToBrowserPagePrompt(renderer, {
requestId: shownPrompts()[0].requestId,
answer: { accept: false, promptText: '' }
})
expect(callback).toHaveBeenCalledExactlyOnceWith(false, '')
expect(getBrowserJavaScriptDialog(guest.id)).toBeNull()
})
it('turns away a second frame’s dialog and keeps the first one answerable', () => {
const { guest, openDialog } = setup()
const first = openDialog('confirm', 'Delete everything?')
@@ -4,7 +4,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { BrowserPagePromptRequest } from '../../../../shared/browser/prompts/browser-page-prompt'
import {
BROWSER_PAGE_PROMPT_TIMEOUT_MS,
cancelBrowserPagePrompts,
openBrowserPagePrompt,
respondToBrowserPagePrompt,
showBrowserPagePromptsAwaitingRegistration,
@@ -57,13 +56,6 @@ function sentRequests(renderer: Electron.WebContents): BrowserPagePromptRequest[
.map(([, request]) => request)
}
function closedRequestIds(renderer: Electron.WebContents): string[] {
return vi
.mocked(renderer.send)
.mock.calls.filter(([channel]) => channel === 'browser:page-prompt-closed')
.map(([, event]) => event.requestId)
}
const latestId = (renderer: Electron.WebContents): string =>
sentRequests(renderer).at(-1)!.requestId
@@ -172,25 +164,4 @@ describe('browser page prompts', () => {
await respondToBrowserPagePrompt(renderer, { requestId: latestId(renderer), answer: true })
expect(ask(guest).opening).toBe('prompting')
})
it('closes the prompt with a null answer when it expires or its page goes away', async () => {
const renderer = mockRenderer(10)
const expiring = ask(mockGuest(renderer, 'page-1'))
const expiringId = latestId(renderer)
const closingGuest = mockGuest(renderer, 'page-2')
const closing = ask(closingGuest)
const closingId = latestId(renderer)
cancelBrowserPagePrompts('page-2')
vi.advanceTimersByTime(BROWSER_PAGE_PROMPT_TIMEOUT_MS)
await Promise.resolve()
expect(closedRequestIds(renderer)).toEqual([closingId, expiringId])
expect(expiring.settle).toHaveBeenCalledWith(null)
expect(closing.settle).toHaveBeenCalledWith(null)
await expect(
respondToBrowserPagePrompt(renderer, { requestId: closingId, answer: true })
).resolves.toBe('rejected')
expect(ask(closingGuest).opening).toBe('prompting')
})
})
@@ -1,56 +0,0 @@
import { describe, expect, it } from 'vitest'
import { getOrcaProfileBrowserSessionPartition } from '../../../../shared/orca-profiles'
import { inspectRetiredBrowserSessionProfileUserAgentModes } from './browser-retired-profile-identity-inspection'
const ORCA_PROFILE_ID = 'local-default'
const PROFILE_ID = '11111111-1111-4111-8111-111111111111'
function profileWithMode(mode: unknown): Record<string, unknown> {
return {
id: PROFILE_ID,
scope: 'isolated',
partition: getOrcaProfileBrowserSessionPartition(ORCA_PROFILE_ID, PROFILE_ID),
label: 'Existing',
source: null,
userAgentMode: mode
}
}
function unhydratableProfile(extra: Record<string, unknown> = {}): Record<string, unknown> {
return {
id: 'not-a-uuid',
scope: 'isolated',
partition: 'persist:orca-browser-session-not-a-uuid',
label: 'Unhydratable',
source: null,
...extra
}
}
describe('retired browser profile identity inspection', () => {
it('detects an inspectable old choice without removing its bytes', () => {
const profile = profileWithMode('native')
expect(inspectRetiredBrowserSessionProfileUserAgentModes([profile], ORCA_PROFILE_ID)).toEqual({
noticePending: true,
degraded: false
})
expect(profile.userAgentMode).toBe('native')
})
it.each([
{ scenario: 'an unreadable mode', entry: profileWithMode('unexpected') },
{
scenario: 'a mode on an entry that cannot be hydrated',
entry: unhydratableProfile({ userAgentMode: 'native' })
}
])('turns $scenario into a degraded notice without throwing', ({ entry }) => {
expect(() =>
inspectRetiredBrowserSessionProfileUserAgentModes([entry], ORCA_PROFILE_ID)
).not.toThrow()
expect(inspectRetiredBrowserSessionProfileUserAgentModes([entry], ORCA_PROFILE_ID)).toEqual({
noticePending: true,
degraded: true
})
})
})
@@ -1,416 +0,0 @@
import { spawn, type ChildProcess } from 'node:child_process'
import { createServer } from 'node:net'
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { createRequire } from 'node:module'
import { tmpdir } from 'node:os'
import { basename, join } from 'node:path'
import { chromium } from 'playwright'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { build as buildVite } from 'vite'
import {
BrowserSessionUaCdpCollector,
type BrowserSessionUaCdpRequest,
waitForBrowserCdpEndpoint
} from './browser-session-ua-cdp-collector'
import {
startBrowserSessionUaWireProbeServer,
type WireProbeJavaScriptIdentity,
type WireProbeReceipt
} from './browser-session-ua-wire-probe-server'
const electronBinary = createRequire(import.meta.url)('electron') as string
const fixtureRoots: string[] = []
let processIdentityModulePath: string
let exceptionModulePath: string
beforeAll(async () => {
const root = mkdtempSync(join(tmpdir(), 'orca-wire-identity-modules-'))
fixtureRoots.push(root)
processIdentityModulePath = join(root, 'browser-process-user-agent.cjs')
exceptionModulePath = join(root, 'browser-session-ua.cjs')
await Promise.all([
buildModule(
'src/main/browser/identity/browser-process-user-agent.ts',
processIdentityModulePath
),
buildModule('src/main/browser/identity/requests/browser-session-ua.ts', exceptionModulePath)
])
})
afterAll(() => {
for (const root of fixtureRoots) {
rmSync(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 })
}
})
type ProbeArm = 'clean' | 'fallback-disabled'
type ProbeResult = Readonly<{
arm: ProbeArm
rawUserAgent: string
cleanUserAgent: string
navigatorUserAgent: string
receipts: readonly WireProbeReceipt[]
identities: readonly WireProbeJavaScriptIdentity[]
cdpRequests: readonly BrowserSessionUaCdpRequest[]
cdpDiagnostics: readonly string[]
fixtureResult: string
fixtureStderr: string
}>
describe('browser session wire identity in cross-site frames and dedicated workers', () => {
it('keeps OOPIF, dedicated-worker, and client-hint identities clean', async () => {
const result = await runProbe('clean')
assertCapturedContexts(result)
const checks = identityChecks(result)
expect(checks.crossSiteDocument).toBe(true)
expect(checks.crossSiteFetch).toBe(true)
expect(checks.dedicatedWorkerScript).toBe(true)
expect(checks.dedicatedWorkerFetch).toBe(true)
expect(checks.clientHints, JSON.stringify(receiptForPath(result.receipts, '/'))).toBe(true)
}, 60_000)
})
async function runProbe(arm: ProbeArm): Promise<ProbeResult> {
const root = mkdtempSync(join(tmpdir(), `orca-wire-cross-context-${arm}-`))
fixtureRoots.push(root)
const server = await startBrowserSessionUaWireProbeServer()
const resultPath = join(root, 'result.json')
const barrierPath = join(root, 'continue')
const fixturePath = join(root, 'main.cjs')
const cdpPort = await reservePort()
writeFileSync(
fixturePath,
fixtureMain({
arm,
barrierPath,
exceptionModulePath,
httpOrigin: server.httpOrigin,
processIdentityModulePath,
resultPath
})
)
let process: ChildProcess | null = null
let collector: BrowserSessionUaCdpCollector | null = null
let browser: Awaited<ReturnType<typeof chromium.connectOverCDP>> | null = null
try {
process = launchFixture(fixturePath, root, cdpPort)
await waitForBrowserCdpEndpoint(cdpPort)
browser = await chromium.connectOverCDP(`http://127.0.0.1:${cdpPort}`)
collector = await BrowserSessionUaCdpCollector.connect(cdpPort)
await collector.installAutoAttach()
writeFileSync(barrierPath, '')
const processResult = await waitForProcess(process)
const fixtureResult = existsSync(resultPath) ? readFileSync(resultPath, 'utf8') : 'no result'
expect(
processResult.code,
`${fixtureResult}\n${processResult.stderr}\n${JSON.stringify({ diagnostics: collector.diagnostics, receipts: server.receipts, identities: server.identities })}`
).toBe(0)
await new Promise((resolve) => setTimeout(resolve, 250))
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: JSON.parse is untyped; the fixture writes this exact shape before exiting.
const parsed = JSON.parse(fixtureResult) as Omit<
ProbeResult,
| 'receipts'
| 'identities'
| 'cdpRequests'
| 'cdpDiagnostics'
| 'fixtureResult'
| 'fixtureStderr'
>
return {
...parsed,
fixtureResult,
fixtureStderr: processResult.stderr,
receipts: [...server.receipts],
identities: [...server.identities],
cdpDiagnostics: [...collector.diagnostics],
cdpRequests: collector.snapshot().filter(({ url }) => {
return (
url.startsWith(server.httpOrigin) ||
url.startsWith(server.crossSiteOrigin) ||
url.startsWith(server.httpsOrigin)
)
})
}
} finally {
await collector?.close().catch(() => {})
await browser?.close().catch(() => {})
await server.close()
if (process && process.exitCode === null) {
process.kill('SIGTERM')
}
}
}
async function buildModule(entry: string, outputPath: string): Promise<void> {
await buildVite({
configFile: false,
logLevel: 'silent',
build: {
emptyOutDir: false,
lib: {
entry: join(process.cwd(), entry),
formats: ['cjs'],
fileName: () => basename(outputPath)
},
outDir: join(outputPath, '..'),
target: 'node20',
rollupOptions: { external: ['electron', /^node:/] }
}
})
}
function launchFixture(fixturePath: string, root: string, cdpPort: number): ChildProcess {
const { ELECTRON_RUN_AS_NODE: _electronRunAsNode, ...env } = process.env
const args = [
fixturePath,
`--user-data-dir=${join(root, 'profile')}`,
`--remote-debugging-port=${cdpPort}`,
'--site-per-process'
]
if (process.platform === 'linux') {
args.push('--no-sandbox')
}
return spawn(
process.platform === 'linux' ? 'xvfb-run' : electronBinary,
process.platform === 'linux' ? ['--auto-servernum', electronBinary, ...args] : args,
{
env: { ...env, ORCA_BACKGROUND_LAUNCH: '1' },
stdio: ['ignore', 'pipe', 'pipe']
}
)
}
function fixtureMain(options: {
arm: ProbeArm
barrierPath: string
exceptionModulePath: string
httpOrigin: string
processIdentityModulePath: string
resultPath: string
}): string {
return String.raw`
const { app, BrowserWindow, session } = require('electron')
const { existsSync, writeFileSync } = require('node:fs')
const processIdentity = require(${JSON.stringify(options.processIdentityModulePath)})
const { cleanElectronUserAgent } = require(${JSON.stringify(options.processIdentityModulePath)})
const arm = ${JSON.stringify(options.arm)}
app.setName('OrcaCrossContextFixture')
app.commandLine.appendSwitch('site-per-process')
const rawUserAgent = app.userAgentFallback
if (arm === 'clean') processIdentity.initializeBrowserProcessUserAgent('clean')
const waitForBarrier = async () => {
const deadline = Date.now() + 15000
while (!existsSync(${JSON.stringify(options.barrierPath)})) {
if (Date.now() >= deadline) throw new Error('startup barrier timeout')
await new Promise(resolve => setTimeout(resolve, 20))
}
}
async function run() {
const timeout = setTimeout(() => { writeFileSync(${JSON.stringify(options.resultPath)}, JSON.stringify({ error: 'timeout' })); app.exit(2) }, 20000)
await app.whenReady()
await waitForBarrier()
const sess = session.fromPartition('persist:wire-cross-context')
sess.setCertificateVerifyProc((_request, callback) => callback(0))
const windows = []
const window = new BrowserWindow({ show: false, webPreferences: { partition: 'persist:wire-cross-context', sandbox: true } })
windows.push(window)
window.webContents.setWindowOpenHandler(() => ({
action: 'allow',
createWindow: options => {
const popup = new BrowserWindow({ ...options, show: false })
windows.push(popup)
return popup.webContents
}
}))
await window.loadURL(${JSON.stringify(options.httpOrigin)} + '/?cross-context=1')
const [navigatorUserAgent] = await Promise.all([
window.webContents.executeJavaScript('navigator.userAgent'),
window.webContents.executeJavaScript('window.probePromise')
])
// Allow isolated-frame CDP events to flush before the fixture exits.
await new Promise(resolve => setTimeout(resolve, 1500))
clearTimeout(timeout)
writeFileSync(${JSON.stringify(options.resultPath)}, JSON.stringify({ arm, rawUserAgent, cleanUserAgent: cleanElectronUserAgent(rawUserAgent), navigatorUserAgent }))
for (const candidate of windows) if (!candidate.isDestroyed()) candidate.destroy()
app.exit(0)
}
run().catch(error => { writeFileSync(${JSON.stringify(options.resultPath)}, JSON.stringify({ error: String(error?.stack || error) })); app.exit(1) })
`
}
function assertCapturedContexts(result: ProbeResult): void {
const paths = new Set(result.receipts.map(({ path }) => path))
for (const path of [
'/',
'/cross-site-frame',
'/cross-site-frame-fetch',
'/dedicated-worker.js',
'/dedicated-worker-fetch',
'/report/cross-site-frame',
'/report/dedicated-worker'
]) {
expect(
paths,
`${result.arm} omitted ${path}\n cdp: ${JSON.stringify(result.cdpDiagnostics)}\n receipts: ${JSON.stringify(result.receipts.map((r) => r.path))}\n fixture: ${result.fixtureResult}\n stderr: ${result.fixtureStderr}`
).toContain(path)
}
expect(
result.cdpDiagnostics.some((message) => message.startsWith('attached:iframe:')),
JSON.stringify(result.cdpDiagnostics)
).toBe(true)
expect(
result.cdpDiagnostics.some((message) => message.startsWith('attached:worker:')),
JSON.stringify(result.cdpDiagnostics)
).toBe(true)
expect(
result.receipts
.filter(({ path }) => path === '/cross-site-frame')
.map(({ protocol }) => protocol)
).toEqual(['https'])
expect(
result.receipts
.filter(({ path }) => path === '/cross-site-frame-fetch')
.map(({ protocol }) => protocol)
).toEqual(['https'])
expect(
result.cdpRequests.some(({ url, targetType }) => {
return new URL(url).pathname === '/cross-site-frame-fetch' && targetType === 'iframe'
}),
JSON.stringify(result.cdpRequests.filter(({ url }) => url.includes('cross-site-frame-fetch')))
).toBe(true)
}
function identityChecks(result: ProbeResult): Readonly<Record<string, boolean>> {
const frameIdentity = identityForContext(result.identities, 'cross-site-frame')
const workerIdentity = identityForContext(result.identities, 'dedicated-worker')
const frameReceipt = receiptForPath(result.receipts, '/cross-site-frame-fetch')
const workerScriptReceipt = receiptForPath(result.receipts, '/dedicated-worker.js')
const workerFetchReceipt = receiptForPath(result.receipts, '/dedicated-worker-fetch')
// Compare hints on a subsequent fetch because initial navigation may omit them.
const rootReceipt = receiptForPath(result.receipts, '/report/document')
return {
crossSiteDocument: frameIdentity.userAgent === result.cleanUserAgent,
crossSiteFetch: frameReceipt.userAgent === result.cleanUserAgent,
dedicatedWorkerScript: workerScriptReceipt.userAgent === result.cleanUserAgent,
dedicatedWorkerFetch:
workerIdentity.userAgent === result.cleanUserAgent &&
workerFetchReceipt.userAgent === result.cleanUserAgent,
clientHints: clientHintIdentityIsClean(
rootReceipt,
identityForContext(result.identities, 'document'),
result.cleanUserAgent
)
}
}
function clientHintIdentityIsClean(
receipt: WireProbeReceipt,
identity: WireProbeJavaScriptIdentity,
cleanUserAgent: string
): boolean {
// Native UA-CH can remain Chromium-shaped; require a clean legacy UA on the same request.
const secChUa = receipt.clientHints['sec-ch-ua']
const wireBrands = parseSecChUa(secChUa)
const navigatorBrands = readNavigatorBrands(identity.userAgentData)
if (!secChUa || wireBrands.length === 0 || navigatorBrands.length === 0) {
return false
}
const token = /electron|orca/i
return (
receipt.userAgent === cleanUserAgent &&
!token.test(receipt.userAgent ?? '') &&
!token.test(secChUa) &&
!navigatorBrands.some(({ brand, version }) => token.test(brand) || token.test(version)) &&
sameBrands(wireBrands, navigatorBrands)
)
}
function parseSecChUa(value: string | undefined): { brand: string; version: string }[] {
if (!value) {
return []
}
const brands: { brand: string; version: string }[] = []
const pattern = /"([^"]+)"\s*;\s*v="([^"]*)"/g
for (const match of value.matchAll(pattern)) {
const brand = match[1]
const version = match[2]
if (brand !== undefined && version !== undefined) {
brands.push({ brand, version })
}
}
return brands
}
function readNavigatorBrands(value: unknown): { brand: string; version: string }[] {
if (typeof value !== 'object' || value === null) {
return []
}
const brandsValue = Object.entries(value).find(([key]) => key === 'brands')?.[1]
if (!Array.isArray(brandsValue)) {
return []
}
const brands: { brand: string; version: string }[] = []
for (const entry of brandsValue) {
if (typeof entry !== 'object' || entry === null) {
continue
}
const fields = Object.fromEntries(Object.entries(entry))
const brand = fields.brand
const version = fields.version
if (typeof brand === 'string' && typeof version === 'string') {
brands.push({ brand, version })
}
}
return brands
}
function sameBrands(
left: readonly { brand: string; version: string }[],
right: readonly { brand: string; version: string }[]
): boolean {
const normalize = (brands: readonly { brand: string; version: string }[]) =>
brands.map(({ brand, version }) => `${brand}\u0000${version}`).sort()
return JSON.stringify(normalize(left)) === JSON.stringify(normalize(right))
}
function identityForContext(
identities: readonly WireProbeJavaScriptIdentity[],
context: string
): WireProbeJavaScriptIdentity {
const matches = identities.filter((identity) => identity.context === context)
expect(matches, context).toHaveLength(1)
return matches[0]!
}
function receiptForPath(receipts: readonly WireProbeReceipt[], path: string): WireProbeReceipt {
const matches = receipts.filter((receipt) => receipt.path === path)
expect(matches, path).not.toHaveLength(0)
return matches[0]!
}
async function reservePort(): Promise<number> {
const server = createServer()
await new Promise<void>((resolve, reject) => {
server.once('error', reject)
server.listen(0, '127.0.0.1', resolve)
})
const address = server.address()
if (!address || typeof address === 'string') {
throw new Error('cdp port unavailable')
}
await new Promise<void>((resolve) => server.close(() => resolve()))
return address.port
}
function waitForProcess(process: ChildProcess): Promise<{ code: number | null; stderr: string }> {
let stderr = ''
process.stderr?.setEncoding('utf8')
process.stderr?.on('data', (chunk: string) => {
stderr += chunk
})
return new Promise((resolve, reject) => {
process.once('error', reject)
process.once('exit', (code) => resolve({ code, stderr }))
})
}
@@ -1,214 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const { fromPartitionMock } = vi.hoisted(() => {
const sessionsByPartition = new Map<string, Record<string, ReturnType<typeof vi.fn>>>()
const fromPartitionMock = vi.fn((partition: string) => {
const existing = sessionsByPartition.get(partition)
if (existing) {
return existing
}
const created = {
resolveProxy: vi.fn(async () => 'DIRECT'),
setProxy: vi.fn(async () => {}),
closeAllConnections: vi.fn(async () => {}),
getUserAgent: vi.fn(() => 'Mozilla/5.0 Electron/43.0.0 Orca/1.0'),
setUserAgent: vi.fn(),
setPermissionRequestHandler: vi.fn(),
setPermissionCheckHandler: vi.fn(),
setDisplayMediaRequestHandler: vi.fn(),
removeListener: vi.fn(),
on: vi.fn()
}
sessionsByPartition.set(partition, created)
return created
})
return { sessionsByPartition, fromPartitionMock }
})
const identityState = vi.hoisted(() => ({ unavailable: false }))
vi.mock('electron', () => ({
session: {
defaultSession: { resolveProxy: vi.fn(async () => 'DIRECT'), setProxy: vi.fn(async () => {}) },
fromPartition: fromPartitionMock
}
}))
vi.mock('../../guest-lifecycle/manager/browser-manager', () => ({
browserManager: {
installCertificateRequestGuard: vi.fn(),
removeCertificateRequestGuard: vi.fn(),
notifyPermissionDenied: vi.fn(),
handleGuestWillDownload: vi.fn()
}
}))
vi.mock('../../site-settings/permissions/browser-media-access', () => ({
hasSystemMediaAccess: vi.fn(() => false),
requestSystemMediaAccess: vi.fn(async () => false)
}))
vi.mock('../../identity/requests/browser-session-ua', () => ({
installBrowserSessionUserAgentPolicy: vi.fn(() => vi.fn())
}))
vi.mock('../../identity/browser-process-user-agent', () => ({
getBrowserProcessUserAgentIdentity: () => {
if (identityState.unavailable) {
throw new Error('Browser process user agent is not initialized')
}
return {
mode: 'clean',
userAgent: 'Mozilla/5.0 Chrome/150.0.0.0 Safari/537.36'
}
}
}))
vi.mock('../../webauthn/browser-webauthn-access', () => ({
allowsBrowserWebAuthnPermission: vi.fn(() => false),
clearBrowserWebAuthnAccessHandlers: vi.fn(),
installBrowserWebAuthnAccessHandlers: vi.fn()
}))
import {
clearBrowserSessionPartitionPolicies,
installBrowserSessionPartitionPolicies
} from './browser-session-partition-policies'
import {
applyBrowserSessionProxies,
setBrowserNetworkProxySettingsResolver
} from '../proxy/browser-session-proxy'
import { handleElectronProxyLogin } from '../../../network/electron-proxy-credentials'
let partitionCounter = 0
function nextProfile() {
partitionCounter += 1
const partition = `persist:orca-browser-session-install-${partitionCounter}`
return {
id: `p${partitionCounter}`,
scope: 'isolated' as const,
partition,
label: 'p',
source: null
}
}
describe('installBrowserSessionPartitionPolicies proxy wiring', () => {
beforeEach(() => {
setBrowserNetworkProxySettingsResolver(null)
// Ignore host-shell proxy variables when testing settings.
for (const key of [
'HTTP_PROXY',
'http_proxy',
'HTTPS_PROXY',
'https_proxy',
'ALL_PROXY',
'all_proxy'
]) {
vi.stubEnv(key, '')
}
})
afterEach(() => {
setBrowserNetworkProxySettingsResolver(null)
vi.unstubAllEnvs()
identityState.unavailable = false
})
it('reports an unavailable process identity through the promise, not a synchronous throw', async () => {
const profile = nextProfile()
identityState.unavailable = true
let installation: Promise<void> | undefined
expect(() => {
installation = installBrowserSessionPartitionPolicies(profile)
}).not.toThrow()
await expect(installation).rejects.toThrow('Browser process user agent is not initialized')
})
it('does not report a new partition ready until Electron finishes applying its proxy', async () => {
let finishWrite: (() => void) | undefined
setBrowserNetworkProxySettingsResolver(() => ({
httpProxyUrl: 'socks5://127.0.0.1:1080',
httpProxyBypassRules: ''
}))
const profile = nextProfile()
const sess = fromPartitionMock(profile.partition)
sess.setProxy.mockImplementation(() => new Promise<void>((resolve) => (finishWrite = resolve)))
let ready = false
const installation = installBrowserSessionPartitionPolicies(profile).then(() => (ready = true))
await vi.waitFor(() => expect(sess.setProxy).toHaveBeenCalledTimes(1))
expect(ready).toBe(false)
finishWrite?.()
await installation
expect(ready).toBe(true)
})
it('recovers partition readiness after one transient proxy rejection', async () => {
setBrowserNetworkProxySettingsResolver(() => ({
httpProxyUrl: 'http://proxy.example:8080',
httpProxyBypassRules: ''
}))
const profile = nextProfile()
const sess = fromPartitionMock(profile.partition)
sess.setProxy.mockRejectedValueOnce(new Error('transient proxy failure'))
await expect(installBrowserSessionPartitionPolicies(profile)).resolves.toBeUndefined()
expect(sess.setProxy).toHaveBeenCalledTimes(2)
})
it('forgets failed startup credentials and restores them after recovery', async () => {
setBrowserNetworkProxySettingsResolver(() => ({
httpProxyUrl: 'http://alice:secret@proxy.example:8080',
httpProxyBypassRules: ''
}))
const profile = nextProfile()
const sess = fromPartitionMock(profile.partition)
sess.closeAllConnections.mockRejectedValue(new Error('proxy settlement failed'))
await expect(installBrowserSessionPartitionPolicies(profile)).rejects.toThrow(
'proxy settlement failed'
)
const failedCallback = vi.fn()
handleElectronProxyLogin(
{ preventDefault: vi.fn() } as never,
{ session: sess } as never,
{} as never,
{ isProxy: true, host: 'proxy.example', port: 8080 },
failedCallback
)
expect(failedCallback).not.toHaveBeenCalled()
sess.closeAllConnections.mockResolvedValue(undefined)
await expect(installBrowserSessionPartitionPolicies(profile)).resolves.toBeUndefined()
const recoveredCallback = vi.fn()
handleElectronProxyLogin(
{ preventDefault: vi.fn() } as never,
{ session: sess } as never,
{} as never,
{ isProxy: true, host: 'proxy.example', port: 8080 },
recoveredCallback
)
expect(recoveredCallback).toHaveBeenCalledWith('alice', 'secret')
})
it('cancels an in-flight policy retry when partition policies are cleared', async () => {
let proxyUrl = 'http://old.example:8080'
let finishWrite: (() => void) | undefined
setBrowserNetworkProxySettingsResolver(() => ({ httpProxyUrl: proxyUrl }))
const profile = nextProfile()
const sess = fromPartitionMock(profile.partition)
sess.setProxy.mockImplementationOnce(
() => new Promise<void>((resolve) => (finishWrite = resolve))
)
const installation = installBrowserSessionPartitionPolicies(profile)
await vi.waitFor(() => expect(sess.setProxy).toHaveBeenCalledTimes(1))
clearBrowserSessionPartitionPolicies(profile.partition, sess as never)
proxyUrl = 'http://new.example:8080'
await applyBrowserSessionProxies([], { httpProxyUrl: proxyUrl })
finishWrite?.()
await installation
expect(sess.setProxy).toHaveBeenCalledTimes(1)
})
})
@@ -4,10 +4,7 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { PersistedUIState } from '../../../../shared/persisted-ui-state-types'
import {
browserPageZoomLevelToPercent,
normalizeBrowserPageZoomLevel
} from '../../../../shared/browser-page-zoom'
import { browserPageZoomLevelToPercent } from '../../../../shared/browser-page-zoom'
import { BrowserSiteDisplayController } from './browser-site-display-controller'
import {
configureBrowserSiteDisplayStore,
@@ -55,12 +52,6 @@ function fakeGuest(url: string): FakeGuest {
return guest
}
function commit(controller: BrowserSiteDisplayController, guest: FakeGuest, url: string): void {
guest.url = url
controller.onMainFrameNavigationCommitted(guest.id)
guest.emit('dom-ready')
}
function uiStore(initial: Partial<PersistedUIState> = {}): {
getUI: () => PersistedUIState
onUIChanged: (listener: (ui: PersistedUIState) => void) => () => void
@@ -89,29 +80,6 @@ beforeEach(() => {
})
describe('BrowserSiteDisplayController zoom', () => {
it('keeps zoom per site and browser profile, and resets it to Default Zoom', () => {
const controller = new BrowserSiteDisplayController()
const ui = uiStore({ browserDefaultZoomLevel: normalizeBrowserPageZoomLevel(0.5) })
controller.followZoomSettings(ui)
const github = fakeGuest('https://github.com/a')
const githubOtherPort = fakeGuest('https://github.com:8443/b')
const githubWork = fakeGuest('https://github.com/c')
const docs = fakeGuest('https://docs.example.com/')
controller.onGuestRegistered(github, null)
controller.onGuestRegistered(githubOtherPort, 'default')
controller.onGuestRegistered(githubWork, 'work')
controller.onGuestRegistered(docs, null)
controller.zoomPage(github.id, 'in')
expect([github, githubOtherPort, githubWork, docs].map(percent)).toEqual([125, 125, 110, 110])
commit(controller, docs, 'https://github.com/d')
expect(percent(docs)).toBe(125)
expect(controller.zoomPage(github.id, 'reset')).toMatchObject({ siteHasZoom: false })
expect([github, githubOtherPort, docs].map(percent)).toEqual([110, 110, 110])
})
it("restores the site's zoom after Orca's UI zoom is copied onto the page", () => {
const controller = new BrowserSiteDisplayController()
const ui = uiStore()
@@ -141,41 +109,6 @@ describe('BrowserSiteDisplayController zoom', () => {
})
describe('BrowserSiteDisplayController color scheme', () => {
it('uses chat media for document guests without changing zoom, and upgrades browser registration', () => {
const controller = new BrowserSiteDisplayController()
const preview = fakeGuest('orca-preview://grant/theme.html')
preview.level = 1
controller.onGuestDocumentStart(preview, {
url: preview.url,
profileId: null,
colorScheme: 'dark'
})
controller.onMainFrameNavigationCommitted(preview.id)
expect(preview.mediaSent).toEqual(['dark', 'dark'])
expect(preview.level).toBe(1)
expect(preview.listenerCount('dom-ready')).toBe(0)
const local = fakeGuest('file:///chat-scratch/theme.html')
controller.onGuestDocumentStart(local, {
url: local.url,
profileId: 'work',
colorScheme: 'dark'
})
controller.onGuestRegistered(local, 'work')
controller.setColorScheme(local.id, 'light')
controller.setGuestColorSchemeFallback(local, 'light')
controller.setGuestColorSchemeFallback(local, 'dark')
expect(local.mediaSent.at(-1)).toBe('light')
expect(controller.getPageDisplay(local.id)?.colorScheme).toBe('light')
controller.setColorScheme(local.id, null)
expect(local.mediaSent.at(-1)).toBe('dark')
controller.setGuestColorSchemeFallback(local, undefined)
commit(controller, local, 'https://ordinary.example/')
expect(local.mediaSent.slice(-2)).toEqual(['dark', ''])
controller.onGuestRetired(preview.id)
expect(preview.debugger.isAttached()).toBe(false)
})
it.each([
{ order: 'read-first', profileId: 'work', colorScheme: 'light', saved: 'dark' },
{ order: 'registration-first', profileId: 'work', colorScheme: 'light', saved: 'dark' }
@@ -20,19 +20,12 @@ import {
BROWSER_PAGE_PROMPT_TIMEOUT_MS,
respondToBrowserPagePrompt
} from '../../guest-lifecycle/prompts/browser-page-prompts'
import {
decideBrowserSitePermissions,
isBrowserSitePermissionAllowed
} from './browser-site-permission-prompt'
import { decideBrowserSitePermissions } from './browser-site-permission-prompt'
import {
savedBrowserSitePermissions,
sessionBrowserSitePermissions,
type BrowserSitePermissionMemory
} from './browser-site-permission-memory'
import {
configureBrowserSitePermissionStore,
listBrowserSitePermissions
} from './browser-site-permission-store'
import { configureBrowserSitePermissionStore } from './browser-site-permission-store'
type MockGuest = Electron.WebContents & EventEmitter
@@ -83,20 +76,6 @@ function ask(
return decideBrowserSitePermissions({ requester: guest, frameOrigin, memory, permissions })
}
function allowedOnCheck(
permissions: BrowserSitePermission[],
{
memory = savedBrowserSitePermissions('default'),
frameOrigin = 'https://meet.example.com'
}: { memory?: BrowserSitePermissionMemory; frameOrigin?: string } = {}
): boolean {
return isBrowserSitePermissionAllowed({
source: { pageUrl: 'https://meet.example.com/room', frameOrigin },
memory,
permissions
})
}
async function answerLatest(value: unknown): Promise<void> {
const request = sentRequests(renderer).at(-1)
if (!request) {
@@ -140,57 +119,4 @@ describe('browser site permission prompts', () => {
await expect(ask(['camera', 'microphone'])).resolves.toBe(false)
expect(sentRequests(renderer)).toHaveLength(2)
})
it("keeps a paired server's answers for its session only, where checks still see them", async () => {
const pairedSession = sessionBrowserSitePermissions('persist:paired-a')
const call = ask(['notifications'], { memory: pairedSession })
await answerLatest('allow')
await expect(call).resolves.toBe(true)
expect(allowedOnCheck(['notifications'], { memory: pairedSession })).toBe(true)
await expect(ask(['notifications'], { memory: pairedSession })).resolves.toBe(true)
expect(sentRequests(renderer)).toHaveLength(1)
expect(listBrowserSitePermissions('default')).toEqual([])
void ask(['notifications'], { memory: sessionBrowserSitePermissions('persist:paired-b') })
expect(sentRequests(renderer)).toHaveLength(2)
})
it("gives another site's frame only its own notification answer, and never asks for it", async () => {
const tab = ask(['notifications'])
await answerLatest('allow')
await tab
const widgetPage = ask(['notifications'], { guest: mockGuest('https://widget.example.net/') })
await answerLatest('allow')
await widgetPage
expect(allowedOnCheck(['notifications'], { frameOrigin: 'https://widget.example.net' })).toBe(
true
)
await expect(
ask(['notifications'], { frameOrigin: 'https://widget.example.net' })
).resolves.toBe(true)
expect(allowedOnCheck(['notifications'], { frameOrigin: 'https://ads.example.net' })).toBe(
false
)
await expect(ask(['notifications'], { frameOrigin: 'https://ads.example.net' })).resolves.toBe(
false
)
expect(sentRequests(renderer)).toHaveLength(2)
})
it('remembers answers across a restart', async () => {
const directory = mkdtempSync(join(tmpdir(), 'orca-site-permissions-'))
configureBrowserSitePermissionStore(directory)
const call = ask(['midi'])
await answerLatest('allow')
await call
// Switch directories to evict the cache and verify the next read comes from disk.
configureBrowserSitePermissionStore(mkdtempSync(join(tmpdir(), 'orca-site-permissions-')))
expect(listBrowserSitePermissions('default')).toEqual([])
configureBrowserSitePermissionStore(directory)
expect(listBrowserSitePermissions('default')).toEqual([
{ origin: 'https://meet.example.com', decisions: { midi: 'allow' } }
])
})
})
@@ -83,24 +83,20 @@ it('rolls back the entire Codex exit settlement when a later row fails', async (
const diskBefore = liveTestJournalRows(database.db, SESSION)
const sawEarlierInserts: number[] = []
const transaction = database.transaction.bind(database)
vi.spyOn(database, 'transaction').mockImplementation((run, streamed, committed) =>
transaction(
(db) => {
try {
return run(db)
} catch (error) {
sawEarlierInserts.push(liveTestJournalRows(db, SESSION).length - diskBefore.length)
expect(journal.snapshot()).toEqual(before)
expect(commits).not.toHaveBeenCalled()
expect(
published.every((snapshot) => JSON.stringify(snapshot) === JSON.stringify(before))
).toBe(true)
throw error
}
},
streamed,
committed
)
vi.spyOn(database, 'transaction').mockImplementation((run, committed) =>
transaction((db) => {
try {
return run(db)
} catch (error) {
sawEarlierInserts.push(liveTestJournalRows(db, SESSION).length - diskBefore.length)
expect(journal.snapshot()).toEqual(before)
expect(commits).not.toHaveBeenCalled()
expect(
published.every((snapshot) => JSON.stringify(snapshot) === JSON.stringify(before))
).toBe(true)
throw error
}
}, committed)
)
database.db.exec(`CREATE TEMP TRIGGER reject_codex_later BEFORE INSERT ON journal_rows
WHEN NEW.session_id = '${SESSION}' AND NEW.seq > ${before.cursor.sequence + 1}
@@ -0,0 +1,12 @@
import { describe, expect, it } from 'vitest'
import { mapBridgeError } from './desktop-script-provider-bridge'
describe('mapBridgeError', () => {
it('keeps the code a provider refusal names', () => {
expect(
mapBridgeError(
'element_not_enabled: element 4 is disabled, so no input was sent; run get-app-state and use a fresh element index'
).code
).toBe('element_not_enabled')
})
})
@@ -158,6 +158,11 @@ function runBridgeProcess(
export function mapBridgeError(message: string): RuntimeClientError {
const text = message.trim() || 'desktop provider failed'
// Why: refusals that name their code must keep it even when the explanation matches a looser rule below.
const coded = /^(background_unavailable|element_not_enabled):/.exec(text)?.[1]
if (coded) {
return new RuntimeClientError(coded, text)
}
if (/text_input_incomplete|window_not_responding/i.test(text)) {
return new RuntimeClientError('action_timeout', text)
}
@@ -178,7 +183,7 @@ export function mapBridgeError(message: string): RuntimeClientError {
return new RuntimeClientError('unsupported_capability', text)
}
if (
/unsupported mouse button|unsupported scroll direction|unsupported (?:key|modifier)|windowId is not supported|must be a positive|must be a finite number|\b(?:x|y|from_x|from_y|to_x|to_y|pages|click_count|text|key|direction) is required\b/i.test(
/unsupported mouse button|unsupported scroll direction|outside the target window|unsupported (?:key|modifier)|windowId is not supported|must be a positive|must be a finite number|\b(?:x|y|from_x|from_y|to_x|to_y|pages|click_count|text|key|direction) is required\b/i.test(
text
)
) {
@@ -14,8 +14,10 @@ import {
const SAMPLING_INTERVAL_BYTES = 512 * 1024
const START_HEAP_RATIO = 0.6
const STOP_HEAP_RATIO = 0.5
// Main refreshes independently because hidden renderers throttle memory samples.
const PROFILE_REFRESH_MS = 30_000
// Each profile read forces a full renderer GC, so after the first read require this much growth.
const PROFILE_READ_GROWTH_RATIO = 0.05
// Main checks the heap itself because hidden renderers throttle memory samples.
const HEAP_CHECK_MS = 30_000
export type ProfiledDebugger = {
isAttached(): boolean
@@ -61,8 +63,8 @@ export function profiledRendererFor(contents: WebContents): ProfiledRenderer {
export type RendererHeapReading = {
heapRatio: number
heapLimitBytes: number
rendererSurface: string
isHighwaterMark: boolean
}
type SamplingSession = {
@@ -70,9 +72,13 @@ type SamplingSession = {
startedAtPct: number
startedAtMs: number
trustedScriptPrefix: string | null
heapLimitBytes: number
admittedHeapRatio: number
profileReadAdmitted: boolean
// Wait for startSampling before requesting a profile.
ready: boolean
reading: boolean
checking: boolean
stop: () => void
}
@@ -94,23 +100,31 @@ export function noteRendererHeapReading(
}
return
}
if (reading.heapRatio < STOP_HEAP_RATIO) {
noteSessionHeapRatio(renderer, session, reading.heapRatio, false, record)
}
function noteSessionHeapRatio(
renderer: ProfiledRenderer,
session: SamplingSession,
heapRatio: number,
fromHeapCheck: boolean,
record: (data: CrashReportBreadcrumbData) => void
): void {
if (heapRatio < STOP_HEAP_RATIO) {
session.stop()
return
}
if (reading.isHighwaterMark) {
refreshProfile(renderer, session, record)
}
}
function refreshProfile(
renderer: ProfiledRenderer,
session: SamplingSession,
record: (data: CrashReportBreadcrumbData) => void
): void {
if (!session.ready || session.reading) {
return
}
// The first heap check reads even without growth so a steady high heap still has evidence.
const firstReadDue = fromHeapCheck && !session.profileReadAdmitted
if (!firstReadDue && heapRatio < session.admittedHeapRatio + PROFILE_READ_GROWTH_RATIO) {
return
}
// Admission, not success, consumes the allowance: a failed read may already have paid for its GC.
session.profileReadAdmitted = true
session.admittedHeapRatio = Math.max(session.admittedHeapRatio, heapRatio)
session.reading = true
void renderer.debugger
.sendCommand('HeapProfiler.getSamplingProfile')
@@ -137,7 +151,7 @@ function startSampling(
record: (data: CrashReportBreadcrumbData) => void
): void {
const debug = renderer.debugger
// Do not take over a debugger attached by DevTools or another diagnostic client.
// Only sees Orca's own debugger client (the lag capture); other CDP sessions are not visible here.
if (debug.isAttached()) {
return
}
@@ -159,7 +173,7 @@ function startSampling(
debug.removeListener('detach', onDetach)
unwatch()
if (!renderer.isDestroyed() && debug.isAttached()) {
void debug.sendCommand('HeapProfiler.stopSampling').catch(() => undefined)
// Detaching ends the inspector session, which stops sampling without stopSampling's forced GC.
try {
debug.detach()
} catch {
@@ -175,8 +189,12 @@ function startSampling(
startedAtPct: Math.round(reading.heapRatio * 100),
startedAtMs: Date.now(),
trustedScriptPrefix: trustedScriptPrefixFor(renderer.getURL()),
heapLimitBytes: reading.heapLimitBytes,
admittedHeapRatio: reading.heapRatio,
profileReadAdmitted: false,
ready: false,
reading: false,
checking: false,
stop
}
sessions.set(renderer.id, session)
@@ -192,15 +210,40 @@ function startSampling(
return
}
session.ready = true
refreshTimer = setInterval(
() => refreshProfile(renderer, session, record),
PROFILE_REFRESH_MS
)
refreshTimer = setInterval(() => checkHeap(renderer, session, record), HEAP_CHECK_MS)
refreshTimer.unref?.()
})
.catch(stop)
}
function checkHeap(
renderer: ProfiledRenderer,
session: SamplingSession,
record: (data: CrashReportBreadcrumbData) => void
): void {
if (session.checking) {
return
}
session.checking = true
void renderer.debugger
.sendCommand('Runtime.getHeapUsage')
.then((result) => {
const used =
result && typeof result === 'object' && 'usedSize' in result ? result.usedSize : undefined
if (
typeof used === 'number' &&
Number.isFinite(used) &&
sessions.get(renderer.id) === session
) {
noteSessionHeapRatio(renderer, session, used / session.heapLimitBytes, true, record)
}
})
.catch(() => undefined)
.finally(() => {
session.checking = false
})
}
export function profileRendererMemoryBreadcrumb(
renderer: ProfiledRenderer,
args: { name?: unknown; data?: unknown } | undefined,
@@ -217,21 +260,29 @@ export function profileRendererMemoryBreadcrumb(
function readHeapReading(
args: { name?: unknown; data?: unknown } | undefined
): RendererHeapReading | null {
const isHighwaterMark = args?.name === 'renderer_memory_highwater'
const data = args?.data
if ((!isHighwaterMark && args?.name !== 'renderer_memory') || !data || typeof data !== 'object') {
if (
(args?.name !== 'renderer_memory_highwater' && args?.name !== 'renderer_memory') ||
!data ||
typeof data !== 'object'
) {
return null
}
const used = 'usedHeapMB' in data ? data.usedHeapMB : undefined
const limit = 'heapLimitMB' in data ? data.heapLimitMB : undefined
if (typeof used !== 'number' || typeof limit !== 'number' || !(limit > 0)) {
if (
typeof used !== 'number' ||
!Number.isFinite(used) ||
typeof limit !== 'number' ||
!(limit > 0)
) {
return null
}
const surface = 'rendererSurface' in data ? data.rendererSurface : undefined
return {
heapRatio: used / limit,
rendererSurface: typeof surface === 'string' ? surface : 'unknown',
isHighwaterMark
heapLimitBytes: limit * 1024 * 1024,
rendererSurface: typeof surface === 'string' ? surface : 'unknown'
}
}
@@ -27,7 +27,9 @@ function skippedCrashDiagnosticBundle(): CrashDiagnosticBundleAttachment {
}
}
async function collectCrashDiagnosticBundleAttachment(): Promise<CrashDiagnosticBundleAttachment> {
async function collectCrashDiagnosticBundleAttachment(
nativeHangCaptureId?: string
): Promise<CrashDiagnosticBundleAttachment> {
const status = getDiagnosticsStatus()
if (!status.bundleEnabled) {
return {
@@ -47,7 +49,8 @@ async function collectCrashDiagnosticBundleAttachment(): Promise<CrashDiagnostic
osRelease: os.release(),
orcaChannel: resolveDiagnosticOrcaChannel(),
// Reports after relaunch need logs older than the default 30-minute support window.
lookbackMinutes: CRASH_REPORT_LOG_LOOKBACK_MINUTES
lookbackMinutes: CRASH_REPORT_LOG_LOOKBACK_MINUTES,
nativeHangCaptureId
})
} catch (error) {
return { diagnosticBundle: { status: 'not_uploaded', reason: formatUnknownError(error) } }
@@ -70,10 +73,11 @@ async function collectCrashDiagnosticBundleAttachment(): Promise<CrashDiagnostic
}
export async function prepareCrashDiagnosticBundle(
includeDiagnosticLogs: boolean
includeDiagnosticLogs: boolean,
nativeHangCaptureId?: string
): Promise<CrashDiagnosticBundleAttachment> {
return includeDiagnosticLogs
? collectCrashDiagnosticBundleAttachment()
? collectCrashDiagnosticBundleAttachment(nativeHangCaptureId)
: skippedCrashDiagnosticBundle()
}
@@ -92,16 +92,7 @@ export class CrashReportStore {
async record(input: CrashReportCreateInput): Promise<CrashReportRecord> {
return this.withWrite(async (reports) => {
const report: CrashReportRecord = {
...input,
id: crypto.randomUUID(),
createdAt: new Date().toISOString(),
status: 'pending',
details: sanitizeCrashReportDetails(input.details),
breadcrumbs: sanitizeCrashReportBreadcrumbs(input.breadcrumbs)?.map(
({ origin: _origin, ...breadcrumb }) => breadcrumb
)
}
const report = this.createRecord(input)
return {
reports: [report, ...reports].slice(0, MAX_REPORTS),
result: report
@@ -109,6 +100,41 @@ export class CrashReportStore {
})
}
async importHangReport(input: CrashReportCreateInput): Promise<CrashReportRecord | null> {
return this.withWrite(async (reports) => {
const existing = reports.find(
(report) =>
report.reason === 'main-thread-hang' &&
report.details.captureId === input.details.captureId
)
if (existing) {
return { reports, result: existing }
}
if (
reports.some(
(report) => report.status === 'pending' && report.reason !== 'main-thread-hang'
)
) {
return { reports, result: null }
}
const report: CrashReportRecord = { ...this.createRecord(input), status: 'dismissed' }
return { reports: [report, ...reports].slice(0, MAX_REPORTS), result: report }
})
}
private createRecord(input: CrashReportCreateInput): CrashReportRecord {
return {
...input,
id: crypto.randomUUID(),
createdAt: new Date().toISOString(),
status: 'pending',
details: sanitizeCrashReportDetails(input.details),
breadcrumbs: sanitizeCrashReportBreadcrumbs(input.breadcrumbs)?.map(
({ origin: _origin, ...breadcrumb }) => breadcrumb
)
}
}
/** Crashpad may finish writing after process-gone; merge its signature into the existing report. */
async attachDetails(
id: string,
@@ -203,7 +229,9 @@ export class CrashReportStore {
// Reading directly avoids waiting on the write chain this callback already owns.
const reports = await this.readReportsFromDisk()
const { reports: nextReports, result } = await mutate(reports)
await this.writeReports(nextReports)
if (nextReports !== reports) {
await this.writeReports(nextReports)
}
return result
})
this.writeChain = run.then(
@@ -1,44 +0,0 @@
import { mkdtempSync, readFileSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { describe, expect, it, vi } from 'vitest'
import { DaemonAutomaticWorkGuard } from '../daemon-automatic-work-guard'
import { DaemonProcessGrowthMonitor } from '../daemon-process-growth-monitor'
describe('DaemonAutomaticWorkGuard stop request', () => {
it('records an outside stop on the marker before teardown, so a cut-short exit is no crash', () => {
const dir = mkdtempSync(join(tmpdir(), 'orca-stop-'))
try {
const markerPath = join(dir, 'daemon-v1.automatic-work-run.json')
const options = {
markerPath,
mode: 'enforce' as const,
log: { log: vi.fn() },
listSessions: () => [],
ownsEndpoint: () => true,
monitor: new DaemonProcessGrowthMonitor({
listSessions: () => [],
recentAutomaticWork: () => false,
sampleWhenIdle: false,
onLevelChanged: () => {},
onTrip: () => {},
readRows: async () => []
})
}
const guard = new DaemonAutomaticWorkGuard(options)
guard.start()
guard['marker'].arm()
expect(JSON.parse(readFileSync(markerPath, 'utf8'))).toMatchObject({ state: 'running' })
guard.recordStopRequested()
expect(JSON.parse(readFileSync(markerPath, 'utf8'))).toMatchObject({ state: 'clean-exit' })
const next = new DaemonAutomaticWorkGuard(options)
next.start()
expect(next.status().mode).toEqual({ kind: 'normal' })
next.stop()
} finally {
rmSync(dir, { recursive: true, force: true })
}
})
})
+2
View File
@@ -29,10 +29,12 @@ import { startDaemonScopeDeathWatch } from './process-scopes/daemon-scope-death-
import { isWindowsProcessTableAvailable } from '../windows/windows-process-table'
import { leaseAppImageGenerationForCurrentProcess } from '../cli/appimage/appimage-generation-lease'
import { createDaemonShutdownDeadline } from './launch/daemon-shutdown-deadline'
import { setDaemonProcessName } from './daemon-process-name'
async function main(): Promise<void> {
// The parent closes startup stderr after readiness; later EPIPE errors must not kill the daemon.
process.stderr.on('error', () => {})
setDaemonProcessName()
const {
socketPath,
+13
View File
@@ -0,0 +1,13 @@
import { writeFileSync } from 'node:fs'
export function setDaemonProcessName(): void {
if (process.platform !== 'linux') {
return
}
try {
// process.title overwrites argv, which daemon adoption and recovery use as identity evidence.
writeFileSync('/proc/self/comm', 'orca-agents')
} catch {
// Naming must not prevent terminals starting when procfs is unavailable or read-only.
}
}
@@ -2,7 +2,7 @@ import { readdir } from 'node:fs/promises'
import { join } from 'node:path'
import { runProcess } from '@orca/process-host'
import { rm } from '../../../filesystem/asar-transparent-fs'
import { macDaemonCopyBundlePath } from './macos-daemon-bundle'
import { MAC_TERMINAL_HOST_BUNDLE } from './macos-daemon-bundle'
const LSREGISTER =
'/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister'
@@ -27,13 +27,20 @@ export async function retireUnusedMacDaemonCopy(directory: string): Promise<void
if (!unused) {
return
}
// Direct execution may register this copy with LaunchServices.
await runProcess({
program: LSREGISTER,
args: ['-u', macDaemonCopyBundlePath(directory)],
timeoutMs: 5_000,
maxOutputBytes: 8192
}).catch(() => {})
// Old copies retain their original bundle name when the helper is renamed.
const parent = join(directory, 'app.noindex')
const bundles = await readdir(parent).catch(() => [])
for (const bundle of bundles) {
if (bundle !== MAC_TERMINAL_HOST_BUNDLE && bundle !== 'Orca Terminal Host.app') {
continue
}
await runProcess({
program: LSREGISTER,
args: ['-u', join(parent, bundle)],
timeoutMs: 5_000,
maxOutputBytes: 8192
}).catch(() => {})
}
await rm(directory, { recursive: true, force: true })
} catch {
// Retain code when its use cannot be verified.
@@ -7,9 +7,9 @@ import { rm } from '../../../filesystem/asar-transparent-fs'
import { ensurePrivateDir } from '../../endpoint/publication/daemon-private-file-modes'
import { inspectMacProcessCodeIdentity } from './daemon-mac-code-identity'
/** Plain Node helper packaged by config/scripts/macos-terminal-host-bundle.cjs. */
export const MAC_TERMINAL_HOST_BUNDLE = 'Orca Terminal Host.app'
export const MAC_TERMINAL_HOST_EXECUTABLE = 'orca-terminal-host'
/** Plain Node helper packaged by config/scripts/macos/terminal-host-bundle/macos-terminal-host-bundle.cjs. */
export const MAC_TERMINAL_HOST_BUNDLE = 'Orca Agents.app'
export const MAC_TERMINAL_HOST_EXECUTABLE = 'Orca Agents'
export const MAC_DAEMON_COPY_PREFIX = 'terminal-host-'
export type MacDaemonBundle = {
@@ -29,9 +29,9 @@ afterEach(async () => {
await rm(root, { recursive: true, force: true })
})
async function copy(name: string): Promise<string> {
async function copy(name: string, bundle = 'Orca Agents.app'): Promise<string> {
const directory = join(root, name)
await mkdir(join(directory, 'app.noindex', 'Orca Terminal Host.app'), { recursive: true })
await mkdir(join(directory, 'app.noindex', bundle), { recursive: true })
return directory
}
@@ -68,6 +68,27 @@ it.each([
expect(await exists(directory)).toBe(true)
})
it.each(['Orca Agents.app', 'Orca Terminal Host.app'])(
'unregisters an unused %s before deleting its copy',
async (bundle) => {
const directory = await copy('terminal-host-old', bundle)
run.mockImplementation(async (spec) => {
if (spec.program === LSREGISTER) {
expect(await exists(directory)).toBe(true)
}
return unused
})
await retireUnusedMacDaemonCopy(directory)
expect(run).toHaveBeenCalledWith(
expect.objectContaining({
program: LSREGISTER,
args: ['-u', join(directory, 'app.noindex', bundle)]
})
)
expect(await exists(directory)).toBe(false)
}
)
it("retires every unused copy and staging dir except the current build's and this process's staging", async () => {
const current = await copy('terminal-host-current')
const oldUnused = await copy('terminal-host-old')
@@ -35,13 +35,13 @@ let verifyCode: number
let onCopy: (() => Promise<void>) | null
const signal = (): AbortSignal => new AbortController().signal
const helperExecutable = (): string => join(helper, 'Contents', 'MacOS', 'orca-terminal-host')
const helperExecutable = (): string => join(helper, 'Contents', 'MacOS', 'Orca Agents')
beforeEach(async () => {
root = await realpath(await mkdtemp(join(tmpdir(), 'orca-mac-runtime-')))
// Model an update that moved the running bundle and replaced its original path.
source = join(root, 'com.stablyai.orca.ShipIt.old', 'Orca.app')
helper = join(source, 'Contents', 'Helpers', 'Orca Terminal Host.app')
helper = join(source, 'Contents', 'Helpers', 'Orca Agents.app')
copies = join(root, 'profile', 'daemon-host', 'macos')
await mkdir(join(source, 'Contents', 'MacOS'), { recursive: true })
await mkdir(join(helper, 'Contents', 'MacOS'), { recursive: true })
@@ -101,14 +101,7 @@ it('publishes one verified copy per helper build, from the running app rather th
await rm(source, { recursive: true })
expect(copy.directory).toBe(join(copies, `terminal-host-${cdhash}`))
expect(copy.execPath).toBe(
join(
copy.directory,
'app.noindex',
'Orca Terminal Host.app',
'Contents',
'MacOS',
'orca-terminal-host'
)
join(copy.directory, 'app.noindex', 'Orca Agents.app', 'Contents', 'MacOS', 'Orca Agents')
)
expect(await readFile(copy.execPath, 'utf8')).toBe('signed-node')
expect(await readFile(copy.entryPath, 'utf8')).toBe('daemon-code')
@@ -136,9 +129,9 @@ it("rejects a copy that does not satisfy the running app's requirement and publi
it('keeps the winning copy when a concurrent publisher renames first', async () => {
onCopy = async () => {
onCopy = null
const winner = join(copies, `terminal-host-${cdhash}`, 'app.noindex', 'Orca Terminal Host.app')
const winner = join(copies, `terminal-host-${cdhash}`, 'app.noindex', 'Orca Agents.app')
await cp(helper, winner, { recursive: true })
await writeFile(join(winner, 'Contents', 'MacOS', 'orca-terminal-host'), 'winner')
await writeFile(join(winner, 'Contents', 'MacOS', 'Orca Agents'), 'winner')
}
const copy = await materializeMacDaemonBundle(copies, signal())
expect(await readFile(copy.execPath, 'utf8')).toBe('winner')
@@ -155,7 +148,7 @@ it('stops at the shared deadline and removes the staging copy', async () => {
return controller.signal.aborted ? { ...ok, code: null } : (signed?.(spec) ?? ok)
})
await expect(materializeMacDaemonBundle(copies, controller.signal)).rejects.toThrow(
'Could not copy Orca Terminal Host.app'
'Could not copy Orca Agents.app'
)
expect(await readdir(copies)).toEqual([])
})
@@ -92,7 +92,7 @@ const nativePlatform = process.platform
const originalGetuid = Object.getOwnPropertyDescriptor(process, 'getuid')
const originalGetSystemVersion = Object.getOwnPropertyDescriptor(process, 'getSystemVersion')
const roomyDeadline = (): number => Date.now() + 60_000
const helperExec = (): string => join(copyDir, 'MacOS', 'orca-terminal-host')
const helperExec = (): string => join(copyDir, 'MacOS', 'Orca Agents')
const helperEntry = (): string => join(copyDir, 'daemon-entry.js')
const service = (): string => `gui/501/${macDaemonJobLabel(state.root)}`
const launchctlCalls = (verb: string): ProcessSpec[] =>
@@ -82,22 +82,6 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => {
expect(setLocalPtyProviderMock).not.toHaveBeenCalled()
})
it('does not kill a preserved daemon when startup lease acquisition fails', async () => {
const mod = await importFresh()
ensureRunningOverrides.push(async () => ({
socketPath: '/fake/preserved-socket',
tokenPath: '/fake/preserved-token'
}))
lifecycleLeaseErrors.push(new Error('preserved lease failed'))
await expect(mod.initDaemonPtyProvider()).rejects.toThrow('preserved lease failed')
expect(adoptionLeaseReleases[0]).toHaveBeenCalledOnce()
expect(adapterInstances[0].disconnectOnly).toHaveBeenCalledOnce()
expect(spawnerInstances[0].shutdown).not.toHaveBeenCalled()
expect(setLocalPtyProviderMock).not.toHaveBeenCalled()
})
it('does not install a late daemon provider after startup fallback aborts the init attempt', async () => {
const mod = await importFresh()
let resolveEnsureRunning!: (value: { socketPath: string; tokenPath: string }) => void
@@ -7,8 +7,7 @@ import { createAdapter } from '../../routing/testing/daemon-pty-router-test-fixt
import {
disconnectDaemon,
listLiveDaemonSessions,
replaceDaemonProvider,
requestIdleDaemonRetirement
replaceDaemonProvider
} from '../daemon-provider-state'
import { PROTOCOL_VERSION } from '../../../protocol/daemon-protocol-version'
@@ -16,11 +15,6 @@ afterEach(async () => {
await disconnectDaemon()
})
it('reads a census without an installed daemon as unverifiable, never as empty', async () => {
await expect(listLiveDaemonSessions()).resolves.toBeNull()
await expect(requestIdleDaemonRetirement()).resolves.toEqual({ state: 'unverifiable' })
})
it('reads a census with an unanswered generation as unverifiable', async () => {
const current = createAdapter('current', ['live-1'], undefined, PROTOCOL_VERSION)
const legacy = createAdapter('legacy', [], undefined, PROTOCOL_VERSION)
@@ -1,39 +0,0 @@
import '../../pty-subprocess/testing/mock-descendant-sweep'
import { describe, expect, it } from 'vitest'
import { HeadlessEmulator } from '../../emulation/headless-emulator'
// This fixture assembles the payload locally; it does not exercise the adapter’s assembly.
function buildReattachPayload(snapshot: ReturnType<HeadlessEmulator['getSnapshot']>) {
const isAltScreen = snapshot.modes.alternateScreen
const fullPayload = snapshot.scrollbackAnsi + snapshot.rehydrateSequences + snapshot.snapshotAnsi
return {
rehydrateSequences: snapshot.rehydrateSequences,
snapshotAnsi: snapshot.snapshotAnsi,
fullPayload,
isAlternateScreen: isAltScreen,
cols: snapshot.cols,
rows: snapshot.rows
}
}
describe('reattach snapshot flow', () => {
describe('alternate-screen TUI (Codex style)', () => {
it('returns to preserved shell history after the reattached TUI exits', async () => {
const emulator = new HeadlessEmulator({ cols: 80, rows: 10 })
await emulator.write('shell output before Codex\r\n$ codex')
await emulator.write('\x1b[?1049h\x1b[2J\x1b[HCodex TUI content')
const payload = buildReattachPayload(emulator.getSnapshot())
emulator.dispose()
const replay = new HeadlessEmulator({ cols: 80, rows: 10 })
try {
await replay.write(payload.fullPayload)
expect(replay.getVisibleLines().join('\n')).toContain('Codex TUI content')
await replay.write('\x1b[?1049l')
expect(replay.getVisibleLines().join('\n')).toContain('shell output before Codex')
} finally {
replay.dispose()
}
})
})
})
@@ -0,0 +1,70 @@
import { once } from 'node:events'
import { createInterface } from 'node:readline'
import { spawnProcess } from '@orca/process-host'
import { describe, expect, it } from 'vitest'
import { inspectDaemonProcessIdentity } from '../endpoint/identity/daemon-pid-identity'
const namingModule = new URL('../daemon-process-name.ts', import.meta.url)
describe.skipIf(process.platform !== 'linux')('Linux daemon process naming', () => {
it.each([false, true])(
'preserves live daemon identity when naming writes are denied: %s',
async (denyWrites) => {
const socketPath = '/tmp/orca naming contract/daemon.sock'
const tokenPath = `/tmp/orca naming contract/${'long endpoint component/'.repeat(12)}daemon.token`
const script = `
import { readFileSync } from 'node:fs'
import { setDaemonProcessName } from ${JSON.stringify(namingModule.href)}
const before = readFileSync('/proc/self/cmdline').toString('base64')
const beforeName = readFileSync('/proc/self/comm').toString('base64')
setDaemonProcessName()
process.stdin.resume()
console.log([
before,
readFileSync('/proc/self/cmdline').toString('base64'),
beforeName,
readFileSync('/proc/self/comm').toString('base64')
].join(' '))
`
const child = spawnProcess({
program: process.env.ORCA_TEST_NODE_EXECUTABLE ?? process.execPath,
args: [
...(denyWrites ? ['--permission', '--allow-fs-read=*'] : []),
'--input-type=module',
'-e',
script,
'--',
'daemon-entry.js',
'--socket',
socketPath,
'--token',
tokenPath
]
})
const exited = once(child, 'exit')
const lines = createInterface({ input: child.stdout })
try {
const [line] = await Promise.race([
once(lines, 'line'),
exited.then(() => {
throw new Error('Naming fixture exited before publishing its process identity')
})
])
const [before, after, beforeName, afterName] = String(line).split(' ')
expect(Buffer.from(before, 'base64').toString()).toContain(tokenPath)
expect(after).toBe(before)
expect(Buffer.from(afterName, 'base64').toString()).toBe(
denyWrites ? Buffer.from(beforeName, 'base64').toString() : 'orca-agents\n'
)
expect(child.pid).toBeDefined()
expect(
await inspectDaemonProcessIdentity(child.pid ?? 0, socketPath, tokenPath, null)
).toBe('match')
} finally {
lines.close()
child.stdin.end()
await exited
}
}
)
})
@@ -1,5 +1,15 @@
import { EventEmitter } from 'node:events'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
// Callback fixtures exercise native execFile; Mac spawn capture has real-child coverage.
const restorePlatform = vi.hoisted(() => {
const platform = process.platform
if (platform === 'darwin') {
Object.defineProperty(process, 'platform', { value: 'linux' })
}
return () => Object.defineProperty(process, 'platform', { value: platform })
})
afterAll(restorePlatform)
const { execFileMock } = vi.hoisted(() => ({ execFileMock: vi.fn() }))
vi.mock('node:child_process', () => ({
@@ -1,7 +1,17 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type * as GitFetchHeadLockModule from '../../../shared/git/fetch/git-fetch-head-lock'
// Callback fixtures exercise native execFile; Mac spawn capture has real-child coverage.
const restorePlatform = vi.hoisted(() => {
const platform = process.platform
if (platform === 'darwin') {
Object.defineProperty(process, 'platform', { value: 'linux' })
}
return () => Object.defineProperty(process, 'platform', { value: platform })
})
afterAll(restorePlatform)
const {
execFileMock,
spawnMock,
@@ -1,4 +1,5 @@
import { execFileSync, type SpawnOptions } from 'node:child_process'
import type { SpawnOptions } from 'node:child_process'
import { execNativeFileSync } from '@orca/process-host/native-process-spawn'
import { recordSubprocessSpawn } from '../../diagnostics/main-thread-churn-probe'
import { untranslatedGitOutputEnv } from './git-environment/git-process-env'
import { resolveCommand } from './host-routing/wsl-command-resolution'
@@ -28,17 +29,18 @@ export function gitExecFileSync(
// Host Git only: a WSL path would make this a synchronous wsl.exe that can boot the distro.
throw new Error('Synchronous Git cannot run in a WSL folder. Use asynchronous Git for it.')
}
const spawnStartedAt = performance.now()
try {
return execFileSync(resolved.binary, resolved.args, {
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: A BufferEncoding is always supplied, so execFileSync returns text.
return execNativeFileSync(
resolved.binary,
resolved.args,
{
cwd: resolved.cwd,
encoding: options.encoding ?? 'utf-8',
env: untranslatedGitOutputEnv(),
stdio: options.stdio ?? ['pipe', 'pipe', 'pipe'],
timeout: options.timeout ?? GIT_EXEC_SYNC_TIMEOUT_MS,
windowsHide: true
}) as string
} finally {
recordSubprocessSpawn(resolved.binary, resolved.args, performance.now() - spawnStartedAt)
}
},
recordSubprocessSpawn
) as string
}
+17 -8
View File
@@ -27,11 +27,7 @@ import {
} from '../../../shared/git/commands/git-command-timeout'
import { classifyGitCommand } from '../../../shared/git/commands/git-command-classification'
import { createAbortError } from './process-capture/abort-error'
import {
readCapturedGitBuffer,
readCapturedGitString,
withCapturedErrorStdout
} from './process-capture/captured-git-output'
import { readCapturedGitBuffer, readCapturedGitString } from './process-capture/captured-git-output'
import { runUnlessGitCwdKnownMissing } from './host-routing/missing-git-spawn-cwd'
import { trackGitWorktreeMutation } from './git-worktree-mutation-state'
import { trackGitRefMutation } from './git-ref-mutation-state'
@@ -45,7 +41,7 @@ async function gitExecFileAsyncUnlocked(
): Promise<{ stdout: string; stderr: string }> {
// Trace caller intent rather than the resolved binary so WSL and native commands group together.
return withGitSpan(
{ args, ...(options.cwd !== undefined ? { cwd: options.cwd } : {}) },
{ args, cwd: options.cwd, admissionTier: options.admissionTier },
async (span) => {
if (options.onStderr && options.terminationBarrier) {
throw new Error('onStderr is not supported with terminationBarrier.')
@@ -106,7 +102,6 @@ async function gitExecFileAsyncUnlocked(
env: policy.env,
signal: options.signal,
terminationBarrier: options.terminationBarrier,
requireGuestTerminationProof: options.requireGuestTerminationProof,
admissionTier: options.admissionTier,
spawnOnWorkerThread: options.spawnOnWorkerThread,
onChildTerminated: reportTerminated,
@@ -142,6 +137,9 @@ async function gitExecFileAsyncUnlocked(
) {
await terminationState.current
const wasMissing = invalidateMissingDirectWslGit(error, resolved)
if (options.canStart?.() === false) {
throw createAbortError()
}
const fallback = resolveGitCommand(args, effectiveOptions, true, true)
result = await capture(fallback)
// Why: matching failures can be normal Git control flow; only a successful login retry proves the direct environment was insufficient.
@@ -185,6 +183,16 @@ async function gitExecFileAsyncUnlocked(
)
}
/** Callers parse stdout of non-zero exits (check-ignore exit 1), so strip the fence there too. */
function withCapturedErrorStdout(error: unknown, command: ResolvedCommand): unknown {
if (command.captured && error && typeof error === 'object' && 'stdout' in error) {
if (typeof error.stdout === 'string') {
error.stdout = readCapturedGitString(error.stdout, command)
}
}
return error
}
export function gitExecFileAsync(args: string[], callerOptions: GitExecOptions) {
const options = routeLocalGitOptions(callerOptions)
const releaseMutation = trackGitWorktreeMutation(args, options)
@@ -220,7 +228,8 @@ async function gitExecFileAsyncBufferInSpan(
args: string[],
options: GitBufferExecOptions
): Promise<{ stdout: Buffer }> {
return withGitSpan({ args, cwd: options.cwd }, async (span) => {
const meta = { args, cwd: options.cwd, admissionTier: options.admissionTier }
return withGitSpan(meta, async (span) => {
const checkoutRoutingReady = pendingWslDriveCheckoutGitRouting(args, options)
if (checkoutRoutingReady) {
await checkoutRoutingReady
@@ -23,8 +23,6 @@ export type GitExecOptions = {
preferWslDirectGit?: boolean
useConfiguredSshCommandForNetwork?: boolean
terminationBarrier?: boolean
/** Destructive WSL checkout removal must retain ownership until guest death is proved. */
requireGuestTerminationProof?: true
observeChildTermination?: (terminated: Promise<void>, writes: boolean) => void
/** Scheduler priority for this child; status is the safe default. */
admissionTier?: GitAdmissionTier
+15 -11
View File
@@ -1,4 +1,5 @@
import { spawn, type ChildProcess, type SpawnOptions } from 'node:child_process'
import type { ChildProcess, SpawnOptions } from 'node:child_process'
import { spawnNativeProcess } from '@orca/process-host/native-process-spawn'
import { recordSubprocessSpawn } from '../../diagnostics/main-thread-churn-probe'
import { startGitSpan } from '../../observability/instrumentation'
import { createAbortError } from './process-capture/abort-error'
@@ -69,7 +70,7 @@ export async function withGitAdmission(
if (admission) {
await admission
}
const span = startGitSpan({ args, cwd: options.cwd })
const span = startGitSpan({ args, cwd: options.cwd, admissionTier: options.admissionTier })
let grant: Awaited<ReturnType<typeof acquireGitAdmission>> | null = null
try {
grant = await acquireGitAdmission({
@@ -163,13 +164,16 @@ export function gitSpawn(args: string[], callerOptions: GitSpawnOptions): ChildP
...(wslDistro ? { wslDistro } : {}),
...(spawnOptions.env ? { env: spawnOptions.env } : {})
})
const spawnStartedAt = performance.now()
const child = spawn(resolved.binary, resolved.args, {
...spawnOptions,
env: untranslatedGitOutputEnv(spawnOptions.env ?? process.env),
windowsHide: true,
cwd: resolved.cwd
})
recordSubprocessSpawn(resolved.binary, resolved.args, performance.now() - spawnStartedAt)
return child
return spawnNativeProcess(
resolved.binary,
resolved.args,
{
...spawnOptions,
env: untranslatedGitOutputEnv(spawnOptions.env ?? process.env),
windowsHide: true,
cwd: resolved.cwd
},
undefined,
recordSubprocessSpawn
)
}
@@ -69,7 +69,8 @@ function streamInSpan(
maxBuffer: number,
timeoutMs: number | undefined
): Promise<GitStreamResult> {
return withGitSpan({ args, cwd: options.cwd }, async (span) => {
const meta = { args, cwd: options.cwd, admissionTier: options.admissionTier }
return withGitSpan(meta, async (span) => {
const checkoutRoutingReady = pendingWslDriveCheckoutGitRouting(args, options)
if (checkoutRoutingReady) {
await checkoutRoutingReady
@@ -1,10 +1,3 @@
import * as processHost from '@orca/process-host'
import type { ProcessTerminationBarrier } from '@orca/process-host/process-spec'
import {
getLocalWorktreePathAccess,
removeLocalWorktreePath
} from '../../../workspaces/removal/local-worktree-filesystem'
import { removeWorktree } from '../../worktrees/removal/worktree-removal'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createFakeSpawnedChild } from '../../../../shared/__fixtures__/fake-spawned-child'
@@ -167,180 +160,6 @@ describe('WSL direct Git reads', () => {
})
})
it.each([
['generic', false],
['git', true],
['git', false],
['filesystem', true],
['filesystem', false]
] as const)(
'E1 retains %s removal until guest termination is proved: confirmed=%s',
async (kind, confirmed) => {
await withPlatform('win32', async () => {
vi.useFakeTimers()
seedWslGitReadEnvironmentForTests(DISTRO, LOGIN_ENVIRONMENT)
const command = createMockChild()
let guestConfirmed = confirmed
let barrier: ProcessTerminationBarrier | undefined
const runProcess = processHost.runProcess
const processSeam = vi
.spyOn(processHost, 'runProcess')
.mockImplementation((spec, capture) => {
if (spec.terminationBarrier && typeof spec.terminationBarrier === 'object') {
barrier ??= spec.terminationBarrier
}
return runProcess(spec, capture)
})
command.kill.mockImplementation(() => {
queueMicrotask(() => command.emit('close', null, 'SIGKILL'))
return true
})
spawnMock.mockImplementation((_program, args) => {
if (spawnMock.mock.calls.length === 1) {
queueMicrotask(() => {
const marker = String(args?.join(' ')).match(
/(__ORCA_WSL_PROCESS_GROUP_[0-9a-f-]+__=)/
)?.[1]
command.stderr.emit('data', Buffer.from(`${marker}4321\n`))
})
return command
}
const terminator = createMockChild()
queueMicrotask(() => {
const running = args?.includes('--running')
if (running) {
terminator.stdout.emit('data', Buffer.from(`${DISTRO}\n`))
}
terminator.emit('close', running || guestConfirmed ? 0 : 1, null)
})
return terminator
})
const controller = new AbortController()
let terminated = false
let settled = false
const pending =
kind === 'generic'
? gitExecFileAsync(['rebase', 'upstream/main'], {
cwd: String.raw`C:\repo`,
env: { GIT_CONFIG_GLOBAL: '/home/user/custom.gitconfig' },
wslDistro: DISTRO,
timeout: 25,
terminationBarrier: true,
observeChildTermination: (ended) => {
void ended.then(() => {
terminated = true
})
}
})
: kind === 'filesystem'
? removeLocalWorktreePath(
String.raw`C:\repo\feature`,
{ wslDistro: DISTRO },
controller.signal
)
: removeWorktree(String.raw`C:\repo`, String.raw`C:\repo\feature`, false, {
wslDistro: DISTRO,
deleteBranch: false,
knownRemovedWorktree: { branch: 'feature', head: 'abc' },
checkoutDeleteSignal: controller.signal,
observeChildTermination: (ended) => {
void ended.then(() => {
terminated = true
})
}
})
const outcome = pending.then(
() => {
settled = true
},
(error: unknown) => {
settled = true
return error
}
)
try {
await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledTimes(1))
await Promise.resolve()
if (kind !== 'generic') {
controller.abort()
}
await vi.advanceTimersByTimeAsync(15_000)
if (kind === 'generic') {
expect(await outcome).toMatchObject({ name: 'GitCommandTimeoutError', timeoutMs: 25 })
expect(terminated).toBe(true)
expect(settled).toBe(true)
expect(command.kill).toHaveBeenCalled()
} else if (confirmed) {
expect(await outcome).toMatchObject({ name: 'AbortError' })
if (kind === 'git') {
expect(terminated).toBe(true)
}
expect(settled).toBe(true)
} else {
expect(terminated).toBe(false)
expect(settled).toBe(false)
expect(command.kill).toHaveBeenCalled()
// Release the fixture's held owner with independent guest proof, never shim closure.
guestConfirmed = true
if (!barrier) {
throw new Error('guest barrier missing')
}
await barrier.force(command)
expect(await outcome).toMatchObject({ name: 'AbortError' })
}
expect(spawnMock.mock.calls[0]?.[1]?.join(' ')).toContain('setsid --wait')
} finally {
processSeam.mockRestore()
vi.useRealTimers()
}
})
}
)
it.each([false, true])(
'waits beyond30s for WSL filesystem deletion to finish with checkout signal=%s',
async (withSignal) => {
await withPlatform('win32', async () => {
vi.useFakeTimers()
try {
const command = createMockChild()
spawnMock.mockReturnValue(command)
let finished = false
const controller = new AbortController()
const pending = removeLocalWorktreePath(
String.raw`C:\repo\feature`,
{ wslDistro: DISTRO },
withSignal ? controller.signal : undefined
).then(() => {
finished = true
})
await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledOnce())
await vi.advanceTimersByTimeAsync(31_000)
expect(finished).toBe(false)
expect(command.kill).not.toHaveBeenCalled()
command.emit('close', 0, null)
await pending
expect(finished).toBe(true)
if (!withSignal) {
const read = createMockChild()
read.kill.mockImplementation(() => {
queueMicrotask(() => read.emit('close', null, 'SIGTERM'))
return true
})
spawnMock.mockReturnValue(read)
const rejected = expect(
getLocalWorktreePathAccess({ wslDistro: DISTRO }).statPath('/mnt/c/repo/missing')
).rejects.toThrow('timed out after 30000ms')
await vi.advanceTimersByTimeAsync(30_000)
await rejected
}
} finally {
vi.useRealTimers()
}
})
}
)
it('stops waiting for a wedged probe and reads through the shell route', async () => {
await withPlatform('win32', async () => {
vi.useFakeTimers()
@@ -28,13 +28,3 @@ export function readCapturedGitString(stdout: string, resolved: ResolvedCommand)
const endIndex = stdout.indexOf(captured.endMarker, payloadStart)
return endIndex === -1 ? stdout.slice(payloadStart) : stdout.slice(payloadStart, endIndex)
}
/** Callers parse stdout of non-zero exits (check-ignore exit 1), so strip the fence there too. */
export function withCapturedErrorStdout(error: unknown, command: ResolvedCommand): unknown {
if (command.captured && error && typeof error === 'object' && 'stdout' in error) {
if (typeof error.stdout === 'string') {
error.stdout = readCapturedGitString(error.stdout, command)
}
}
return error
}
@@ -0,0 +1,151 @@
import { setImmediate as nextTurn } from 'node:timers/promises'
import { chmod, mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { nativeExecutableResolver } from '@orca/process-host/native-executable-resolution'
import * as nativeSpawn from '@orca/process-host/native-process-spawn'
import { beginLocalCommandSelection } from '../../../ipc/command-path/command-path-resolver'
import { execFileCapture } from './exec-file-capture'
const roots: string[] = []
afterEach(async () => {
vi.restoreAllMocks()
vi.unstubAllEnvs()
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
})
describe.skipIf(process.platform !== 'darwin')('Mac capture during executable lookup', () => {
it.each(['abort', 'deadline'] as const)(
'settles %s before lookup completes without launching late',
async (reason) => {
let release!: (selection: { program: string; file: string }) => void
vi.spyOn(nativeExecutableResolver, 'resolveAsync').mockReturnValue(
new Promise((resolve) => {
release = resolve
})
)
const spawn = vi.spyOn(nativeSpawn, 'spawnNativeProcess')
const controller = new AbortController()
const terminated = vi.fn()
const deadline = vi.fn()
const pending = execFileCapture(process.execPath, ['-e', 'throw Error("launched late")'], {
signal: controller.signal,
...(reason === 'deadline' ? { timeout: 1 } : {}),
onChildTerminated: terminated,
onDeadlineKill: deadline
})
const rejected = expect(pending).rejects.toThrow(reason === 'abort' ? 'aborted' : 'timed out')
if (reason === 'abort') {
controller.abort()
}
await rejected
release({ program: process.execPath, file: process.execPath })
await nextTurn()
expect(spawn).not.toHaveBeenCalled()
expect(terminated).toHaveBeenCalledOnce()
expect(deadline).toHaveBeenCalledTimes(reason === 'deadline' ? 1 : 0)
}
)
it('keeps the preflight-selected CLI ahead of an earlier executable', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-capture-selection-'))
roots.push(root)
const first = join(root, 'first')
const second = join(root, 'second')
await Promise.all([mkdir(first), mkdir(second)])
const earlier = join(first, 'gh')
const selected = join(second, 'gh')
await Promise.all([
writeFile(earlier, '#!/bin/sh\necho wrong\n'),
symlink(process.execPath, selected)
])
await chmod(earlier, 0o755)
vi.stubEnv('PATH', `${first}:${second}`)
const publish = beginLocalCommandSelection('gh')
try {
await publish(selected)
await expect(
execFileCapture('gh', ['-p', 'process.argv0'], { timeout: 2_000 })
).resolves.toEqual({
stdout: 'gh\n',
stderr: ''
})
} finally {
await publish(null)
}
})
it('preserves raw buffers and stderr observation on a failed command', async () => {
const observed: string[] = []
await expect(
execFileCapture(
process.execPath,
[
'-e',
'process.stdout.write(Buffer.from([0,255,128]));process.stderr.write("diagnostic");process.exitCode=7'
],
{
encoding: 'buffer',
onStderr: (chunk) => observed.push(chunk),
timeout: 2_000
}
)
).rejects.toMatchObject({
code: 7,
stdout: Buffer.from([0, 255, 128]),
stderr: Buffer.from('diagnostic')
})
expect(observed.join('')).toBe('diagnostic')
})
})
describe.skipIf(process.platform !== 'darwin')('capture output overflow termination', () => {
it('keeps the deadline and termination obligation alive when the child ignores the overflow signal', async () => {
const original = nativeSpawn.spawnNativeProcess
let child: ReturnType<typeof original> | undefined
vi.spyOn(nativeSpawn, 'spawnNativeProcess').mockImplementation((...args) => {
child = original(...args)
return child
})
let reportTermination!: () => void
const terminated = new Promise<void>((resolve) => {
reportTermination = resolve
})
const onTerminated = vi.fn(reportTermination)
const signals: string[] = []
try {
await expect(
execFileCapture(
process.execPath,
[
'-e',
`
let signals = 0;
process.on('SIGTERM', () => {
if (++signals === 1) process.stderr.write('first');
else process.stderr.write('second', () => process.exit(0));
});
setInterval(() => {}, 1000);
process.stdout.write('x'.repeat(64));
`
],
{
maxBuffer: 32,
timeout: 3_000,
onChildTerminated: onTerminated,
onStderr: (chunk) => signals.push(chunk)
}
)
).rejects.toThrow('timed out')
await terminated
expect(onTerminated).toHaveBeenCalledOnce()
expect(signals.join('')).toBe('firstsecond')
} finally {
// The test owns this exact returned child, including when the regression assertion fails.
if (child?.pid && child.exitCode === null && child.signalCode === null) {
child.kill('SIGKILL')
}
}
})
})
@@ -2,22 +2,22 @@ import { execFile, type ChildProcess, type ExecFileOptions } from 'node:child_pr
import { recordSubprocessSpawn } from '../../../diagnostics/main-thread-churn-probe'
import { endSubprocessStdin } from '../../../../shared/subprocess-stdin-write'
import { runProcess } from '@orca/process-host'
import { nativeExecutableResolver } from '@orca/process-host/native-executable-resolution'
import { spawnNativeProcess } from '@orca/process-host/native-process-spawn'
import { resolveSelectedLocalCommand } from '../../../ipc/command-path/command-path-resolver'
import type { WslProcessGroupTermination } from './wsl-process-group-termination'
import { createWslGuestTerminationProof } from './wsl-guest-termination-proof'
import { createAbortError } from './abort-error'
import { killSpawnedCommandTree } from './spawned-command-tree-kill'
import { DEFAULT_GIT_MAX_BUFFER } from '../git-exec-options'
import type { GitAdmissionTier } from '../../../../shared/rpc-contract/source-control/git-admission-tier-params'
import { workerExecFile } from './worker-exec-file'
import { spawnedExecFile, workerExecFile } from './worker-exec-file'
import type { WorkerThreadChildProcess } from '../../../worker-thread-child-process/worker-thread-child-process'
type ExecFileCaptureOptions = Omit<ExecFileOptions, 'timeout'> & {
timeout?: number
stdin?: string
terminationBarrier?: boolean
requireGuestTerminationProof?: true
onChildTerminated?: () => void
admissionTier?: GitAdmissionTier
createTimeoutError?: () => Error
@@ -36,10 +36,6 @@ export async function execFileCaptureToTermination(
options: Omit<ExecFileCaptureOptions, 'onStderr' | 'stdin'> & { stdin?: string | Uint8Array },
termination?: WslProcessGroupTermination
): Promise<{ stdout: string | Buffer; stderr: string | Buffer }> {
if (options.signal?.aborted) {
options.onChildTerminated?.()
throw createAbortError()
}
// runProcess already reports spawn cost through spawnProcess.
let program: string
try {
@@ -52,13 +48,6 @@ export async function execFileCaptureToTermination(
throw error
}
const captureBytes = options.encoding === 'buffer'
const proof =
termination && options.requireGuestTerminationProof
? createWslGuestTerminationProof(termination)
: undefined
if (proof) {
void proof.terminated.then(() => options.onChildTerminated?.())
}
const pending = runProcess({
program,
args,
@@ -69,12 +58,11 @@ export async function execFileCaptureToTermination(
captureStdoutAsBytes: captureBytes,
captureStderrAsBytes: captureBytes,
signal: options.signal,
terminationBarrier: proof?.barrier ?? termination ?? true,
onChildTerminated: proof?.onChildTerminated ?? options.onChildTerminated,
terminationBarrier: termination ?? true,
onChildTerminated: options.onChildTerminated,
...(options.stdin === undefined ? {} : { input: options.stdin })
})
const result = await pending.finally(() => proof?.terminated)
const aborted = !!options.signal?.aborted && (!proof || proof.stopping)
const result = await pending
const stdout = captureBytes ? (result.stdoutBytes ?? Buffer.alloc(0)) : result.stdout
const stderrBytes = result.stderrBytes ?? Buffer.alloc(0)
const stderr = captureBytes
@@ -83,29 +71,34 @@ export async function execFileCaptureToTermination(
Buffer.from(termination.stripControlOutput(stderrBytes.toString('latin1')), 'latin1')
: stderrBytes
: (termination?.stripControlOutput(result.stderr) ?? result.stderr)
if (result.code === 0 && !result.timedOut && !result.outputTruncated && !aborted) {
if (
result.code === 0 &&
!result.timedOut &&
!result.outputTruncated &&
!options.signal?.aborted
) {
return { stdout, stderr }
}
if (result.timedOut && !aborted) {
if (result.timedOut && !options.signal?.aborted) {
options.onDeadlineKill?.()
}
const cleanStderr = typeof stderr === 'string' ? stderr : stderr.toString('utf8')
const error = result.timedOut
? (options.createTimeoutError?.() ?? new Error(`${command} timed out.`))
: new Error(
aborted
options.signal?.aborted
? 'The operation was aborted.'
: result.outputTruncated
? // Reject clipped output: JSON/JSONL consumers could otherwise accept an incomplete result.
`${command} produced more than ${options.maxBuffer ?? DEFAULT_GIT_MAX_BUFFER} bytes of output.`
: cleanStderr.trim() || `${command} exited with ${result.code}.`
)
if (aborted) {
if (options.signal?.aborted) {
error.name = 'AbortError'
}
throw Object.assign(error, {
code: result.code,
killed: result.timedOut || result.signal !== null || aborted,
killed: result.timedOut || result.signal !== null || options.signal?.aborted === true,
signal: result.signal,
outputTruncated: result.outputTruncated,
stdout,
@@ -162,6 +155,8 @@ export function execFileCapture(
let terminating = false
let child: ChildProcess | WorkerThreadChildProcess | null = null
const useWorker = options.spawnOnWorkerThread === true && process.platform === 'darwin'
const deadline =
options.timeout && options.timeout > 0 ? performance.now() + options.timeout : null
let timer: NodeJS.Timeout | null = null
let terminationReported = false
const reportChildTerminated = (): void => {
@@ -197,39 +192,59 @@ export function execFileCapture(
}
resolve({ stdout, stderr })
}
const onAbort = (): void => {
const stop = (error: Error): void => {
if (settled || terminating) {
return
}
terminating = true
const abortError = createAbortError()
if (!child) {
terminating = false
finish(abortError)
reportChildTerminated()
finish(error)
return
}
void killSpawnedCommandTree(child).then(() => {
terminating = false
finish(abortError)
finish(error)
})
}
try {
const spawnStartedAt = performance.now()
// Why: our abort listener owns tree cleanup; Node's signal handler could kill wsl.exe before taskkill sees its children.
const capture = useWorker ? workerExecFile : execFile
child = capture(
command,
args,
{
const onAbort = (): void => stop(createAbortError())
const onDeadline = (): void => {
if (!settled && !terminating) {
options.onDeadlineKill?.()
stop(options.createTimeoutError?.() ?? new Error(`${command} timed out.`))
}
}
// Lookup is part of the deadline; cancellation must settle even before there is a child.
if (options.timeout && options.timeout > 0) {
timer = setTimeout(onDeadline, options.timeout)
}
options.signal?.addEventListener('abort', onAbort, { once: true })
if (options.signal?.aborted) {
onAbort()
}
const launch = async (): Promise<void> => {
try {
if (settled) {
return
}
const spawnStartedAt = performance.now()
// Why: our abort listener owns tree cleanup; Node's signal handler could kill wsl.exe before taskkill sees its children.
const captureOptions = {
cwd: options.cwd,
// Hide console-subsystem children so frequent Git calls cannot flash or take focus.
windowsHide: true,
encoding: options.encoding,
maxBuffer: options.maxBuffer ?? DEFAULT_GIT_MAX_BUFFER,
env: options.env
},
(error, stdout, stderr) => {
}
const complete = (
error: Error | null,
stdout: string | Buffer,
stderr: string | Buffer
): void => {
if (terminating) {
return
}
@@ -239,57 +254,67 @@ export function execFileCapture(
}
finish(error, stdout, stderr)
}
)
recordSubprocessSpawn(command, args, performance.now() - spawnStartedAt)
} catch (error) {
reportChildTerminated()
finish(error instanceof Error ? error : new Error(String(error)))
return
}
if (!useWorker && process.platform === 'darwin') {
const program = resolveSelectedLocalCommand(command, {
env: options.env,
cwd: typeof options.cwd === 'string' ? options.cwd : undefined
})
const selection = await nativeExecutableResolver.resolveAsync(program, options.env)
if (deadline !== null && performance.now() >= deadline) {
onDeadline()
}
if (settled) {
return
}
child = spawnedExecFile(command, captureOptions, complete, () =>
spawnNativeProcess(
program,
args,
{ ...captureOptions, argv0: command },
selection,
recordSubprocessSpawn
)
)
} else {
const capture = useWorker ? workerExecFile : execFile
child = capture(command, args, captureOptions, complete)
recordSubprocessSpawn(command, args, performance.now() - spawnStartedAt)
}
} catch (error) {
reportChildTerminated()
finish(error instanceof Error ? error : new Error(String(error)))
return
}
child.once('error', (error) => {
// A lost worker may have spawned a child whose PID never reached us; only close proves termination.
if (!useWorker && !child?.pid) {
child.once('error', (error) => {
// A lost worker may have spawned a child whose PID never reached us; only close proves termination.
if (!useWorker && !child?.pid) {
reportChildTerminated()
}
if (!terminating) {
finish(error)
}
})
child.once('close', reportChildTerminated)
if (options.onStderr) {
observeStderr(child, options.onStderr)
}
// Non-interactive signers must see EOF instead of waiting on an open pipe.
if ('id' in child) {
if (options.stdin !== undefined) {
child.stdin.write(options.stdin)
}
child.stdin.end()
} else {
endSubprocessStdin(child.stdin, options.stdin ?? '')
}
}
void launch().catch((error: unknown) => {
if (!child) {
reportChildTerminated()
}
if (!terminating) {
finish(error)
}
finish(error instanceof Error ? error : new Error(String(error)))
})
child.once('close', reportChildTerminated)
if (options.onStderr) {
observeStderr(child, options.onStderr)
}
// Non-interactive signers must see EOF instead of waiting on an open pipe.
if ('id' in child) {
if (options.stdin !== undefined) {
child.stdin.write(options.stdin)
}
child.stdin.end()
} else {
endSubprocessStdin(child.stdin, options.stdin ?? '')
}
// Why: Node's timeout waits forever on signal-ignoring CLIs; enforce our own deadline with bounded tree cleanup.
if (options.timeout && options.timeout > 0) {
timer = setTimeout(() => {
if (settled || terminating) {
return
}
terminating = true
const timeoutError = options.createTimeoutError?.() ?? new Error(`${command} timed out.`)
if (!child) {
terminating = false
finish(timeoutError)
return
}
void killSpawnedCommandTree(child).then(() => {
terminating = false
finish(timeoutError)
})
}, options.timeout)
}
options.signal?.addEventListener('abort', onAbort, { once: true })
})
}
@@ -1,33 +1,54 @@
import { createOutputSink } from '@orca/process-host/bounded-output-sink'
import type { ChildProcess } from 'node:child_process'
import { fileURLToPath } from 'node:url'
import { spawnProcessOnWorkerThread } from '../../../worker-thread-child-process/worker-thread-child-process'
import type { WorkerThreadChildProcess } from '../../../worker-thread-child-process/worker-thread-child-process'
type CaptureOptions = {
cwd?: string | URL
env?: NodeJS.ProcessEnv
encoding?: string | null
maxBuffer: number
}
type CaptureCallback = (
error: Error | null,
stdout: string | Buffer,
stderr: string | Buffer
) => void
/** Capture a worker-owned child; the caller still owns deadlines and aborts. */
export function workerExecFile(
command: string,
args: string[],
options: {
cwd?: string | URL
env?: NodeJS.ProcessEnv
encoding?: string | null
maxBuffer: number
},
callback: (error: Error | null, stdout: string | Buffer, stderr: string | Buffer) => void
options: CaptureOptions,
callback: CaptureCallback
): WorkerThreadChildProcess {
return spawnedExecFile(command, options, callback, () =>
spawnProcessOnWorkerThread({
program: command,
args,
cwd: options.cwd instanceof URL ? fileURLToPath(options.cwd) : options.cwd,
env: options.env
})
)
}
/** Capture either host's real child using the same byte limits and completion contract. */
export function spawnedExecFile<T extends ChildProcess | WorkerThreadChildProcess>(
command: string,
options: CaptureOptions,
callback: CaptureCallback,
launch: () => T
): T {
const encoding = options.encoding === null ? 'buffer' : (options.encoding ?? 'utf8')
if (encoding !== 'buffer' && !Buffer.isEncoding(encoding)) {
throw new TypeError(`Unsupported Git output encoding: ${encoding}`)
throw new TypeError(`Unsupported output encoding: ${encoding}`)
}
const child = spawnProcessOnWorkerThread({
program: command,
args,
cwd: options.cwd instanceof URL ? fileURLToPath(options.cwd) : options.cwd,
env: options.env
})
const child = launch()
const stdout = createOutputSink(options.maxBuffer)
const stderr = createOutputSink(options.maxBuffer)
let completed = false
let overflowError: Error | null = null
const finish = (error: Error | null): void => {
if (completed) {
return
@@ -44,28 +65,27 @@ export function workerExecFile(
return
}
sink.write(chunk)
if (sink.truncated()) {
if (sink.truncated() && !overflowError) {
overflowError = Object.assign(new Error(`${command} output exceeded maxBuffer`), {
code: 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER',
killed: true
})
child.kill()
finish(
Object.assign(new Error('Git output exceeded maxBuffer'), {
code: 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER',
killed: true
})
)
}
}
child.stdout.on('data', (chunk: Buffer) => collect(stdout, chunk))
child.stderr.on('data', (chunk: Buffer) => collect(stderr, chunk))
child.stdout?.on('data', (chunk: Buffer) => collect(stdout, chunk))
child.stderr?.on('data', (chunk: Buffer) => collect(stderr, chunk))
child.once('error', finish)
child.once('close', (code: number | null, signal: NodeJS.Signals | null) => {
finish(
code === 0
? null
: Object.assign(new Error(stderr.text().trim() || `${command} exited with ${code}`), {
code,
signal,
killed: signal !== null
})
overflowError ??
(code === 0
? null
: Object.assign(new Error(stderr.text().trim() || `${command} exited with ${code}`), {
code,
signal,
killed: signal !== null
}))
)
})
return child
@@ -1,41 +0,0 @@
import type { ChildProcess } from 'node:child_process'
import type { WslProcessGroupTermination } from './wsl-process-group-termination'
/** Root wsl.exe closure cannot prove an interrupted guest command has stopped. */
export function createWslGuestTerminationProof(group: WslProcessGroupTermination) {
let stopping = false
let complete!: () => void
const terminated = new Promise<void>((resolve) => {
complete = resolve
})
const observe = async (child: ChildProcess, stop: () => Promise<boolean>): Promise<boolean> => {
stopping = true
if (!child.pid) {
complete()
return true
}
const proved = await stop()
if (proved) {
complete()
}
return proved
}
return {
terminated,
get stopping() {
return stopping
},
onChildTerminated: () => {
if (!stopping) {
complete()
}
},
barrier: {
...group,
signal: (...args: Parameters<typeof group.signal>) =>
observe(args[0], () => group.signal(...args)),
force: (...args: Parameters<typeof group.force>) =>
observe(args[0], () => group.force(...args))
}
}
}
@@ -1,6 +1,16 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createFakeSpawnedChild as createMockChildProcess } from '../../../shared/__fixtures__/fake-spawned-child'
// Callback fixtures exercise native execFile; Mac spawn capture has real-child coverage.
const restorePlatform = vi.hoisted(() => {
const platform = process.platform
if (platform === 'darwin') {
Object.defineProperty(process, 'platform', { value: 'linux' })
}
return () => Object.defineProperty(process, 'platform', { value: platform })
})
afterAll(restorePlatform)
const { execFileMock, execFileSyncMock, spawnMock } = vi.hoisted(() => ({
execFileMock: vi.fn(),
execFileSyncMock: vi.fn(),
@@ -5,12 +5,15 @@ import { resolveDefaultBaseRefViaExec } from '../../../../shared/git/refs/git-de
export type LocalGitExecOptions = {
wslDistro?: string
admissionTier?: GitAdmissionTier
/** Checked at admission; a bounded optional read refuses git that would start past its deadline. */
canStart?: () => boolean
}
export type LocalDefaultBaseRefGitOptions = {
cwd: string
wslDistro?: string
admissionTier?: GitAdmissionTier
canStart?: () => boolean
}
export const DEFAULT_BASE_REF_PROBE_TIMEOUT_MS = 15_000
@@ -22,7 +25,8 @@ export function gitExecOptions(
return {
cwd,
...(options.wslDistro ? { wslDistro: options.wslDistro } : {}),
...(options.admissionTier ? { admissionTier: options.admissionTier } : {})
...(options.admissionTier ? { admissionTier: options.admissionTier } : {}),
...(options.canStart ? { canStart: options.canStart } : {})
}
}
@@ -9,6 +9,7 @@ import {
import { BranchRefSnapshotStore, BranchSnapshotIdentityChanged } from './branch-ref-snapshot-store'
import { subscribeGitRefChanges } from '../git-ref-change-signal'
import { readTranslatedWorktreeGraph } from '../../worktrees/catalog/worktree-list-reader'
import { withRepositoryGitCause } from '../../repository-git-cause'
const snapshots = new BranchRefSnapshotStore({ subscribe: subscribeGitRefChanges })
@@ -16,6 +17,19 @@ async function withIdentity<T>(
path: string,
git: LocalGitExecOptions,
read: (identity: BranchSnapshotIdentity) => Promise<T>
): Promise<T> {
return withRepositoryGitCause(
path,
git,
{ family: 'refs', reason: 'branch-snapshot', cache: 'uncached' },
() => readWithIdentity(path, git, read)
)
}
async function readWithIdentity<T>(
path: string,
git: LocalGitExecOptions,
read: (identity: BranchSnapshotIdentity) => Promise<T>
): Promise<T> {
for (let attempt = 0; attempt < 2; attempt++) {
const identity = await resolveBranchSnapshotIdentity(path, git)
@@ -0,0 +1,41 @@
import { deriveOriginStandInRemoteName } from '../../../shared/git/remotes/git-remote-identity'
import { gitExecFileAsync } from '../command-runner/git-exec-file'
import { listCachedRemoteNames, type RemoteNameListingGitOptions } from './remote-name-listing'
import { REMOTE_URL_PROBE_TIMEOUT_MS } from './remote-url-probe'
async function readOriginStandInRemoteName(
repoPath: string,
connectionId: string | null | undefined,
localGitOptions: RemoteNameListingGitOptions
): Promise<string | null> {
const names = await listCachedRemoteNames(repoPath, connectionId, localGitOptions)
if (!names?.some((name) => name !== 'origin' && name !== 'upstream')) {
return null
}
try {
const { stdout } = await gitExecFileAsync(['remote', '-v'], {
cwd: repoPath,
timeout: REMOTE_URL_PROBE_TIMEOUT_MS,
...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {}),
...(localGitOptions.admissionTier ? { admissionTier: localGitOptions.admissionTier } : {})
})
return deriveOriginStandInRemoteName(stdout)
} catch {
return null
}
}
/** Origin's GitHub role falls to the remote project identity reads when origin is missing or a local path. */
export async function resolveOriginWithStandIn<T>(
repoPath: string,
connectionId: string | null | undefined,
localGitOptions: RemoteNameListingGitOptions,
resolveRemote: (remoteName: string) => Promise<T | null>
): Promise<T | null> {
const origin = await resolveRemote('origin')
if (origin !== null) {
return origin
}
const standIn = await readOriginStandInRemoteName(repoPath, connectionId, localGitOptions)
return standIn ? resolveRemote(standIn) : null
}
@@ -8,6 +8,7 @@ import { resolveWorktreeAddBaseRef } from '../../../../shared/worktree/base-refs
type GitExecOptions = {
wslDistro?: string
admissionTier?: GitAdmissionTier
canStart?: () => boolean
}
/** Return the probe's commit OID so callers need not resolve it again. */
@@ -90,14 +90,7 @@ async function performRemoveWorktree(
}
const execOptions = {
...finalizationOptions,
...(options.checkoutDeleteSignal
? {
signal: options.checkoutDeleteSignal,
...(process.platform === 'win32' && (options.wslDistro || parseWslPath(repoPath))
? { terminationBarrier: true, requireGuestTerminationProof: true as const }
: {})
}
: {})
...(options.checkoutDeleteSignal ? { signal: options.checkoutDeleteSignal } : {})
}
const args = [...longPathArgs, 'worktree', 'remove']
if (force) {
@@ -0,0 +1,47 @@
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { runProcess } from '@orca/process-host'
import { afterEach, expect, it } from 'vitest'
import { resolveIssueGitHubApiRepositorySource } from './github-api-repository'
import { resolvePrWorkItemSource } from '../client/list/work-item-list-request'
import { installRealWorkerThreadChildProcesses } from '../../worker-thread-child-process/worker-thread-child-process-real.test-fixtures'
let directory: string | null = null
afterEach(async () => {
if (directory) {
await rm(directory, { recursive: true, force: true })
directory = null
}
})
async function createRepo(remotes: [name: string, url: (repo: string) => string][]) {
const repo = await mkdtemp(join(tmpdir(), 'orca-origin-stand-in-'))
directory = repo
const git = async (args: string[]) => {
const result = await runProcess({ program: 'git', args, cwd: repo })
if (result.code !== 0) {
throw new Error(result.stderr)
}
}
await git(['init', '-q'])
await git(['init', '--bare', '-q', join(repo, 'mirror.git')])
for (const [name, url] of remotes) {
await git(['remote', 'add', name, url(repo)])
}
return repo
}
it('reads GitHub from the remote standing in for an origin that is a local path (#42102)', async () => {
const repo = await createRepo([
['origin', (root) => join(root, 'mirror.git')],
['github', () => 'https://github.com/acme/widgets.git']
])
const expected = { owner: 'acme', repo: 'widgets', host: 'github.com' }
expect((await resolveIssueGitHubApiRepositorySource(repo, undefined)).source).toEqual(expected)
expect((await resolvePrWorkItemSource(repo, undefined)).source).toEqual(expected)
})
installRealWorkerThreadChildProcesses()
@@ -6,6 +6,7 @@ import {
} from './github-repository-identity'
import type { GitHubRepositoryIdentity as GitHubApiRepository } from '../../../shared/github/repositories/repository-identity'
import { shouldProbeGitRemote } from '../../git/remotes/remote-name-listing'
import { resolveOriginWithStandIn } from '../../git/remotes/origin-stand-in-remote'
import {
getEnterpriseGitHubRepoSlug,
@@ -128,10 +129,14 @@ export async function getGitHubApiRepositoryForRemote(
})
}
/** The repo origin names; a project whose origin is missing or a local path reads its stand-in remote. */
export async function getOriginGitHubApiRepository(
repoPath: string,
connectionId?: string | null,
localGitOptions: LocalGitExecOptions = {}
localGitOptions: LocalGitExecOptions = {},
probeOptions: GitHubRemoteIdentityProbeOptions = {}
): Promise<GitHubApiRepository | null> {
return getGitHubApiRepositoryForRemote(repoPath, 'origin', connectionId, localGitOptions)
return resolveOriginWithStandIn(repoPath, connectionId, localGitOptions, (name) =>
getGitHubApiRepositoryForRemote(repoPath, name, connectionId, localGitOptions, probeOptions)
)
}
@@ -34,7 +34,8 @@ vi.mock('./github-enterprise-repository', async (importOriginal) => ({
}))
vi.mock('../../git/remotes/remote-name-listing', () => ({
shouldProbeGitRemote: shouldProbeGitRemoteMock
shouldProbeGitRemote: shouldProbeGitRemoteMock,
listCachedRemoteNames: async () => null
}))
import {
_resetOriginGitHubApiRepositoryCache,
@@ -41,9 +41,8 @@ export async function getIssueGitHubApiRepository(
localGitOptions: LocalGitExecOptions = {}
): Promise<GitHubApiRepository | null> {
return withGitHubRepoExecution(repoPath, connectionId, localGitOptions, async () => {
const originPromise = getGitHubApiRepositoryForRemote(
const originPromise = getOriginGitHubApiRepository(
repoPath,
'origin',
connectionId,
localGitOptions
).then(
@@ -81,15 +80,9 @@ export async function resolveGitHubApiRepositoryCandidates(
localGitOptions: LocalGitExecOptions = {}
): Promise<GitHubApiRepositoryCandidates> {
return withGitHubRepoExecution(repoPath, connectionId, localGitOptions, async () => {
const originPromise = getGitHubApiRepositoryForRemote(
repoPath,
'origin',
connectionId,
localGitOptions,
{
requireVerifiedSshProbe: true
}
).then(
const originPromise = getOriginGitHubApiRepository(repoPath, connectionId, localGitOptions, {
requireVerifiedSshProbe: true
}).then(
(value) => ({ status: 'fulfilled' as const, value }),
(reason: unknown) => ({ status: 'rejected' as const, reason })
)
@@ -151,22 +144,12 @@ export async function resolveIssueGitHubApiRepositorySource(
if (upstream) {
return { source: upstream, fellBack: false }
}
const origin = await getGitHubApiRepositoryForRemote(
repoPath,
'origin',
connectionId,
localGitOptions
)
const origin = await getOriginGitHubApiRepository(repoPath, connectionId, localGitOptions)
return { source: origin, fellBack: origin !== null }
}
if (preference === 'origin') {
return {
source: await getGitHubApiRepositoryForRemote(
repoPath,
'origin',
connectionId,
localGitOptions
),
source: await getOriginGitHubApiRepository(repoPath, connectionId, localGitOptions),
fellBack: false
}
}
@@ -1,6 +1,7 @@
export const HANG_WATCHDOG_HEARTBEAT_INTERVAL_MS = 2_000
export const HANG_WATCHDOG_TIMEOUT_MS = 45_000
export const HANG_WATCHDOG_CHECK_INTERVAL_MS = 5_000
import type { NativeHangProcessMetadata } from './native-hang-report'
export type HangWatchdogWorkerData = {
parentPid: number
@@ -8,12 +9,15 @@ export type HangWatchdogWorkerData = {
timeoutMs: number
checkIntervalMs: number
tracePath?: string
artifactDirectory?: string
processMetadata?: NativeHangProcessMetadata
}
export type MainToHangWatchdogWorkerMessage =
| { type: 'heartbeat' }
| { type: 'shutdown' }
| { type: 'capture-drain-ack'; token: string }
| { type: 'menu-bar-setting'; enabled: boolean }
| { type: 'power-event'; event: HangPowerReceipt['event']; observedAt: number }
import type { HangPowerReceipt } from './hang-observations'
@@ -5,6 +5,9 @@ const state = vi.hoisted(() => ({
clock: 0,
delayedWrite: false,
rejectRunner: false,
parkedStop: false,
successfulNative: false,
artifacts: new Array<{ showMenuBarIcon: boolean | null }>(),
commands: new Array<string>(),
terminations: new Array<() => void>(),
records: new Array<unknown>()
@@ -14,8 +17,16 @@ vi.mock('node:inspector', () => ({
Session: class {
connectToMainThread() {}
disconnect() {}
post(method: string, _params: unknown, response: (error: null, result?: unknown) => void) {
post(
method: string,
_params: unknown,
response: (error: Error | null, result?: unknown) => void
) {
state.commands.push(method)
if (method === 'Profiler.stop' && state.parkedStop) {
response(new Error('uncertain inspector transport'))
return
}
response(
null,
method === 'Profiler.stop'
@@ -31,9 +42,22 @@ vi.mock('@orca/process-host', () => ({
if (state.rejectRunner) {
throw new Error('runner rejected after possible acquisition')
}
if (state.successfulNative) {
options.onChildTerminated()
return { stdout: 'native evidence', stderr: '', code: 0, timedOut: false }
}
return { stdout: '', stderr: '', code: null, timedOut: true }
}
}))
vi.mock('./native-hang-capture-store', () => ({
createNativeHangCaptureId: (time: number) => `hang-${time}`,
NativeHangCaptureStore: class {
async save(artifact: { showMenuBarIcon: boolean | null }) {
state.artifacts.push(structuredClone(artifact))
}
async recover() {}
}
}))
vi.mock('./hang-detection-marker', () => ({
readAvailableHangDetectionMarker: () => null,
writeHangDetectionMarker: (_path: string, record: unknown) => {
@@ -53,6 +77,9 @@ beforeEach(() => {
state.clock = 0
state.delayedWrite = false
state.rejectRunner = false
state.parkedStop = false
state.successfulNative = false
state.artifacts = []
state.commands = []
state.terminations = []
state.records = []
@@ -75,6 +102,15 @@ function watchdog() {
{
parentPid: 123,
markerPath: '/qa/main-thread-hang.json',
artifactDirectory: '/qa/captures',
processMetadata: {
appVersion: '1',
electronVersion: '43',
chromeVersion: '150',
arch: 'arm64',
osRelease: '25',
osVersion: 'Darwin'
},
timeoutMs: 100,
checkIntervalMs: 25
},
@@ -124,7 +160,7 @@ it.each([false, true])(
await vi.advanceTimersByTimeAsync(125)
await vi.dynamicImportSettled()
expect(state.records.at(-1)).toMatchObject({
observations: [expect.anything(), expect.not.objectContaining({ mainJs: expect.anything() })]
observations: [expect.anything(), expect.not.objectContaining({ native: expect.anything() })]
})
owner.send({ type: 'heartbeat' })
state.terminations.shift()?.()
@@ -153,3 +189,42 @@ it.each([false, true])(
owner.send({ type: 'shutdown' })
}
)
it('captures a later native episode while the earlier inspector cleanup remains uncertain', async () => {
state.parkedStop = true
const owner = watchdog()
await vi.advanceTimersByTimeAsync(125)
await vi.dynamicImportSettled()
state.terminations.shift()?.()
owner.send({ type: 'heartbeat' })
await vi.dynamicImportSettled()
await vi.advanceTimersByTimeAsync(125)
await vi.dynamicImportSettled()
expect(state.records.at(-1)).toMatchObject({
observations: [
expect.objectContaining({ mainJs: expect.objectContaining({ state: 'failed' }) }),
expect.objectContaining({ native: expect.objectContaining({ state: 'completed' }) })
]
})
owner.send({ type: 'shutdown' })
})
it('snapshots only the setting actually received before each episode', async () => {
state.successfulNative = true
const owner = watchdog()
await vi.advanceTimersByTimeAsync(125)
await vi.dynamicImportSettled()
owner.send({ type: 'heartbeat' })
owner.send({ type: 'menu-bar-setting', enabled: false })
await vi.dynamicImportSettled()
await vi.advanceTimersByTimeAsync(125)
await vi.dynamicImportSettled()
owner.send({ type: 'menu-bar-setting', enabled: true })
expect(state.artifacts.map((item) => item.showMenuBarIcon)).toEqual([null, false])
owner.send({ type: 'heartbeat' })
await vi.dynamicImportSettled()
await vi.advanceTimersByTimeAsync(125)
await vi.dynamicImportSettled()
expect(state.artifacts.map((item) => item.showMenuBarIcon)).toEqual([null, false, true])
owner.send({ type: 'shutdown' })
})
@@ -12,6 +12,7 @@ import {
type HangPowerReceipt
} from './hang-observations'
import { captureHangSnapshot } from './hang-snapshot'
import { createWatchdogHangCapture } from './watchdog-hang-capture'
import type {
HangWatchdogWorkerData,
MainToHangWatchdogWorkerMessage
@@ -22,13 +23,6 @@ type HangWatchdogPort = {
close: () => void
postMessage?: (message: { type: 'capture-drain'; token: string }) => void
}
type CaptureOwner = {
id: string
reason?: 'recovery' | 'shutdown'
stopJs?: (reason: 'recovery' | 'shutdown') => void
stopNative?: () => void
done?: Promise<void>
}
export function runWatchdog(
config: HangWatchdogWorkerData,
@@ -43,7 +37,7 @@ export function runWatchdog(
let sequence = 0
let activeStallId: string | null = null
let stopped = false
let captureOwner: CaptureOwner | null = null
let showMenuBarIcon: boolean | null = null
let drain: { token: string; acknowledge: () => void } | null = null
let collector: AbortController | null = null
let recentPower: HangPowerReceipt[] = []
@@ -80,55 +74,18 @@ export function runWatchdog(
observations = retainHangObservation(observations, { ...record, ...change })
save()
}
const capture = (record: HangObservation, detectedAt: number): void => {
if (isMainThread || captureOwner || stopped || record.kind !== 'heartbeat-stall') {
return
}
const owner: CaptureOwner = { id: record.id }
captureOwner = owner
owner.done = (async () => {
const [inspector, js, native] = await Promise.all([
import('node:inspector'),
import('./main-js-hang-capture'),
import('./native-hang-capture')
])
const mainJs = js.captureMainJsHang({
session: new inspector.Session(),
detectedAt,
now: () => performance.now(),
publish: (mainJs) => update(record.id, { mainJs }),
drain: () =>
new Promise<void>((resolve) => {
drain = { token: record.id, acknowledge: resolve }
port.postMessage?.({ type: 'capture-drain', token: record.id })
})
})
owner.stopJs = mainJs.stop
if (owner.reason) {
mainJs.stop(owner.reason)
}
const nativeCapture =
!owner.reason && process.platform === 'darwin'
? native.captureNativeHang({
pid: config.parentPid,
publish: (native) => update(record.id, { native })
})
: null
owner.stopNative = nativeCapture?.stop
await Promise.all([mainJs.done, nativeCapture?.done])
})()
.catch(() => {
// A published baseline remains useful if an optional collector cannot initialize.
})
.finally(() => {
if (captureOwner === owner) {
captureOwner = null
}
if (stopped) {
port.close()
}
})
}
const captures = createWatchdogHangCapture({
parentPid: config.parentPid,
artifactDirectory: config.artifactDirectory,
processMetadata: config.processMetadata,
publish: update,
drain: (token) =>
new Promise<void>((resolve) => {
drain = { token, acknowledge: resolve }
port.postMessage?.({ type: 'capture-drain', token })
}),
onIdle: () => port.close()
})
const openReceiptWindow = (id: string): void => {
receiptWindow = { id, until: Date.now() + config.checkIntervalMs, remaining: 4 }
}
@@ -204,7 +161,9 @@ export function runWatchdog(
}
openReceiptWindow(record.id)
save()
capture(record, detectedAt)
if (!isMainThread) {
captures.capture(record, detectedAt, showMenuBarIcon)
}
enrich(record)
}
const loop = createHangWatchdogDetectionLoop({
@@ -218,10 +177,7 @@ export function runWatchdog(
}
const id = activeStallId
activeStallId = null
if (captureOwner?.id === id) {
captureOwner.reason ??= 'recovery'
captureOwner.stopJs?.('recovery')
}
captures.recover(id)
update(id, { selfRecovered: true, resolution: captureHangSnapshot() })
openReceiptWindow(id)
},
@@ -254,6 +210,8 @@ export function runWatchdog(
}
if (message.type === 'heartbeat') {
loop.recordHeartbeat()
} else if (message.type === 'menu-bar-setting') {
showMenuBarIcon = message.enabled
} else if (message.type === 'power-event') {
const receipt = parsePowerReceipt({ ...message, receivedAt: Date.now() })
if (!receipt) {
@@ -281,13 +239,7 @@ export function runWatchdog(
receiptWindow = null
clearInterval(checkTimer)
collector?.abort()
if (captureOwner) {
captureOwner.reason ??= 'shutdown'
captureOwner.stopJs?.('shutdown')
captureOwner.stopNative?.()
} else {
port.close()
}
captures.shutdown()
}
})
}
@@ -1,9 +1,11 @@
import type { Worker } from 'node:worker_threads'
import os from 'node:os'
import { spawnWorkerThread } from '../worker-threads/worker-thread-spawn'
import { app, powerMonitor } from 'electron'
import { hangDetectionMarkerPath } from './hang-detection-marker'
import { resolveHangWatchdogWorkerPath } from './hang-watchdog-worker-path'
import { getTraceFilePath, resolveObservabilityConsent } from '../observability'
import { nativeHangCaptureDirectory } from './native-hang-capture-store'
import {
HANG_WATCHDOG_CHECK_INTERVAL_MS,
HANG_WATCHDOG_HEARTBEAT_INTERVAL_MS,
@@ -16,6 +18,7 @@ import {
export type MainThreadHangWatchdogHandle = {
stop: () => void
worker: Worker
setMenuBarIconSetting: (enabled: boolean) => void
}
function positiveTiming(value: string | undefined, fallback: number): number {
@@ -41,6 +44,15 @@ export function installMainThreadHangWatchdog(options: {
parentPid: process.pid,
markerPath: hangDetectionMarkerPath(options.userDataPath),
tracePath: getTraceFilePath(),
artifactDirectory: nativeHangCaptureDirectory(options.userDataPath),
processMetadata: {
appVersion: app.getVersion(),
electronVersion: process.versions.electron ?? 'unknown',
chromeVersion: process.versions.chrome ?? 'unknown',
arch: os.arch(),
osRelease: os.release(),
osVersion: os.version()
},
timeoutMs: positiveTiming(process.env.ORCA_HANG_WATCHDOG_TIMEOUT_MS, HANG_WATCHDOG_TIMEOUT_MS),
checkIntervalMs: positiveTiming(
process.env.ORCA_HANG_WATCHDOG_CHECK_INTERVAL_MS,
@@ -117,5 +129,9 @@ export function installMainThreadHangWatchdog(options: {
})
worker.unref()
app.on('will-quit', stop)
return { stop, worker }
return {
stop,
worker,
setMenuBarIconSetting: (enabled) => postMessage({ type: 'menu-bar-setting', enabled })
}
}
@@ -0,0 +1,246 @@
import fs from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import { CrashReportStore } from '../crash-reporting/reports/crash-report-store'
import { createPreviousNativeHangReport } from './previous-native-hang-report'
import {
NativeHangCaptureStore,
nativeHangCaptureDirectory,
readNativeHangCapture,
readPreviousNativeHangCapture
} from './native-hang-capture-store'
import { sanitizeNativeHangReport, type NativeHangArtifact } from './native-hang-report'
import { readPreviousNativeHangTelemetry } from './previous-native-hang-telemetry'
import { mainThreadHangDetectedSchema } from '../../shared/telemetry-daemon-event-schemas'
import { getLatestPendingReport } from '../ipc/crash-reporting/crash-reporting-sendable-reports'
import { collectBundle } from '../observability/bundle'
const sample = `Sampling process 123 for 1 second with 10 milliseconds of run time between samples
Process: Orca [123]
Path: /Users/Test User/Secret Project/Orca.app/Contents/MacOS/Orca
Analysis Tool: /Users/Test User/Private Tools/sample
OS Version: macOS 26.1 (25B78)
Call graph:
100 Thread_1 DispatchQueue_1: com.apple.main-thread (serial)
+ 100 nativeWait (in Orca) + 12 [0x10000000c]
100 Thread_42 background-worker
+ 100 backgroundWork (in /Users/Test User/Private (Client List)/Worker) + 8 [0x100100008]
Total number in stack (recursive counted multiple, when >=5):
100 nativeWait
password=fixture-private-value
Binary Images:
0x100000000 - 0x1000fffff Orca (1.2.3) <AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA> /Users/Test User/Secret Project/Orca.app/Contents/MacOS/Orca
0x100100000 - 0x1001fffff Worker (4.5.6) <BBBBBBBB-BBBB-BBBB-BBBB-BBBBBBBBBBBB> /Users/Test User/Private Project/Worker
`
function artifact(time: number): NativeHangArtifact {
return {
version: 1,
captureId: `hang-${time}-00000000-0000-0000-0000-${String(time).padStart(12, '0')}`,
detectedAt: time,
capturedAt: time + 1,
pid: 123,
showMenuBarIcon: null,
process: {
appVersion: '1.2.3',
electronVersion: '43.8.6',
chromeVersion: '150',
arch: 'arm64',
osRelease: '25.1.0',
osVersion: 'Darwin Kernel Version 25.1.0'
},
report: sample
}
}
let directory: string
beforeEach(async () => {
vi.useFakeTimers({ toFake: ['Date'] })
vi.setSystemTime('2026-11-12T01:00:00.000Z')
directory = await fs.mkdtemp(join(tmpdir(), 'orca-native-hang-'))
})
afterEach(async () => {
vi.useRealTimers()
await fs.rm(directory, { recursive: true, force: true })
})
it('retains every sampled thread and image identity while removing full spaced paths and credentials', async () => {
const store = new NativeHangCaptureStore(directory)
await store.save(artifact(1))
const capture = await readNativeHangCapture(directory)
expect(capture?.artifact).toMatchObject({
showMenuBarIcon: null,
process: { appVersion: '1.2.3', electronVersion: '43.8.6' }
})
expect(capture?.artifact.report).toContain('Thread_42 background-worker')
expect(capture?.artifact.report).toContain('backgroundWork (in Worker) + 8 [0x100100008]')
expect(capture?.artifact.report).toContain('macOS 26.1 (25B78)')
expect(capture?.artifact.report).toContain(
'0x100100000 - 0x1001fffff Worker (4.5.6) <BBBBBBBB-BBBB-BBBB-BBBB-BBBBBBBBBBBB>'
)
expect(capture?.artifact.report).not.toMatch(
/Test User|Secret Project|Private Project|Client List|Private Tools|fixture-private-value/
)
expect((await fs.stat(capture!.path)).mode & 0o777).toBe(0o600)
})
it('withholds complete-artifact admission for a cut report or unsafe image row', () => {
expect(sanitizeNativeHangReport(sample.trimEnd())).toBeNull()
expect(sanitizeNativeHangReport(sample.replace('Binary Images:', 'Missing Images:'))).toBeNull()
expect(sanitizeNativeHangReport(`${sample}unparsed /Users/Test User/private image\n`)).toBeNull()
expect(sanitizeNativeHangReport(sample.replace('[0x100100008]', 'unknown location'))).toBeNull()
})
it.each([
{ count: 4, times: [1, 2, 3, 4] },
{ count: 40, times: [37, 38, 39, 40] }
])(
'bounds retained evidence after $count incidents and preserves unrelated files',
async ({ count, times }) => {
const store = new NativeHangCaptureStore(directory)
await fs.writeFile(join(directory, 'unrelated.txt'), 'keep')
await fs.writeFile(
join(directory, 'hang-0-00000000-0000-0000-0000-000000000000.tmp'),
'interrupted'
)
for (let time = 1; time <= count; time++) {
await store.save(artifact(time))
}
const names = await fs.readdir(directory)
const retained: number[] = []
for (const name of names.filter((name) => name.endsWith('.json'))) {
retained.push(JSON.parse(await fs.readFile(join(directory, name), 'utf8')).detectedAt)
}
expect(retained.sort((a, b) => a - b)).toEqual(times)
expect(names.filter((name) => name.endsWith('.tmp'))).toEqual([])
expect(await fs.readFile(join(directory, 'unrelated.txt'), 'utf8')).toBe('keep')
}
)
it('keeps manual freeze reports silent and preserves identity and sent status across restarts', async () => {
await new NativeHangCaptureStore(directory).save(artifact(1))
const store = new CrashReportStore(join(directory, 'reports.json'))
const previous = createPreviousNativeHangReport({ store, directory, processStartedAt: 100 })
await Promise.all([previous.ensureImported(), previous.ensureImported()])
const report = (await store.listRecent())[0]!
expect(await getLatestPendingReport(store)).toBeNull()
expect(report).toMatchObject({
status: 'dismissed',
details: { captureId: artifact(1).captureId, showMenuBarIcon: null }
})
await store.markDismissedSent(report.id)
await createPreviousNativeHangReport({ store, directory, processStartedAt: 200 }).ensureImported()
expect(await store.listRecent()).toMatchObject([{ id: report.id, status: 'sent' }])
expect((await readPreviousNativeHangCapture(directory, 200))?.artifact.captureId).toBe(
artifact(1).captureId
)
})
it('preserves an ordinary pending crash and imports freeze evidence on a later manual open', async () => {
await new NativeHangCaptureStore(directory).save(artifact(1))
const store = new CrashReportStore(join(directory, 'reports.json'))
const original = await store.record({
source: 'renderer',
processType: 'renderer',
reason: 'crashed',
exitCode: 5,
platform: 'darwin',
appVersion: '1',
electronVersion: '43',
chromeVersion: '150',
arch: 'arm64',
osRelease: '25',
details: {}
})
const previous = createPreviousNativeHangReport({ store, directory, processStartedAt: 100 })
await previous.ensureImported()
expect((await getLatestPendingReport(store))?.id).toBe(original.id)
expect(await store.listRecent()).toHaveLength(1)
await store.dismiss(original.id)
await previous.ensureImported()
expect((await store.listRecent())[0]).toMatchObject({
reason: 'main-thread-hang',
status: 'dismissed',
details: { captureId: artifact(1).captureId }
})
expect(await getLatestPendingReport(store)).toBeNull()
})
it('enriches only the matching freeze event with captured safe frames, build and setting', async () => {
const captures = nativeHangCaptureDirectory(directory)
await new NativeHangCaptureStore(captures).save({ ...artifact(1), showMenuBarIcon: false })
await new NativeHangCaptureStore(captures).save({ ...artifact(2), showMenuBarIcon: true })
const marker = { detectedAt: 1, parentPid: 123, unresponsiveMs: 50000, selfRecovered: false }
const details = await readPreviousNativeHangTelemetry(directory, marker)
expect(details).toEqual({
native_top_frames: ['nativeWait [Orca]'],
macos_build: 'macOS 26.1 (25B78)',
menu_bar_icon: false
})
expect(
mainThreadHangDetectedSchema.safeParse({
unresponsive_ms: 50000,
self_recovered: false,
...details
}).success
).toBe(true)
expect(await readPreviousNativeHangTelemetry(directory, { ...marker, parentPid: 999 })).toEqual(
{}
)
expect(await readPreviousNativeHangTelemetry(directory, { ...marker, detectedAt: 3 })).toEqual({})
})
it('selects the reported incident and never replaces missing evidence with a newer capture', async () => {
const store = new NativeHangCaptureStore(directory)
await store.save(artifact(1))
await store.save(artifact(2))
expect((await readNativeHangCapture(directory, artifact(1).captureId))?.artifact.detectedAt).toBe(
1
)
expect((await readNativeHangCapture(directory))?.artifact.detectedAt).toBe(2)
expect(await readNativeHangCapture(directory, artifact(3).captureId)).toBeNull()
})
it('keeps an intact large capture with both log sources inside the attachment limit', async () => {
const trace = join(directory, 'main.trace')
const daemon = join(directory, 'daemon.log')
await fs.writeFile(
trace,
`${JSON.stringify({ name: 'important-main-span', ts: '2026-11-12T00:59:00.000Z', value: 't'.repeat(16_000) })}\n`
)
await fs.writeFile(
daemon,
Array.from({ length: 200 }, () =>
JSON.stringify({ name: 'daemon-event', value: 'a'.repeat(8_000) })
).join('\n')
)
const clean = sample.replace('password=fixture-private-value\n', '')
const base = { type: 'native-hang-capture', status: 'complete', ...artifact(1), report: clean }
const escapedPadding = Math.floor((3_145_600 - Buffer.byteLength(JSON.stringify(base))) / 2)
const report = clean.replace(
'nativeWait (in Orca)',
`${'\\'.repeat(escapedPadding)}nativeWait (in Orca)`
)
const bundle = collectBundle({
traceFilePath: trace,
maxFiles: 1,
daemonLogFilePath: daemon,
appVersion: '1',
platform: 'darwin',
arch: 'arm64',
osRelease: '25',
orcaChannel: 'dev',
nativeHangRecord: { type: 'native-hang-capture', status: 'complete', ...artifact(1), report }
})
const lines = bundle.payload
.trimEnd()
.split('\n')
.map((line) => JSON.parse(line))
expect(lines.find((line) => line.type === 'native-hang-capture')?.report).toBe(report)
expect(lines.some((line) => line.name === 'important-main-span')).toBe(true)
expect(lines.some((line) => line.name === 'daemon-event')).toBe(true)
expect(Buffer.byteLength(bundle.payload)).toBeLessThanOrEqual(4_194_304)
expect(bundle.bytes).toBe(Buffer.byteLength(bundle.payload))
})
@@ -0,0 +1,178 @@
import { randomUUID } from 'node:crypto'
import { constants } from 'node:fs'
import fs from 'node:fs/promises'
import { join } from 'node:path'
import {
MAX_NATIVE_HANG_ARTIFACT_BYTES,
parseNativeHangArtifact,
type NativeHangArtifact
} from './native-hang-report'
const CAPTURE_ID = /^hang-(\d{1,16})-[\da-f]{8}-[\da-f]{4}-[\da-f]{4}-[\da-f]{4}-[\da-f]{12}$/
const CAPTURE_FILE = /^(hang-\d{1,16}-[\da-f-]{36})(\.(?:recovered|seen))?\.json$/
type CaptureFile = { name: string; captureId: string; detectedAt: number }
export type StoredNativeHangCapture = { artifact: NativeHangArtifact; path: string }
export function nativeHangCaptureDirectory(userDataPath: string): string {
return join(userDataPath, 'hang-captures')
}
export function createNativeHangCaptureId(detectedAt: number): string {
return `hang-${detectedAt}-${randomUUID()}`
}
function captureFile(name: string): CaptureFile | null {
const match = CAPTURE_FILE.exec(name)
const id = match && CAPTURE_ID.exec(match[1]!)
return match && id ? { name, captureId: match[1]!, detectedAt: Number(id[1]) } : null
}
async function entries(directory: string): Promise<string[] | null> {
try {
if (!(await fs.lstat(directory)).isDirectory()) {
return null
}
const handle = await fs.opendir(directory)
try {
const names: string[] = []
while (names.length < 64) {
const entry = await handle.read()
if (!entry) {
return names
}
names.push(entry.name)
}
return null
} finally {
await handle.close()
}
} catch {
return null
}
}
export async function listNativeHangCaptures(directory: string): Promise<CaptureFile[]> {
return ((await entries(directory)) ?? [])
.map(captureFile)
.filter((file): file is CaptureFile => file !== null)
.sort((a, b) => b.detectedAt - a.detectedAt || b.name.localeCompare(a.name))
}
async function readCaptureFile(
directory: string,
file: CaptureFile
): Promise<StoredNativeHangCapture | null> {
const filePath = join(directory, file.name)
let handle: Awaited<ReturnType<typeof fs.open>> | undefined
try {
handle = await fs.open(filePath, constants.O_RDONLY | constants.O_NOFOLLOW)
const stat = await handle.stat()
if (!stat.isFile() || stat.size > MAX_NATIVE_HANG_ARTIFACT_BYTES) {
return null
}
const bytes = Buffer.alloc(stat.size + 1)
const { bytesRead } = await handle.read(bytes, 0, bytes.length, 0)
if (bytesRead !== stat.size) {
return null
}
const parsed: unknown = JSON.parse(bytes.subarray(0, bytesRead).toString('utf8'))
const artifact = parseNativeHangArtifact(parsed)
return artifact &&
artifact.captureId === file.captureId &&
artifact.detectedAt === file.detectedAt
? { artifact, path: filePath }
: null
} catch {
return null
} finally {
await handle?.close()
}
}
export async function readNativeHangCapture(
directory: string,
captureId?: string
): Promise<StoredNativeHangCapture | null> {
const files = captureId
? CAPTURE_ID.test(captureId)
? ['', '.seen', '.recovered'].map((state) => captureFile(`${captureId}${state}.json`)!)
: []
: await listNativeHangCaptures(directory)
for (const file of files) {
const capture = await readCaptureFile(directory, file)
if (capture) {
return capture
}
}
return null
}
export async function readPreviousNativeHangCapture(
directory: string,
processStartedAt: number
): Promise<StoredNativeHangCapture | null> {
for (const file of await listNativeHangCaptures(directory)) {
if (file.detectedAt < processStartedAt) {
const capture = await readCaptureFile(directory, file)
if (capture) {
return capture
}
}
}
return null
}
export class NativeHangCaptureStore {
private busy = false
constructor(private readonly directory: string) {}
async save(artifact: NativeHangArtifact): Promise<void> {
if (this.busy || !CAPTURE_ID.test(artifact.captureId)) {
return
}
this.busy = true
const temporary = join(this.directory, `${artifact.captureId}.tmp`)
try {
const safe = parseNativeHangArtifact(artifact)
if (!safe) {
return
}
const payload = `${JSON.stringify(safe)}\n`
if (Buffer.byteLength(payload) > MAX_NATIVE_HANG_ARTIFACT_BYTES) {
return
}
await fs.mkdir(this.directory, { recursive: true, mode: 0o700 })
if (!(await fs.lstat(this.directory)).isDirectory()) {
return
}
await fs.chmod(this.directory, 0o700)
const names = await entries(this.directory)
if (!names) {
return
}
const captures = names
.map(captureFile)
.filter((file): file is CaptureFile => !!file)
.sort((a, b) => b.detectedAt - a.detectedAt)
for (const name of names) {
if (name.endsWith('.tmp') && CAPTURE_ID.test(name.slice(0, -4))) {
await fs.unlink(join(this.directory, name))
}
}
for (const [index, file] of captures.entries()) {
const stat = await fs.lstat(join(this.directory, file.name))
if (index >= 3 || stat.size > MAX_NATIVE_HANG_ARTIFACT_BYTES || !stat.isFile()) {
await fs.unlink(join(this.directory, file.name))
}
}
await fs.writeFile(temporary, payload, { mode: 0o600, flag: 'wx' })
await fs.rename(temporary, join(this.directory, `${artifact.captureId}.json`))
} catch {
// Optional full evidence must not interrupt marker capture or child cleanup.
} finally {
await fs.unlink(temporary).catch(() => {})
this.busy = false
}
}
}
+24 -5
View File
@@ -1,4 +1,5 @@
import type { runProcess as runNativeProcess } from '@orca/process-host'
import { MAX_NATIVE_HANG_REPORT_BYTES } from './native-hang-report'
import {
emptyHangStacks,
hangLabel,
@@ -117,8 +118,13 @@ export function reduceNativeHangSample(
export function captureNativeHang(options: {
pid: number
publish: (capture: NativeHangCapture) => void
}): { stop: () => void; done: Promise<void> } {
completeReport?: (report: string) => void
}): { stop: () => void; started: Promise<void>; done: Promise<void> } {
const abort = new AbortController()
let acquired: () => void = () => {}
const started = new Promise<void>((resolve) => {
acquired = resolve
})
const baseline: NativeHangCapture = {
...emptyHangStacks(),
state: 'collecting',
@@ -141,6 +147,7 @@ export function captureNativeHang(options: {
runProcess = (await import('@orca/process-host')).runProcess
} catch {
publish({ ...baseline, state: 'failed', completedAt: Date.now(), cleanup: 'unverified' })
acquired()
return
}
let terminated = false
@@ -149,11 +156,11 @@ export function captureNativeHang(options: {
release = resolve
})
try {
const result = await runProcess({
const pending = runProcess({
program: '/usr/bin/sample',
args: [String(options.pid), '1', '10', '-file', '/dev/stdout'],
timeoutMs: 2500,
maxOutputBytes: 128 * 1024,
timeoutMs: 10_000,
maxOutputBytes: MAX_NATIVE_HANG_REPORT_BYTES,
killOnOutputLimit: true,
signal: abort.signal,
onChildTerminated: () => {
@@ -161,6 +168,8 @@ export function captureNativeHang(options: {
release()
}
})
acquired()
const result = await pending
publish({
...baseline,
...reduceNativeHangSample(result.stdout),
@@ -171,6 +180,14 @@ export function captureNativeHang(options: {
cleanup: terminated ? 'terminated' : 'unverified',
...(result.code !== null ? { exitCode: result.code } : {})
})
if (
result.code === 0 &&
!result.timedOut &&
!result.outputTruncated &&
!abort.signal.aborted
) {
options.completeReport?.(result.stdout)
}
} catch {
publish({
...baseline,
@@ -178,11 +195,13 @@ export function captureNativeHang(options: {
completedAt: Date.now(),
cleanup: terminated ? 'terminated' : 'unverified'
})
} finally {
acquired()
}
if (!terminated) {
await termination
publish({ ...latest, cleanup: 'terminated' })
}
})()
return { stop: () => abort.abort(), done }
return { stop: () => abort.abort(), started, done }
}
@@ -0,0 +1,140 @@
import { basename } from 'node:path/posix'
import { sanitizeCrashReportString } from '../../shared/crash-report-redaction'
import { redactString } from '../observability/redactor'
export const MAX_NATIVE_HANG_REPORT_BYTES = 2 * 1024 * 1024
export const MAX_NATIVE_HANG_ARTIFACT_BYTES = 3 * 1024 * 1024
export type NativeHangProcessMetadata = {
appVersion: string
electronVersion: string
chromeVersion: string
arch: string
osRelease: string
osVersion: string
}
export type NativeHangArtifact = {
version: 1
captureId: string
detectedAt: number
capturedAt: number
pid: number
showMenuBarIcon: boolean | null
process: NativeHangProcessMetadata
report: string
}
export function sanitizeNativeHangReport(text: string): string | null {
if (
!text.endsWith('\n') ||
!/^OS Version:\s+.+\(.+\)/m.test(text) ||
!/^Call graph:/m.test(text) ||
!/^\s+\d+ Thread_\S+/m.test(text)
) {
return null
}
const lines = text.split('\n')
const images = lines.indexOf('Binary Images:')
if (images === -1) {
return null
}
let imageCount = 0
for (let index = 0; index < lines.length; index++) {
const line = lines[index]!
if (index > images && line.trim()) {
const image =
/^(\s*0x[\da-f]+\s+-\s+0x[\da-f]+\s+.+\s+<[\da-f]{8}-[\da-f]{4}-[\da-f]{4}-[\da-f]{4}-[\da-f]{12}>)\s+.+$/i.exec(
line
)
if (!image) {
return null
}
lines[index] = `${image[1]} [redacted-path]`
imageCount++
} else if (/^(?:Path|Analysis Tool):/.test(line)) {
lines[index] = `${line.slice(0, line.indexOf(':'))}: [redacted-path]`
} else {
lines[index] = line.replace(
/\(in (\/.+)\)(?=\s+(?:\+\s+\d+\s+)?\[0x[\da-f]+\])/gi,
(_match, location: string) => `(in ${basename(location)})`
)
if (lines[index]!.includes('(in /')) {
return null
}
}
}
if (!imageCount) {
return null
}
// Artifact admission bounds the whole value; scalar truncation would destroy image identities.
return redactString(sanitizeCrashReportString(lines.join('\n'), Number.MAX_SAFE_INTEGER))
}
export function parseNativeHangArtifact(value: unknown): NativeHangArtifact | null {
if (
!value ||
typeof value !== 'object' ||
!('version' in value) ||
value.version !== 1 ||
!('captureId' in value) ||
typeof value.captureId !== 'string' ||
!('detectedAt' in value) ||
typeof value.detectedAt !== 'number' ||
!Number.isSafeInteger(value.detectedAt) ||
!('capturedAt' in value) ||
typeof value.capturedAt !== 'number' ||
!Number.isSafeInteger(value.capturedAt) ||
!('pid' in value) ||
typeof value.pid !== 'number' ||
!Number.isSafeInteger(value.pid) ||
value.pid <= 0 ||
!('showMenuBarIcon' in value) ||
(typeof value.showMenuBarIcon !== 'boolean' && value.showMenuBarIcon !== null) ||
!('report' in value) ||
typeof value.report !== 'string' ||
!('process' in value) ||
!value.process ||
typeof value.process !== 'object'
) {
return null
}
const metadata = value.process
if (
!('appVersion' in metadata) ||
typeof metadata.appVersion !== 'string' ||
!('electronVersion' in metadata) ||
typeof metadata.electronVersion !== 'string' ||
!('chromeVersion' in metadata) ||
typeof metadata.chromeVersion !== 'string' ||
!('arch' in metadata) ||
typeof metadata.arch !== 'string' ||
!('osRelease' in metadata) ||
typeof metadata.osRelease !== 'string' ||
!('osVersion' in metadata) ||
typeof metadata.osVersion !== 'string'
) {
return null
}
const report = sanitizeNativeHangReport(value.report)
if (!report) {
return null
}
return {
version: 1,
captureId: value.captureId,
detectedAt: value.detectedAt,
capturedAt: value.capturedAt,
pid: value.pid,
showMenuBarIcon: value.showMenuBarIcon,
process: {
appVersion: sanitizeCrashReportString(metadata.appVersion),
electronVersion: sanitizeCrashReportString(metadata.electronVersion),
chromeVersion: sanitizeCrashReportString(metadata.chromeVersion),
arch: sanitizeCrashReportString(metadata.arch),
osRelease: sanitizeCrashReportString(metadata.osRelease),
osVersion: sanitizeCrashReportString(metadata.osVersion)
},
report
}
}
@@ -0,0 +1,45 @@
import type { CrashReportStore } from '../crash-reporting/reports/crash-report-store'
import { readPreviousNativeHangCapture } from './native-hang-capture-store'
export function createPreviousNativeHangReport(options: {
store: CrashReportStore
directory: string
processStartedAt: number
}): { ensureImported: () => Promise<void> } {
let importing: Promise<void> | undefined
const prepare = async (): Promise<void> => {
const capture = await readPreviousNativeHangCapture(options.directory, options.processStartedAt)
if (!capture) {
return
}
const artifact = capture.artifact
await options.store.importHangReport({
source: 'child',
processType: 'main',
reason: 'main-thread-hang',
exitCode: null,
appVersion: artifact.process.appVersion,
electronVersion: artifact.process.electronVersion,
chromeVersion: artifact.process.chromeVersion,
platform: 'darwin',
arch: artifact.process.arch,
osRelease: artifact.process.osRelease,
details: {
captureId: artifact.captureId,
detectedAt: new Date(artifact.detectedAt).toISOString(),
capturedAt: new Date(artifact.capturedAt).toISOString(),
targetPid: artifact.pid,
osVersion: artifact.process.osVersion,
showMenuBarIcon: artifact.showMenuBarIcon
}
})
}
return {
ensureImported: () =>
(importing ??= prepare()
.catch(() => {})
.finally(() => {
importing = undefined
}))
}
}
@@ -0,0 +1,41 @@
import type { EventProps } from '../../shared/telemetry-events'
import type { HangDetectionMarker } from './hang-detection-marker'
import {
listNativeHangCaptures,
nativeHangCaptureDirectory,
readNativeHangCapture
} from './native-hang-capture-store'
import { reduceNativeHangSample } from './native-hang-capture'
export type NativeHangTelemetry = Pick<
EventProps<'main_thread_hang_detected'>,
'native_top_frames' | 'macos_build' | 'menu_bar_icon'
>
export async function readPreviousNativeHangTelemetry(
userDataPath: string,
marker: HangDetectionMarker
): Promise<NativeHangTelemetry> {
const directory = nativeHangCaptureDirectory(userDataPath)
for (const file of await listNativeHangCaptures(directory)) {
if (file.detectedAt !== marker.detectedAt) {
continue
}
const capture = await readNativeHangCapture(directory, file.captureId)
if (!capture || capture.artifact.pid !== marker.parentPid) {
continue
}
const artifact = capture.artifact
const macosBuild = /^OS Version:\s*(macOS \d+(?:\.\d+){0,3} \([A-Za-z0-9]+\))\s*$/m.exec(
artifact.report
)?.[1]
return {
native_top_frames: reduceNativeHangSample(artifact.report).stacks.flatMap((stack) =>
stack.frames.map((frame) => `${frame.name} [${frame.module}]`)
),
...(macosBuild && macosBuild.length <= 64 ? { macos_build: macosBuild } : {}),
menu_bar_icon: artifact.showMenuBarIcon
}
}
return {}
}
@@ -0,0 +1,137 @@
import { createNativeHangCaptureId, NativeHangCaptureStore } from './native-hang-capture-store'
import type { NativeHangProcessMetadata } from './native-hang-report'
import type { HangObservation } from './hang-observations'
type CaptureOwner = {
id: string
reason?: 'recovery' | 'shutdown'
stop?: (reason: 'recovery' | 'shutdown') => void
}
export function createWatchdogHangCapture(options: {
parentPid: number
artifactDirectory?: string
processMetadata?: NativeHangProcessMetadata
publish: (id: string, change: Partial<HangObservation>) => void
drain: (token: string) => Promise<void>
onIdle: () => void
}): {
capture: (record: HangObservation, detectedAt: number, showMenuBarIcon: boolean | null) => void
recover: (id: string) => void
shutdown: () => void
} {
let stopped = false
let jsOwner: CaptureOwner | null = null
let nativeOwner: CaptureOwner | null = null
const store = options.artifactDirectory
? new NativeHangCaptureStore(options.artifactDirectory)
: null
const idle = (): void => {
if (stopped && !jsOwner && !nativeOwner) {
options.onIdle()
}
}
const capture = (
record: HangObservation,
detectedAt: number,
showMenuBarIcon: boolean | null
): void => {
if (stopped || record.kind !== 'heartbeat-stall') {
return
}
let nativeStarted: Promise<void> = Promise.resolve()
if (process.platform === 'darwin' && !nativeOwner) {
const owner: CaptureOwner = { id: record.id }
nativeOwner = owner
const captureId = createNativeHangCaptureId(record.detectedAt)
const metadata = options.processMetadata
nativeStarted = import('./native-hang-capture')
.then((native) => {
const child = native.captureNativeHang({
pid: options.parentPid,
publish: (native) => options.publish(record.id, { native }),
completeReport: (report) => {
if (store && metadata) {
void store.save({
version: 1,
captureId,
detectedAt: record.detectedAt,
capturedAt: Date.now(),
pid: options.parentPid,
showMenuBarIcon,
process: metadata,
report
})
}
}
})
owner.stop = () => child.stop()
if (owner.reason === 'shutdown') {
child.stop()
}
void child.done.finally(() => {
if (nativeOwner === owner) {
nativeOwner = null
}
idle()
})
return child.started
})
.catch(() => {
if (nativeOwner === owner) {
nativeOwner = null
}
idle()
})
}
if (!jsOwner) {
const owner: CaptureOwner = { id: record.id }
jsOwner = owner
void (async () => {
await nativeStarted
const [inspector, js] = await Promise.all([
import('node:inspector'),
import('./main-js-hang-capture')
])
const mainJs = js.captureMainJsHang({
session: new inspector.Session(),
detectedAt,
now: () => performance.now(),
publish: (mainJs) => options.publish(record.id, { mainJs }),
drain: () => options.drain(record.id)
})
owner.stop = mainJs.stop
if (owner.reason) {
mainJs.stop(owner.reason)
}
await mainJs.done
})()
.catch(() => {})
.finally(() => {
if (jsOwner === owner) {
jsOwner = null
}
idle()
})
}
}
return {
capture,
recover: (id) => {
if (jsOwner?.id === id) {
jsOwner.reason ??= 'recovery'
jsOwner.stop?.('recovery')
}
},
shutdown: () => {
stopped = true
for (const owner of [jsOwner, nativeOwner]) {
if (owner) {
owner.reason ??= 'shutdown'
owner.stop?.('shutdown')
}
}
idle()
}
}
}
@@ -23,8 +23,12 @@ export async function getLatestPendingReport(
): Promise<Awaited<ReturnType<CrashReportStore['getLatestPending']>>> {
const reports = await store.listRecent()
return (
reports.find((report) => report.status === 'pending' && !submittedReportIds.has(report.id)) ??
null
reports.find(
(report) =>
report.status === 'pending' &&
report.reason !== 'main-thread-hang' &&
!submittedReportIds.has(report.id)
) ?? null
)
}

Some files were not shown because too many files have changed in this diff Show More