From cc384c5a3d12508684156dd7ce5d3eaff121380f Mon Sep 17 00:00:00 2001 From: Mark Xian Date: Fri, 28 Aug 2026 03:32:29 +0800 Subject: [PATCH 01/22] fix(agent-hooks): post posix payloads as json (#11292) * fix(agent-hooks): post posix payloads as json * fix(agent-hooks): mark header merged envelopes * docs(agent-hooks): describe header merge envelope * fix(agent-hooks): encode posix metadata headers * test(agent-hooks): update WSL JSON hook assertions * fix(agent-hooks): negotiate raw JSON transport * fix(agent-hooks): preserve packed metadata in POSIX shells * test(agent-hooks): include hook envelope in relay boundary inventory --------- Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> --- config/tsconfig.cli.json | 1 + src/main/agent-hooks/hook-post-command.ts | 37 +++++++++ src/main/agent-hooks/installer-utils.test.ts | 21 +++++ .../server-endpoint-file-lifecycle.test.ts | 1 + .../server-hook-http-ingest.test.ts | 32 +++++++ src/main/agent-hooks/server.ts | 17 ++-- src/main/claude/hook-service.test.ts | 10 ++- src/main/claude/hook-service.ts | 19 ++--- .../codex/hook-service-wsl-runtime.test.ts | 6 ++ src/main/codex/hook-service.ts | 22 ++--- src/main/ipc/pty/host-env/spawn-env-keys.ts | 1 + src/main/pty/wsl-orca-env.test.ts | 2 + src/main/pty/wsl-orca-env.ts | 1 + src/main/runtime/orca-runtime.ts | 1 + src/relay/agent-hook-endpoint-coordinates.ts | 8 +- src/relay/agent-hook-server.test.ts | 40 ++++++--- src/relay/agent-hook-server.ts | 22 +++-- ...ent-hook-listener-relay-dependency.test.ts | 1 + .../agent-hook-listener-transport.test.ts | 81 +++++++++++++++++- .../endpoint-publication.ts | 4 + .../agent-hook-listener/hook-envelope.ts | 83 +++++++++++++++++++ src/shared/agent-hook-types.ts | 3 + 22 files changed, 356 insertions(+), 57 deletions(-) create mode 100644 src/main/agent-hooks/hook-post-command.ts diff --git a/config/tsconfig.cli.json b/config/tsconfig.cli.json index f4555ae4a48..cec4e80cff5 100644 --- a/config/tsconfig.cli.json +++ b/config/tsconfig.cli.json @@ -5,6 +5,7 @@ "../src/shared/**/*", "../src/main/agent-state-file-reader.ts", "../src/main/agent-hooks/hook-stdin-contract.ts", + "../src/main/agent-hooks/hook-post-command.ts", "../src/main/agent-hooks/hook-config-write-path.ts", "../src/main/agent-hooks/hooks-json-read.ts", "../src/main/agent-hooks/installer-utils.ts", diff --git a/src/main/agent-hooks/hook-post-command.ts b/src/main/agent-hooks/hook-post-command.ts new file mode 100644 index 00000000000..1595e681899 --- /dev/null +++ b/src/main/agent-hooks/hook-post-command.ts @@ -0,0 +1,37 @@ +import type { AgentHookSource } from '../../shared/agent-hook-relay' +import { ORCA_HOOK_RAW_JSON_TRANSPORT } from '../../shared/agent-hook-types' + +export function buildPosixAgentHookPostCommand( + source: AgentHookSource, + options: { curlCommand?: string; indent?: string } = {} +): string[] { + const curlCommand = options.curlCommand ?? 'curl' + const indent = options.indent ?? ' ' + return [ + `if [ "\${ORCA_AGENT_HOOK_TRANSPORT:-}" = "${ORCA_HOOK_RAW_JSON_TRANSPORT}" ] && command -v base64 >/dev/null 2>&1 && command -v tr >/dev/null 2>&1; then`, + ` orca_hook_metadata=$(printf '%s\\037%s\\037%s\\037%s\\037%s\\037%s' "$ORCA_PANE_KEY" "$ORCA_TAB_ID" "$ORCA_AGENT_LAUNCH_TOKEN" "$ORCA_WORKTREE_ID" "$ORCA_AGENT_HOOK_ENV" "$ORCA_AGENT_HOOK_VERSION" | base64 | tr -d '\\n') && \\`, + ` [ -n "$orca_hook_metadata" ] && \\`, + ` printf '%s' "$payload" | ${curlCommand} -sS -X POST "http://127.0.0.1:\${ORCA_AGENT_HOOK_PORT}/hook/${source}" \\`, + ` ${indent}--connect-timeout "\${connect_timeout:-0.5}" --max-time "\${max_time:-1.5}" \\`, + ` ${indent}--noproxy "127.0.0.1" \\`, + ` ${indent}-H "Content-Type: application/json" \\`, + ` ${indent}-H "X-Orca-Agent-Hook-Token: \${ORCA_AGENT_HOOK_TOKEN}" \\`, + ` ${indent}-H "X-Orca-Agent-Hook-Meta-Encoding: base64" \\`, + ` ${indent}-H "X-Orca-Agent-Hook-Meta: \${orca_hook_metadata}" \\`, + ` ${indent}--data-binary @-`, + 'else', + ` printf '%s' "$payload" | ${curlCommand} -sS -X POST "http://127.0.0.1:\${ORCA_AGENT_HOOK_PORT}/hook/${source}" \\`, + ` ${indent}--connect-timeout "\${connect_timeout:-0.5}" --max-time "\${max_time:-1.5}" \\`, + ` ${indent}--noproxy "127.0.0.1" \\`, + ` ${indent}-H "Content-Type: application/x-www-form-urlencoded" \\`, + ` ${indent}-H "X-Orca-Agent-Hook-Token: \${ORCA_AGENT_HOOK_TOKEN}" \\`, + ` ${indent}--data-urlencode "paneKey=\${ORCA_PANE_KEY}" \\`, + ` ${indent}--data-urlencode "tabId=\${ORCA_TAB_ID}" \\`, + ` ${indent}--data-urlencode "launchToken=\${ORCA_AGENT_LAUNCH_TOKEN}" \\`, + ` ${indent}--data-urlencode "worktreeId=\${ORCA_WORKTREE_ID}" \\`, + ` ${indent}--data-urlencode "env=\${ORCA_AGENT_HOOK_ENV}" \\`, + ` ${indent}--data-urlencode "version=\${ORCA_AGENT_HOOK_VERSION}" \\`, + ` ${indent}--data-urlencode "payload@-"`, + 'fi' + ] +} diff --git a/src/main/agent-hooks/installer-utils.test.ts b/src/main/agent-hooks/installer-utils.test.ts index 3cf2525ba1e..215979206e9 100644 --- a/src/main/agent-hooks/installer-utils.test.ts +++ b/src/main/agent-hooks/installer-utils.test.ts @@ -30,6 +30,7 @@ import { writeHooksJson, type HooksConfig } from './installer-utils' +import { buildPosixAgentHookPostCommand } from './hook-post-command' import { POSIX_HOOK_STDIN_DRAIN_COMMAND } from './hook-stdin-contract' import { wrapRuntimeHomeHookCommand } from './runtime-home-hook-command' @@ -882,6 +883,26 @@ describe('buildWindowsAgentHookPostCommand', () => { }) }) +describe('buildPosixAgentHookPostCommand', () => { + it('uses raw JSON only when the listener advertises support', () => { + const command = buildPosixAgentHookPostCommand('claude').join('\n') + + expect(command).toContain('ORCA_AGENT_HOOK_TRANSPORT:-}') + expect(command).toContain('raw-json-v1') + expect(command).toContain('command -v base64') + expect(command).toContain('command -v tr') + expect(command).toContain('Content-Type: application/json') + expect(command).toContain('X-Orca-Agent-Hook-Meta-Encoding: base64') + expect(command).toContain('X-Orca-Agent-Hook-Meta: ${orca_hook_metadata}') + expect(command).toContain("printf '%s\\037%s\\037%s\\037%s\\037%s\\037%s'") + expect(command).toContain('$ORCA_PANE_KEY') + expect(command).toContain('$ORCA_WORKTREE_ID') + expect(command).toContain('--data-binary @-') + expect(command).toContain('Content-Type: application/x-www-form-urlencoded') + expect(command).toContain('--data-urlencode "payload@-"') + }) +}) + describe('buildWindowsAgentHookCurlPostCommand', () => { it('posts form fields via curl.exe and reads the payload from stdin', () => { const command = buildWindowsAgentHookCurlPostCommand('codex') diff --git a/src/main/agent-hooks/server-endpoint-file-lifecycle.test.ts b/src/main/agent-hooks/server-endpoint-file-lifecycle.test.ts index b567e726695..91b7c495532 100644 --- a/src/main/agent-hooks/server-endpoint-file-lifecycle.test.ts +++ b/src/main/agent-hooks/server-endpoint-file-lifecycle.test.ts @@ -66,6 +66,7 @@ describe('Endpoint file lifecycle', () => { expect(contents).toContain(`${prefix}ORCA_AGENT_HOOK_TOKEN=${expectedToken}`) expect(contents).toContain(`${prefix}ORCA_AGENT_HOOK_ENV=development`) expect(contents).toContain(`${prefix}ORCA_AGENT_HOOK_VERSION=1`) + expect(contents).toContain(`${prefix}ORCA_AGENT_HOOK_TRANSPORT=raw-json-v1`) } finally { server.stop() } diff --git a/src/main/agent-hooks/server-hook-http-ingest.test.ts b/src/main/agent-hooks/server-hook-http-ingest.test.ts index 55f2c6e1007..f89dcebd005 100644 --- a/src/main/agent-hooks/server-hook-http-ingest.test.ts +++ b/src/main/agent-hooks/server-hook-http-ingest.test.ts @@ -44,6 +44,38 @@ async function postClaudeHook( } describe('AgentHookServer listener replay', () => { + it('accepts raw JSON hook bodies with base64 metadata headers', async () => { + const server = new AgentHookServer() + await server.start({ env: 'production' }) + try { + const env = server.buildPtyEnv() + const response = await fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/claude`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN, + 'X-Orca-Agent-Hook-Meta-Encoding': 'base64', + 'X-Orca-Agent-Hook-Meta': Buffer.from( + [PANE, 'tab-1', '', 'wt-1', 'production', ''].join('\x1f') + ).toString('base64') + }, + body: JSON.stringify({ hook_event_name: 'UserPromptSubmit', prompt: 'raw JSON' }) + }) + + expect(response.status).toBe(204) + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ + paneKey: PANE, + worktreeId: 'wt-1', + state: 'working', + prompt: 'raw JSON' + }) + ]) + } finally { + server.stop() + } + }) + it('caches and notifies status/main/plugin before retry scheduling and HTTP response', async () => { const server = new AgentHookServer() await server.start({ env: 'production' }) diff --git a/src/main/agent-hooks/server.ts b/src/main/agent-hooks/server.ts index ba29d3bc211..311ea277879 100644 --- a/src/main/agent-hooks/server.ts +++ b/src/main/agent-hooks/server.ts @@ -8,7 +8,10 @@ import { join } from 'node:path' import { track } from '../telemetry/client' import { getCohortAtEmit } from '../telemetry/cohort-classifier' import { AGENT_KIND_VALUES, type AgentKind } from '../../shared/telemetry-events' -import { ORCA_HOOK_PROTOCOL_VERSION } from '../../shared/agent-hook-types' +import { + ORCA_HOOK_PROTOCOL_VERSION, + ORCA_HOOK_RAW_JSON_TRANSPORT +} from '../../shared/agent-hook-types' import { clearAllListenerCaches, clearPaneCacheState, @@ -46,6 +49,7 @@ import { } from '../../shared/agent-hook-listener/listener-limits' import { isNewTurnEvent } from '../../shared/agent-hook-listener/provider-event-routing' import { normalizeHookPayload } from '../../shared/agent-hook-listener' +import { mergeAgentHookRequestHeaders } from '../../shared/agent-hook-listener/hook-envelope' import { parseFormEncodedBody, readRequestBody @@ -2616,8 +2620,9 @@ export class AgentHookServer { return } - trackEmptyPaneKeyHook(body) - const aliasedBody = this.normalizeHookBodyPaneKeyAlias(body) + const hookBody = mergeAgentHookRequestHeaders(body, req.headers) + trackEmptyPaneKeyHook(hookBody) + const aliasedBody = this.normalizeHookBodyPaneKeyAlias(hookBody) const normalized = this.normalizeLocalHookPayload(source, aliasedBody) const statusDisposition = normalized.event ? this.getAgentStatusDisposition(normalized.event.paneKey, { @@ -3129,7 +3134,8 @@ export class AgentHookServer { ORCA_AGENT_HOOK_PORT: String(this.port), ORCA_AGENT_HOOK_TOKEN: this.token, ORCA_AGENT_HOOK_ENV: this.env, - ORCA_AGENT_HOOK_VERSION: ORCA_HOOK_PROTOCOL_VERSION + ORCA_AGENT_HOOK_VERSION: ORCA_HOOK_PROTOCOL_VERSION, + ORCA_AGENT_HOOK_TRANSPORT: ORCA_HOOK_RAW_JSON_TRANSPORT } // Why: hooks source this file at invocation; dev namespaces it so parallel `pnpm dev` runs don't steal each other's hooks. if (this.endpointFileWritten && this.endpointFilePathCache) { @@ -3156,7 +3162,8 @@ export class AgentHookServer { port: this.port, token: this.token, env: this.env, - version: ORCA_HOOK_PROTOCOL_VERSION + version: ORCA_HOOK_PROTOCOL_VERSION, + transport: ORCA_HOOK_RAW_JSON_TRANSPORT }) this.endpointFileWritten = ok } diff --git a/src/main/claude/hook-service.test.ts b/src/main/claude/hook-service.test.ts index 531c092e3eb..e2937015bea 100644 --- a/src/main/claude/hook-service.test.ts +++ b/src/main/claude/hook-service.test.ts @@ -539,12 +539,14 @@ describe('ClaudeHookService.installRemote', () => { expect(script!.indexOf('printf "{}\\n"')).toBe( script!.indexOf('#!/bin/sh') + '#!/bin/sh\n'.length ) - // Why: payload is piped to curl via stdin (`payload@-`) so it never lands - // on the curl command line (EDR oversized-command-line false positive), - // matching the Windows curl.exe hook post. + // Why: payload stays on stdin, while metadata headers avoid URL-encoded IDS signatures. expect(script).toContain('printf \'%s\' "$payload" | curl') + expect(script).toContain('-H "Content-Type: application/json"') + expect(script).toContain('orca_hook_metadata=$(printf') + expect(script).toContain('unset ORCA_AGENT_HOOK_TRANSPORT') + expect(script).toContain('-H "X-Orca-Agent-Hook-Meta: ${orca_hook_metadata}"') + expect(script).toContain('--data-binary @-') expect(script).toContain('--data-urlencode "payload@-"') - expect(script).not.toContain('--data-urlencode "payload=${payload}"') expect(fs.modes.get('/home/dev/.orca/agent-hooks/claude-hook.sh')).toBe(0o755) // Why: no remote statusLine — this path serves SSH remotes and WSL guests, whose relay // listener doesn't route /statusline/claude and whose accounts aren't attributable locally. diff --git a/src/main/claude/hook-service.ts b/src/main/claude/hook-service.ts index 3dba1121a20..b3ae8d01136 100644 --- a/src/main/claude/hook-service.ts +++ b/src/main/claude/hook-service.ts @@ -9,6 +9,7 @@ import { writeManagedScript, type HooksConfig } from '../agent-hooks/installer-utils' +import { buildPosixAgentHookPostCommand } from '../agent-hooks/hook-post-command' import { readHooksJsonRemote, writeHooksJsonRemote, @@ -114,25 +115,17 @@ function getManagedScript( // Why: refresh endpoint coordinates for PTYs surviving an Orca restart. // Why: suppress parse errors so they neither leak nor trip outer set -e. 'if [ -n "$ORCA_AGENT_HOOK_ENDPOINT" ] && [ -r "$ORCA_AGENT_HOOK_ENDPOINT" ]; then', + ' unset ORCA_AGENT_HOOK_TRANSPORT', ' . "$ORCA_AGENT_HOOK_ENDPOINT" 2>/dev/null || :', 'fi', 'if [ -z "$ORCA_AGENT_HOOK_PORT" ] || [ -z "$ORCA_AGENT_HOOK_TOKEN" ] || [ -z "$ORCA_PANE_KEY" ]; then', ' spool_hook_event', ' exit 0', 'fi', - // Why: post form fields because path-bearing payloads are unsafe in hand-built JSON. - // Why: pipe payload to curl stdin to keep large output off the command line. - 'printf \'%s\' "$payload" | curl -sS -X POST "http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/claude" \\', - ' --connect-timeout 0.5 --max-time 1.5 \\', - ' -H "Content-Type: application/x-www-form-urlencoded" \\', - ' -H "X-Orca-Agent-Hook-Token: ${ORCA_AGENT_HOOK_TOKEN}" \\', - ' --data-urlencode "paneKey=${ORCA_PANE_KEY}" \\', - ' --data-urlencode "tabId=${ORCA_TAB_ID}" \\', - ' --data-urlencode "launchToken=${ORCA_AGENT_LAUNCH_TOKEN}" \\', - ' --data-urlencode "worktreeId=${ORCA_WORKTREE_ID}" \\', - ' --data-urlencode "env=${ORCA_AGENT_HOOK_ENV}" \\', - ' --data-urlencode "version=${ORCA_AGENT_HOOK_VERSION}" \\', - ' --data-urlencode "payload@-" >/dev/null 2>&1 || spool_hook_event', + // Why: keep full hook JSON off the command line and avoid IDS-friendly URL-encoded paths. + ...buildPosixAgentHookPostCommand('claude').map((line, index, lines) => + index === lines.length - 1 ? `${line} >/dev/null 2>&1 || spool_hook_event` : line + ), 'exit 0', '' ].join('\n') diff --git a/src/main/codex/hook-service-wsl-runtime.test.ts b/src/main/codex/hook-service-wsl-runtime.test.ts index 8e892e69147..5694e4fe9bb 100644 --- a/src/main/codex/hook-service-wsl-runtime.test.ts +++ b/src/main/codex/hook-service-wsl-runtime.test.ts @@ -275,11 +275,15 @@ describe('Codex WSL runtime hook install', () => { it('generates a POSIX hook that bridges WSL loopback failures through Windows curl', async () => { const script = _internals.getManagedScript('posix') expect(script).toContain('load_hook_endpoint()') + expect(script).toContain('unset ORCA_AGENT_HOOK_TRANSPORT') expect(script).toContain('"set ORCA_AGENT_HOOK_TOKEN="*)') expect(script).toContain('post_codex_hook()') expect(script).toContain('is_wsl_runtime()') expect(script).toContain('WSL_DISTRO_NAME') expect(script).toContain('windows_curl=$(command -v curl.exe 2>/dev/null || true)') + expect(script).toContain('-H "Content-Type: application/json"') + expect(script).toContain('-H "X-Orca-Agent-Hook-Meta-Encoding: base64"') + expect(script).toContain('--data-binary @-') expect(script).toContain('--data-urlencode "payload@-"') expect(script).toContain('if post_codex_hook curl >/dev/null 2>&1; then') expect(script).toContain('post_codex_hook "$windows_curl" 3 5 >/dev/null 2>&1 || true') @@ -302,6 +306,7 @@ describe('Codex WSL runtime hook install', () => { 'set ORCA_AGENT_HOOK_TOKEN=fresh-token', 'set ORCA_AGENT_HOOK_ENV=development', 'set ORCA_AGENT_HOOK_VERSION=1', + 'set ORCA_AGENT_HOOK_TRANSPORT=raw-json-v1', '' ].join('\r\n'), 'utf-8' @@ -333,6 +338,7 @@ describe('Codex WSL runtime hook install', () => { const posted = readFileSync(capturePath, 'utf-8') expect(posted).toContain('http://127.0.0.1:43210/hook/codex') expect(posted).toContain('X-Orca-Agent-Hook-Token: fresh-token') + expect(posted).toContain('Content-Type: application/json') expect(posted).not.toContain('stale-token') } ) diff --git a/src/main/codex/hook-service.ts b/src/main/codex/hook-service.ts index a25f14bcd89..0ea7837f154 100644 --- a/src/main/codex/hook-service.ts +++ b/src/main/codex/hook-service.ts @@ -19,6 +19,7 @@ import { writeManagedScript, type HookDefinition } from '../agent-hooks/installer-utils' +import { buildPosixAgentHookPostCommand } from '../agent-hooks/hook-post-command' import { refreshManagedScriptIfPresent } from '../agent-hooks/managed-hook-script-refresh' import { resolveHooksJsonWritePath } from '../agent-hooks/hook-config-write-path' import { writeFileAtomically } from '../codex-accounts/fs-utils' @@ -823,6 +824,7 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string { // Why: sourcing refreshes PORT/TOKEN/ENV/VERSION from the current Orca so a surviving PTY keeps reporting after a restart (see claude/hook-service.ts). 'load_hook_endpoint() {', ' endpoint_path="$1"', + ' unset ORCA_AGENT_HOOK_TRANSPORT', ' case "$endpoint_path" in', ' *.cmd)', // Why: Windows passes endpoint.cmd into WSL via WSLENV; parse only Orca's known assignments since cmd.exe `set` lines aren't shell syntax. @@ -834,6 +836,7 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string { ' "set ORCA_AGENT_HOOK_TOKEN="*) ORCA_AGENT_HOOK_TOKEN=${endpoint_line#*=} ;;', ' "set ORCA_AGENT_HOOK_ENV="*) ORCA_AGENT_HOOK_ENV=${endpoint_line#*=} ;;', ' "set ORCA_AGENT_HOOK_VERSION="*) ORCA_AGENT_HOOK_VERSION=${endpoint_line#*=} ;;', + ' "set ORCA_AGENT_HOOK_TRANSPORT="*) ORCA_AGENT_HOOK_TRANSPORT=${endpoint_line#*=} ;;', ' esac', ' done < "$endpoint_path"', ' ;;', @@ -853,20 +856,11 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string { ' curl_bin="$1"', ' connect_timeout="${2:-0.5}"', ' max_time="${3:-1.5}"', - // Why: worktreeId embeds a path, so hand-building JSON in shell is unsafe with quotes/newlines; post raw payload plus metadata as form fields instead. - // Why: pipe payload to curl's stdin (`payload@-`) not an inline arg, so tens-of-KB tool output stays off the command line (EDR false positives). - ' printf \'%s\' "$payload" | "$curl_bin" -sS -X POST "http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/codex" \\', - ' --connect-timeout "$connect_timeout" --max-time "$max_time" \\', - ' --noproxy "127.0.0.1" \\', - ' -H "Content-Type: application/x-www-form-urlencoded" \\', - ' -H "X-Orca-Agent-Hook-Token: ${ORCA_AGENT_HOOK_TOKEN}" \\', - ' --data-urlencode "paneKey=${ORCA_PANE_KEY}" \\', - ' --data-urlencode "tabId=${ORCA_TAB_ID}" \\', - ' --data-urlencode "launchToken=${ORCA_AGENT_LAUNCH_TOKEN}" \\', - ' --data-urlencode "worktreeId=${ORCA_WORKTREE_ID}" \\', - ' --data-urlencode "env=${ORCA_AGENT_HOOK_ENV}" \\', - ' --data-urlencode "version=${ORCA_AGENT_HOOK_VERSION}" \\', - ' --data-urlencode "payload@-"', + // Why: keep full hook JSON off the command line and avoid URL-encoding paths/commands into IDS-friendly traversal signatures. + ...buildPosixAgentHookPostCommand('codex', { + curlCommand: '"$curl_bin"', + indent: ' ' + }).map((line) => ` ${line}`), '}', 'is_wsl_runtime() {', ' [ -n "$WSL_DISTRO_NAME" ] && return 0', diff --git a/src/main/ipc/pty/host-env/spawn-env-keys.ts b/src/main/ipc/pty/host-env/spawn-env-keys.ts index 0cd0b183729..cabbde7b0ec 100644 --- a/src/main/ipc/pty/host-env/spawn-env-keys.ts +++ b/src/main/ipc/pty/host-env/spawn-env-keys.ts @@ -3,6 +3,7 @@ export const AGENT_HOOK_RUNTIME_ENV_KEYS = [ 'ORCA_AGENT_HOOK_TOKEN', 'ORCA_AGENT_HOOK_ENV', 'ORCA_AGENT_HOOK_VERSION', + 'ORCA_AGENT_HOOK_TRANSPORT', 'ORCA_AGENT_HOOK_ENDPOINT', // Why: PR 2778 briefly exported this path; keep deleting stale inherited values so older PTYs can't leak the reverted path. 'ORCA_CLAUDE_AGENT_STATUS_SETTINGS' diff --git a/src/main/pty/wsl-orca-env.test.ts b/src/main/pty/wsl-orca-env.test.ts index f1275403001..133844dec78 100644 --- a/src/main/pty/wsl-orca-env.test.ts +++ b/src/main/pty/wsl-orca-env.test.ts @@ -73,6 +73,7 @@ describe('addOrcaWslInteropEnv', () => { ORCA_AGENT_HOOK_TOKEN: 'token', ORCA_AGENT_HOOK_ENV: 'dev', ORCA_AGENT_HOOK_VERSION: '1', + ORCA_AGENT_HOOK_TRANSPORT: 'raw-json-v1', ORCA_WSL_HOOK_INSTANCE: 'testinstance', ORCA_ORCHESTRATION_COMPATIBILITY_HOST_KIND: 'wsl', ORCA_ORCHESTRATION_COMPATIBILITY_HOST_ID: 'local', @@ -95,6 +96,7 @@ describe('addOrcaWslInteropEnv', () => { expect(env.WSLENV).toContain('ORCA_AGENT_HOOK_TOKEN/u') expect(env.WSLENV).toContain('ORCA_AGENT_HOOK_ENV/u') expect(env.WSLENV).toContain('ORCA_AGENT_HOOK_VERSION/u') + expect(env.WSLENV).toContain('ORCA_AGENT_HOOK_TRANSPORT/u') expect(env.WSLENV).toContain('ORCA_WSL_HOOK_INSTANCE/u') expect(env.WSLENV).toContain('ORCA_ORCHESTRATION_COMPATIBILITY_HOST_KIND/u') expect(env.WSLENV).toContain('ORCA_ORCHESTRATION_COMPATIBILITY_HOST_ID/u') diff --git a/src/main/pty/wsl-orca-env.ts b/src/main/pty/wsl-orca-env.ts index a6fad65ca61..0fe855ba3b0 100644 --- a/src/main/pty/wsl-orca-env.ts +++ b/src/main/pty/wsl-orca-env.ts @@ -90,6 +90,7 @@ export function addOrcaWslInteropEnv(env: Record): void { 'ORCA_AGENT_HOOK_TOKEN/u', 'ORCA_AGENT_HOOK_ENV/u', 'ORCA_AGENT_HOOK_VERSION/u', + 'ORCA_AGENT_HOOK_TRANSPORT/u', `ORCA_AGENT_HOOK_ENDPOINT/${endpointFlag}`, ...opencodeOverlayEntries, 'ORCA_WSL_HOOK_RELAY_VERSION/u', diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 0b25b3cef46..f83e5e777e4 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -2845,6 +2845,7 @@ const AGENT_HOOK_RUNTIME_ENV_KEYS = [ 'ORCA_AGENT_HOOK_TOKEN', 'ORCA_AGENT_HOOK_ENV', 'ORCA_AGENT_HOOK_VERSION', + 'ORCA_AGENT_HOOK_TRANSPORT', 'ORCA_AGENT_HOOK_ENDPOINT' ] as const diff --git a/src/relay/agent-hook-endpoint-coordinates.ts b/src/relay/agent-hook-endpoint-coordinates.ts index 868fc120689..ac066f8afd9 100644 --- a/src/relay/agent-hook-endpoint-coordinates.ts +++ b/src/relay/agent-hook-endpoint-coordinates.ts @@ -4,7 +4,10 @@ import { basename, dirname, join } from 'node:path' import { homedir } from 'node:os' -import { ORCA_HOOK_PROTOCOL_VERSION } from '../shared/agent-hook-types' +import { + ORCA_HOOK_PROTOCOL_VERSION, + ORCA_HOOK_RAW_JSON_TRANSPORT +} from '../shared/agent-hook-types' // Why: relay's userData equivalent under $HOME so each user on a shared dev box gets their own 0o700 dir. const RELAY_HOOKS_DIR_NAME = '.orca-relay' @@ -49,7 +52,8 @@ export function buildRelayHookPtyEnv(coordinates: { ORCA_AGENT_HOOK_PORT: String(coordinates.port), ORCA_AGENT_HOOK_TOKEN: coordinates.token, ORCA_AGENT_HOOK_ENV: coordinates.env, - ORCA_AGENT_HOOK_VERSION: ORCA_HOOK_PROTOCOL_VERSION + ORCA_AGENT_HOOK_VERSION: ORCA_HOOK_PROTOCOL_VERSION, + ORCA_AGENT_HOOK_TRANSPORT: ORCA_HOOK_RAW_JSON_TRANSPORT } if (coordinates.endpointFileWritten) { env.ORCA_AGENT_HOOK_ENDPOINT = coordinates.endpointFilePath diff --git a/src/relay/agent-hook-server.test.ts b/src/relay/agent-hook-server.test.ts index e247706b5dc..c3a99b8a20c 100644 --- a/src/relay/agent-hook-server.test.ts +++ b/src/relay/agent-hook-server.test.ts @@ -8,6 +8,7 @@ import { endpointDirForRelaySocket } from './agent-hook-endpoint-coordinates' import type { AgentHookRelayEnvelope } from '../shared/agent-hook-relay' import { makePaneKey } from '../shared/stable-pane-id' import * as agentHookListener from '../shared/agent-hook-listener/grok-result-discovery' +import { HOOK_REQUEST_MAX_BYTES } from '../shared/agent-hook-listener/request-body' const LEAF_ID = '11111111-1111-4111-8111-111111111111' const PANE_KEY = makePaneKey('tab-1', LEAF_ID) @@ -42,7 +43,7 @@ describe('RelayAgentHookServer', () => { expect(endpointDir).not.toContain('\\\\.\\pipe') }) - it('forwards a parsed Claude UserPromptSubmit POST as a normalized envelope', async () => { + it('forwards a raw Claude JSON POST with base64 metadata headers', async () => { const forward = vi.fn<(envelope: AgentHookRelayEnvelope) => void>() const server = new RelayAgentHookServer({ endpointDir: dir, forward }) await server.start() @@ -52,16 +53,13 @@ describe('RelayAgentHookServer', () => { method: 'POST', headers: { 'Content-Type': 'application/json', - 'X-Orca-Agent-Hook-Token': token + 'X-Orca-Agent-Hook-Token': token, + 'X-Orca-Agent-Hook-Meta-Encoding': 'base64', + 'X-Orca-Agent-Hook-Meta': Buffer.from( + [PANE_KEY, 'tab-1', '', 'wt-1', 'remote', '1'].join('\x1f') + ).toString('base64') }, - body: JSON.stringify({ - paneKey: PANE_KEY, - tabId: 'tab-1', - worktreeId: 'wt-1', - env: 'remote', - version: '1', - payload: { hook_event_name: 'UserPromptSubmit', prompt: 'hi' } - }) + body: JSON.stringify({ hook_event_name: 'UserPromptSubmit', prompt: 'hi' }) }) expect(res.status).toBe(204) expect(forward).toHaveBeenCalledTimes(1) @@ -330,6 +328,27 @@ describe('RelayAgentHookServer', () => { } }) + it('rejects unknown hook paths before parsing the request body', async () => { + const forward = vi.fn() + const server = new RelayAgentHookServer({ endpointDir: dir, forward }) + await server.start() + try { + const { port, token } = server.getCoordinates() + const res = await fetch(`http://127.0.0.1:${port}/hook/unknown`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Orca-Agent-Hook-Token': token + }, + body: JSON.stringify({ value: 'x'.repeat(HOOK_REQUEST_MAX_BYTES + 1) }) + }) + expect(res.status).toBe(404) + expect(forward).not.toHaveBeenCalled() + } finally { + server.stop() + } + }) + it('replays cached payloads on demand', async () => { const forward = vi.fn<(envelope: AgentHookRelayEnvelope) => void>() const server = new RelayAgentHookServer({ endpointDir: dir, forward }) @@ -492,6 +511,7 @@ describe('RelayAgentHookServer', () => { expect(env.ORCA_AGENT_HOOK_TOKEN).toBeTruthy() expect(env.ORCA_AGENT_HOOK_ENV).toBe('remote') expect(env.ORCA_AGENT_HOOK_VERSION).toBe('1') + expect(env.ORCA_AGENT_HOOK_TRANSPORT).toBe('raw-json-v1') expect(env.ORCA_AGENT_HOOK_ENDPOINT).toBeTruthy() } finally { server.stop() diff --git a/src/relay/agent-hook-server.ts b/src/relay/agent-hook-server.ts index ec980470dd4..59c1cbd8e00 100644 --- a/src/relay/agent-hook-server.ts +++ b/src/relay/agent-hook-server.ts @@ -2,7 +2,10 @@ import { createServer, type IncomingMessage, type ServerResponse } from 'node:ht import { randomUUID } from 'node:crypto' import { join } from 'node:path' -import { ORCA_HOOK_PROTOCOL_VERSION } from '../shared/agent-hook-types' +import { + ORCA_HOOK_PROTOCOL_VERSION, + ORCA_HOOK_RAW_JSON_TRANSPORT +} from '../shared/agent-hook-types' import { clearAllListenerCaches, clearPaneCacheState, @@ -15,6 +18,7 @@ import { } from '../shared/agent-hook-listener/endpoint-publication' import { HOOK_REQUEST_SLOWLORIS_MS } from '../shared/agent-hook-listener/listener-limits' import { normalizeHookPayload } from '../shared/agent-hook-listener' +import { mergeAgentHookRequestHeaders } from '../shared/agent-hook-listener/hook-envelope' import { readRequestBody } from '../shared/agent-hook-listener/request-body' import { resolveHookSource } from '../shared/agent-hook-listener/source-routing' import type { AgentHookEventPayload } from '../shared/agent-hook-listener/listener-event' @@ -174,7 +178,8 @@ export class RelayAgentHookServer { port: this.port, token: this.token, env: this.env, - version: ORCA_HOOK_PROTOCOL_VERSION + version: ORCA_HOOK_PROTOCOL_VERSION, + transport: ORCA_HOOK_RAW_JSON_TRANSPORT }) return this.endpointFileWritten } @@ -252,7 +257,6 @@ export class RelayAgentHookServer { req.destroy() }) try { - const body = await readRequestBody(req) const pathname = new URL(req.url ?? '/', 'http://127.0.0.1').pathname const source = resolveHookSource(pathname) if (!source) { @@ -260,16 +264,18 @@ export class RelayAgentHookServer { res.end() return } - const event = normalizeHookPayload(this.state, source, body, this.env, { + const body = await readRequestBody(req) + const hookBody = mergeAgentHookRequestHeaders(body, req.headers) + const event = normalizeHookPayload(this.state, source, hookBody, this.env, { deferCompactOwnershipToClient: true }) if (event) { // TODO: once normalizeHookPayload returns validated env/version, drop bodyEnv/bodyVersion and source them from the listener result. - const env = hookBodyEnv(body) - const version = hookBodyVersion(body) + const env = hookBodyEnv(hookBody) + const version = hookBodyVersion(hookBody) this.applyEvent(event, source, env, version) - this.retryScheduler.scheduleAssistantMessageRetry(source, body, event, env, version) - this.retryScheduler.scheduleCodexSubagentPoll(source, body, event, env, version) + this.retryScheduler.scheduleAssistantMessageRetry(source, hookBody, event, env, version) + this.retryScheduler.scheduleCodexSubagentPoll(source, hookBody, event, env, version) } res.writeHead(204) res.end() diff --git a/src/shared/agent-hook-listener-relay-dependency.test.ts b/src/shared/agent-hook-listener-relay-dependency.test.ts index e05ef801f7f..e23277e9f18 100644 --- a/src/shared/agent-hook-listener-relay-dependency.test.ts +++ b/src/shared/agent-hook-listener-relay-dependency.test.ts @@ -122,6 +122,7 @@ describe('agent hook listener relay dependency boundary', () => { 'agent-hook-listener.ts', 'agent-hook-listener/endpoint-publication.ts', 'agent-hook-listener/grok-result-discovery.ts', + 'agent-hook-listener/hook-envelope.ts', 'agent-hook-listener/listener-limits.ts', 'agent-hook-listener/listener-state.ts', 'agent-hook-listener/providers/codex-state.ts', diff --git a/src/shared/agent-hook-listener-transport.test.ts b/src/shared/agent-hook-listener-transport.test.ts index 344be39eca3..f0a86986414 100644 --- a/src/shared/agent-hook-listener-transport.test.ts +++ b/src/shared/agent-hook-listener-transport.test.ts @@ -14,7 +14,10 @@ import { parseFormEncodedBody, readRequestBody } from './agent-hook-listener/request-body' +import { mergeAgentHookRequestHeaders } from './agent-hook-listener/hook-envelope' +import { createHookListenerState } from './agent-hook-listener/listener-state' import { resolveHookSource } from './agent-hook-listener/source-routing' +import { normalizeHookPayload } from './agent-hook-listener' import { clearGrokSessionPathLookupCacheForTests } from './grok-session-paths' type FakeIncomingMessage = EventEmitter & { @@ -38,6 +41,10 @@ function expectRequestParserListenersReleased(req: FakeIncomingMessage): void { } describe('shared agent-hook-listener', () => { + const paneKey = 'tab-1:11111111-1111-4111-8111-111111111111' + const b64 = (value: string): string => Buffer.from(value, 'utf8').toString('base64') + const packedMetadata = (...values: string[]): string => b64(values.join('\x1f')) + afterEach(() => { clearGrokSessionPathLookupCacheForTests() vi.unstubAllEnvs() @@ -60,6 +67,76 @@ describe('shared agent-hook-listener', () => { expectRequestParserListenersReleased(req) }) + it('normalizes a raw JSON hook body with metadata headers', async () => { + const req = createReadableRequest({ + 'content-type': 'application/json', + 'x-orca-pane-key': paneKey, + 'x-orca-worktree-id': 'repo::/tmp/work', + 'x-orca-agent-hook-env': 'production', + 'x-orca-agent-hook-version': '1' + }) + const body = readRequestBody(req as unknown as IncomingMessage) + req.emit('data', Buffer.from('{"hook_event_name":"UserPromptSubmit","prompt":"hello"}')) + req.emit('end') + + const merged = mergeAgentHookRequestHeaders(await body, req.headers) + expect( + normalizeHookPayload(createHookListenerState(), 'claude', merged, 'production') + ).toMatchObject({ + paneKey, + worktreeId: 'repo::/tmp/work', + payload: { state: 'working', prompt: 'hello' } + }) + }) + + it('decodes base64 metadata headers without corrupting path text', () => { + const worktreeId = 'repo::/tmp/中文 worktree' + const merged = mergeAgentHookRequestHeaders( + { hook_event_name: 'UserPromptSubmit', prompt: 'hello' }, + { + 'x-orca-agent-hook-meta-encoding': 'base64', + 'x-orca-agent-hook-meta': packedMetadata( + paneKey, + 'tab-1', + '', + worktreeId, + 'production', + '1' + ) + } + ) + + expect( + normalizeHookPayload(createHookListenerState(), 'claude', merged, 'production') + ).toMatchObject({ + paneKey, + tabId: 'tab-1', + worktreeId, + payload: { state: 'working', prompt: 'hello' } + }) + }) + + it('keeps raw envelope-like fields inside the agent payload', () => { + const rawBody = { + paneKey: 'payload-owned-pane', + payload: { hook_event_name: 'PayloadField' }, + hook_event_name: 'UserPromptSubmit', + prompt: 'hello' + } + const merged = mergeAgentHookRequestHeaders(rawBody, { + 'x-orca-pane-key': paneKey, + 'x-orca-tab-id': 'tab-1' + }) + + expect(merged).toMatchObject({ paneKey, tabId: 'tab-1', payload: rawBody }) + expect( + normalizeHookPayload(createHookListenerState(), 'claude', merged, 'production') + ).toMatchObject({ + paneKey, + payload: { state: 'working', prompt: 'hello' } + }) + }) + it('releases request parser listeners after rejecting an oversized body', async () => { const req = createReadableRequest({ 'content-type': 'application/json' }) const body = readRequestBody(req as unknown as IncomingMessage) @@ -168,13 +245,15 @@ describe('shared agent-hook-listener', () => { port: 12345, token: 'abcdef-0123', env: 'production', - version: '1' + version: '1', + transport: 'raw-json-v1' }) expect(ok).toBe(true) const text = readFileSync(finalPath, 'utf8') expect(text).toContain('ORCA_AGENT_HOOK_PORT=12345') expect(text).toContain('ORCA_AGENT_HOOK_TOKEN=abcdef-0123') expect(text).toContain('ORCA_AGENT_HOOK_VERSION=1') + expect(text).toContain('ORCA_AGENT_HOOK_TRANSPORT=raw-json-v1') // POSIX 0o600 — owner read/write only. if (process.platform !== 'win32') { const mode = statSync(finalPath).mode & 0o777 diff --git a/src/shared/agent-hook-listener/endpoint-publication.ts b/src/shared/agent-hook-listener/endpoint-publication.ts index cb09f204a01..76964c00254 100644 --- a/src/shared/agent-hook-listener/endpoint-publication.ts +++ b/src/shared/agent-hook-listener/endpoint-publication.ts @@ -21,6 +21,7 @@ export type EndpointFileFields = { token: string env: string version: string + transport?: string } /** Atomically write the endpoint file at `endpointDir/`. @@ -39,6 +40,9 @@ export function writeEndpointFile( ['ORCA_AGENT_HOOK_ENV', fields.env], ['ORCA_AGENT_HOOK_VERSION', fields.version] ] + if (fields.transport) { + valuesToWrite.push(['ORCA_AGENT_HOOK_TRANSPORT', fields.transport]) + } for (const [key, value] of valuesToWrite) { if (!isShellSafeEndpointValue(value)) { console.error( diff --git a/src/shared/agent-hook-listener/hook-envelope.ts b/src/shared/agent-hook-listener/hook-envelope.ts index 5435f9fe912..57c29da338f 100644 --- a/src/shared/agent-hook-listener/hook-envelope.ts +++ b/src/shared/agent-hook-listener/hook-envelope.ts @@ -1,8 +1,91 @@ +import { Buffer } from 'node:buffer' +import type { IncomingHttpHeaders } from 'node:http' + import type { AgentHookSource } from '../agent-hook-relay' import { parsePaneKey } from '../stable-pane-id' import { MAX_PANE_KEY_LEN, warnOnHookEnvOrVersionMismatch } from './listener-limits' import type { HookListenerState } from './listener-state' import { parseAgentHookJson } from './request-body' + +function readHookHeader(headers: IncomingHttpHeaders, name: string): string | undefined { + const value = headers[name] + return Array.isArray(value) ? value[0] : value +} + +function decodeBase64HookHeader(value: string): string | undefined { + if (!/^[A-Za-z0-9+/]*={0,2}$/.test(value) || value.length % 4 === 1) { + return undefined + } + const decoded = Buffer.from(value, 'base64').toString('utf8') + const normalizedInput = value.replace(/=+$/, '') + const normalizedRoundTrip = Buffer.from(decoded, 'utf8').toString('base64').replace(/=+$/, '') + return normalizedRoundTrip === normalizedInput ? decoded : undefined +} + +function readHookMetadataHeader( + headers: IncomingHttpHeaders, + name: string, + encoding: 'base64' | undefined +): string | undefined { + const value = readHookHeader(headers, name) + if (value === undefined) { + return undefined + } + return encoding === 'base64' ? decodeBase64HookHeader(value) : value +} + +type HookMetadata = { + paneKey: string + tabId?: string + launchToken?: string + worktreeId?: string + env?: string + version?: string +} + +function readPackedHookMetadata( + headers: IncomingHttpHeaders, + encoding: 'base64' | undefined +): HookMetadata | null { + const encoded = readHookHeader(headers, 'x-orca-agent-hook-meta') + if (encoded === undefined || encoding !== 'base64') { + return null + } + const decoded = decodeBase64HookHeader(encoded) + if (decoded === undefined) { + return null + } + // POSIX command substitution strips NUL bytes, so use the shell-safe unit separator. + const fields = decoded.split('\x1f') + if (fields.length !== 6 || !fields[0]) { + return null + } + const [paneKey, tabId, launchToken, worktreeId, env, version] = fields + return { paneKey, tabId, launchToken, worktreeId, env, version } +} + +/** Rebuilds the canonical envelope for POSIX hooks that carry raw JSON bodies. */ +export function mergeAgentHookRequestHeaders(body: unknown, headers: IncomingHttpHeaders): unknown { + const metadataEncoding = + readHookHeader(headers, 'x-orca-agent-hook-meta-encoding')?.trim().toLowerCase() === 'base64' + ? 'base64' + : undefined + const metadata = readPackedHookMetadata(headers, metadataEncoding) ?? { + paneKey: readHookMetadataHeader(headers, 'x-orca-pane-key', metadataEncoding) ?? '', + tabId: readHookMetadataHeader(headers, 'x-orca-tab-id', metadataEncoding), + launchToken: readHookMetadataHeader(headers, 'x-orca-launch-token', metadataEncoding), + worktreeId: readHookMetadataHeader(headers, 'x-orca-worktree-id', metadataEncoding), + env: readHookMetadataHeader(headers, 'x-orca-agent-hook-env', metadataEncoding), + version: readHookMetadataHeader(headers, 'x-orca-agent-hook-version', metadataEncoding) + } + if (!metadata.paneKey) { + return body + } + return { + ...metadata, + payload: body + } +} function readEnvelopeString(record: Record, key: string): string | undefined { const value = record[key] if (typeof value !== 'string') { diff --git a/src/shared/agent-hook-types.ts b/src/shared/agent-hook-types.ts index 5319dffceaf..248638c5079 100644 --- a/src/shared/agent-hook-types.ts +++ b/src/shared/agent-hook-types.ts @@ -49,3 +49,6 @@ export type AgentHookInstallStatus = { // rewritten on every install() call so there is no durable on-disk v1 script // to inherit. Reserve the next bump for a real wire change. export const ORCA_HOOK_PROTOCOL_VERSION = '1' as const + +// Why: absence means the listener predates raw-JSON metadata headers, so managed scripts must keep using form posts. +export const ORCA_HOOK_RAW_JSON_TRANSPORT = 'raw-json-v1' as const From 9635e6822f491ed66847e01f155edf7d7052da61 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Thu, 27 Aug 2026 12:35:03 -0700 Subject: [PATCH 02/22] Normalize project catalog rows defensively (#16826) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * wip: normalize untrusted project catalog rows at load boundaries * fix(catalog): make ProjectHostSetup field types true at the ingest boundary Crash 3bcc5be3: a setup row whose repoId arrived null reached Settings' projectByRepoId memo and threw on .trim(). The type said `string`; persisted JSON and remote hosts on other versions can disagree. Normalize project/setup rows where untrusted data enters typed code — the persisted-state load (marking dirty, which is the migration), profile transfer reads, the repo-derived projection, and the renderer's IPC/RPC ingest and adoption steps — instead of re-guarding each consumer. Also covers `setup.path`, whose identical crash is on the sidebar render path. Coercion only: never drops a row, never adds or removes an optional key, and returns input references when a row already conforms, so selector and useMemo identity is unchanged. * refactor(catalog): drop the `as` casts the normalizer introduced A change whose thesis is "stop the declared types from lying" should not use `as` to paper over types. The four source casts all came from the row normalizers returning `readonly` arrays into mutably-owned fields. Take and return mutable arrays instead, and copy at the one caller that holds a readonly projection — where identity is not load-bearing, unlike the persistence path, whose dirty check compares it. Tests built deliberately malformed rows by casting a literal. Build a valid row and `Reflect.set` the bad value onto it, which says outright that the fixture violates its type; parse the non-array case from JSON, which is how it actually arrives. Fixtures that were merely incomplete needed no cast at all — `Repo` requires only the five fields they already had. Also narrow normalizeLoadedProjectCatalog to the two fields it reads. * refactor(profiles): validate untrusted profile JSON instead of asserting it Removes the last introduced cast and the unsound pre-existing ones in the file this change already touches. The test cast is gone because narrowing normalizeLoadedProjectCatalog to the two fields it reads made `{}` assignable on its own. arrayOrEmpty and recordOrEmpty checked the shape and then asserted the element type, which is the same "declared type is a promise the data does not keep" problem this change exists to fix. Array.isArray already narrows on its own, and a generic isRecord narrows the value part, so both assertions delete outright. JSON.parse returns any, so annotating the binding beats asserting its result. The two remaining `as const` in project-host-setup-actions are untouched and deliberate: a literal assertion narrows a type rather than overriding it. * Make project catalog normalizers handle null rows defensively Instead of crashing when corrupt or null catalog rows are encountered, the normalizers now gracefully repair them with default values. Refactored helper functions for clarity and added type predicates to improve type narrowing. --- .../profile-project-state-file.ts | 31 +-- .../normalize-loaded-profile-state.ts | 7 +- .../normalize-loaded-project-catalog.test.ts | 67 ++++++ .../normalize-loaded-state-collections.ts | 22 ++ .../project-catalog-null-field-ingest.test.ts | 167 ++++++++++++++ .../store/projects/project-host-routing.ts | 31 ++- .../projects/project-host-setup-actions.ts | 28 ++- .../src/store/projects/project-update.ts | 5 +- .../project-catalog-row-normalization.test.ts | 207 ++++++++++++++++++ .../project-catalog-row-normalization.ts | 123 +++++++++++ src/shared/project-host-setup-projection.ts | 10 +- 11 files changed, 663 insertions(+), 35 deletions(-) create mode 100644 src/main/persistence/loading-store/normalize-loaded-project-catalog.test.ts create mode 100644 src/renderer/src/store/projects/project-catalog-null-field-ingest.test.ts create mode 100644 src/shared/project-catalog-row-normalization.test.ts create mode 100644 src/shared/project-catalog-row-normalization.ts diff --git a/src/main/orca-profiles/profile-project-state-file.ts b/src/main/orca-profiles/profile-project-state-file.ts index 5c623f19bad..d4f291a09e4 100644 --- a/src/main/orca-profiles/profile-project-state-file.ts +++ b/src/main/orca-profiles/profile-project-state-file.ts @@ -3,8 +3,11 @@ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from ' import { homedir } from 'node:os' import { dirname } from 'node:path' import { getDefaultPersistedState, getDefaultWorkspaceSession } from '../../shared/constants' -import type { ExecutionHostId } from '../../shared/execution-host' import { projectHostSetupProjectionFromRepos } from '../../shared/project-host-setup-projection' +import { + normalizeProjectHostSetupRows, + normalizeProjectRows +} from '../../shared/project-catalog-row-normalization' import { carryProjectStateThroughIdentityChange } from '../../shared/project-identity-succession' import type { PersistedState } from '../../shared/persisted-state-types' import type { Project, ProjectHostSetup } from '../../shared/project-types' @@ -16,16 +19,16 @@ import { getOrcaProfileDataFile } from './profile-index-store' export type TransferProfileState = PersistedState -function isRecord(value: unknown): value is Record { +function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value) } function arrayOrEmpty(value: unknown): T[] { - return Array.isArray(value) ? (value as T[]) : [] + return Array.isArray(value) ? value : [] } function recordOrEmpty(value: unknown): Record { - return isRecord(value) ? (value as Record) : {} + return isRecord(value) ? value : {} } export function readProfileState(profileId: string, userDataPath: string): TransferProfileState { @@ -34,13 +37,17 @@ export function readProfileState(profileId: string, userDataPath: string): Trans if (!existsSync(dataFile)) { return structuredClone(defaults) } - const parsed = JSON.parse(readFileSync(dataFile, 'utf-8')) as Partial + const parsed: Partial = JSON.parse(readFileSync(dataFile, 'utf-8')) return rebuildRepoBackedProjectState({ ...defaults, ...parsed, repos: arrayOrEmpty(parsed.repos), - projects: arrayOrEmpty(parsed.projects), - projectHostSetups: arrayOrEmpty(parsed.projectHostSetups), + // Why normalize: another profile's file is untrusted JSON, and a null repoId/path here would be + // carried straight into the importing app's state. + projects: normalizeProjectRows(arrayOrEmpty(parsed.projects)), + projectHostSetups: normalizeProjectHostSetupRows( + arrayOrEmpty(parsed.projectHostSetups) + ), projectGroups: arrayOrEmpty(parsed.projectGroups), folderWorkspaces: arrayOrEmpty(parsed.folderWorkspaces), sparsePresetsByRepo: recordOrEmpty(parsed.sparsePresetsByRepo), @@ -59,17 +66,15 @@ export function readProfileState(profileId: string, userDataPath: string): Trans ui: isRecord(parsed.ui) ? { ...defaults.ui, ...parsed.ui } : defaults.ui, githubCache: isRecord(parsed.githubCache) ? { - pr: recordOrEmpty((parsed.githubCache as PersistedState['githubCache']).pr), - issue: recordOrEmpty((parsed.githubCache as PersistedState['githubCache']).issue) + pr: recordOrEmpty(parsed.githubCache.pr), + issue: recordOrEmpty(parsed.githubCache.issue) } : defaults.githubCache, workspaceSession: isRecord(parsed.workspaceSession) ? { ...getDefaultWorkspaceSession(), ...parsed.workspaceSession } : defaults.workspaceSession, - workspaceSessionsByHostId: isRecord(parsed.workspaceSessionsByHostId) - ? (parsed.workspaceSessionsByHostId as Partial< - Record - >) + workspaceSessionsByHostId: isRecord(parsed.workspaceSessionsByHostId) + ? parsed.workspaceSessionsByHostId : {}, sshTargets: arrayOrEmpty(parsed.sshTargets), sshRemotePtyLeases: arrayOrEmpty(parsed.sshRemotePtyLeases), diff --git a/src/main/persistence/loading-store/normalize-loaded-profile-state.ts b/src/main/persistence/loading-store/normalize-loaded-profile-state.ts index 464a1eaa9b8..2286e40d54b 100644 --- a/src/main/persistence/loading-store/normalize-loaded-profile-state.ts +++ b/src/main/persistence/loading-store/normalize-loaded-profile-state.ts @@ -21,7 +21,8 @@ import { normalizeLoadedUiState } from './normalize-loaded-ui-state' import { normalizeLoadedAutomationRuns, normalizeLoadedHostSessions, - normalizeLoadedLocalSession + normalizeLoadedLocalSession, + normalizeLoadedProjectCatalog } from './normalize-loaded-state-collections' import { normalizeRetiredNameRegistryMap } from './retired-name-registry-normalization' @@ -33,6 +34,7 @@ export function normalizeLoadedProfileState( ): PersistedState { const { defaults, migratedExternalVisibility, osc52ClipboardNoticePending } = terminal const { normalizedOnboarding, normalizedProjectGroups, loadedCompactWorktreeCards } = profile + const projectCatalog = normalizeLoadedProjectCatalog(parsed, markNeedsSave) return { ...defaults, @@ -42,6 +44,9 @@ export function normalizeLoadedProfileState( ), projectGroups: normalizedProjectGroups, repos: migratedExternalVisibility.repos, + // Why: persisted catalog rows are untrusted JSON; consumers call string methods on fields the type says are strings. + projects: projectCatalog.projects, + projectHostSetups: projectCatalog.projectHostSetups, folderWorkspaces: normalizeFolderWorkspaces(parsed.folderWorkspaces, normalizedProjectGroups), folderWorkspaceDiffComments: normalizeFolderWorkspaceDiffComments( parsed.folderWorkspaceDiffComments diff --git a/src/main/persistence/loading-store/normalize-loaded-project-catalog.test.ts b/src/main/persistence/loading-store/normalize-loaded-project-catalog.test.ts new file mode 100644 index 00000000000..8a535b41919 --- /dev/null +++ b/src/main/persistence/loading-store/normalize-loaded-project-catalog.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it, vi } from 'vitest' +import type { PersistedState } from '../../../shared/persisted-state-types' +import type { Project, ProjectHostSetup } from '../../../shared/project-types' +import { normalizeLoadedProjectCatalog } from './normalize-loaded-state-collections' + +// Why Reflect.set and not a cast: these rows deliberately violate their declared types, which is +// exactly what a stale on-disk catalog does. +function corrupt(row: T, overrides: Record): T { + for (const [key, value] of Object.entries(overrides)) { + Reflect.set(row, key, value) + } + return row +} + +function makeParsed( + setups: ProjectHostSetup[], + projects: Project[] = [] +): Partial> { + return { projects, projectHostSetups: setups } +} + +function makeBadSetup(): ProjectHostSetup { + const setup: ProjectHostSetup = { + id: 'setup-1', + projectId: 'project-1', + hostId: 'local', + repoId: 'repo-1', + path: '/Users/alice/orca', + displayName: 'orca', + setupState: 'ready', + setupMethod: 'legacy-repo', + createdAt: 1, + updatedAt: 1 + } + return corrupt(setup, { repoId: null, path: null }) +} + +describe('normalizeLoadedProjectCatalog', () => { + it('repairs stored rows whose field types do not match the declared ones', () => { + const result = normalizeLoadedProjectCatalog(makeParsed([makeBadSetup()]), vi.fn()) + expect(result.projectHostSetups[0]?.repoId).toBe('') + expect(result.projectHostSetups[0]?.path).toBe('') + }) + + // Why: without a save the bad rows stay on disk, get repaired again every launch, and this + // host keeps publishing them to paired clients. Marking dirty is the migration. + it('marks the profile dirty so the repair is persisted', () => { + const markNeedsSave = vi.fn() + normalizeLoadedProjectCatalog(makeParsed([makeBadSetup()]), markNeedsSave) + expect(markNeedsSave).toHaveBeenCalled() + }) + + it('leaves a conforming catalog untouched and does not schedule a save', () => { + const markNeedsSave = vi.fn() + const setups = [{ ...makeBadSetup(), repoId: 'repo-1', path: '/repo' }] + const parsed = makeParsed(setups) + const result = normalizeLoadedProjectCatalog(parsed, markNeedsSave) + expect(result.projectHostSetups).toBe(setups) + expect(markNeedsSave).not.toHaveBeenCalled() + }) + + it('tolerates missing or non-array collections', () => { + const result = normalizeLoadedProjectCatalog({}, vi.fn()) + expect(result.projects).toEqual([]) + expect(result.projectHostSetups).toEqual([]) + }) +}) diff --git a/src/main/persistence/loading-store/normalize-loaded-state-collections.ts b/src/main/persistence/loading-store/normalize-loaded-state-collections.ts index 978515a5752..2b133d486b7 100644 --- a/src/main/persistence/loading-store/normalize-loaded-state-collections.ts +++ b/src/main/persistence/loading-store/normalize-loaded-state-collections.ts @@ -1,4 +1,8 @@ import type { PersistedState } from '../../../shared/persisted-state-types' +import { + normalizeProjectHostSetupRows, + normalizeProjectRows +} from '../../../shared/project-catalog-row-normalization' import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' import { parseWorkspaceSessionSalvaging } from '../../../shared/workspace-session-salvage' import { @@ -64,3 +68,21 @@ export function normalizeLoadedAutomationRuns( } return runs } + +/** + * Repairs project/setup rows whose stored field types do not match the declared ones — a null + * `repoId` or `path` written by an older build reaches every consumer that calls `.trim()` on it. + * Marking dirty is the migration: without a save the bad rows stay on disk and are repaired again + * every launch, and this host keeps publishing them to paired clients over the wire. + */ +export function normalizeLoadedProjectCatalog( + parsed: Partial>, + markNeedsSave: () => void +): Pick { + const projects = normalizeProjectRows(parsed.projects ?? []) + const projectHostSetups = normalizeProjectHostSetupRows(parsed.projectHostSetups ?? []) + if (projects !== parsed.projects || projectHostSetups !== parsed.projectHostSetups) { + markNeedsSave() + } + return { projects, projectHostSetups } +} diff --git a/src/renderer/src/store/projects/project-catalog-null-field-ingest.test.ts b/src/renderer/src/store/projects/project-catalog-null-field-ingest.test.ts new file mode 100644 index 00000000000..1d57bde1b87 --- /dev/null +++ b/src/renderer/src/store/projects/project-catalog-null-field-ingest.test.ts @@ -0,0 +1,167 @@ +import { describe, expect, it, vi } from 'vitest' +import type { Project, ProjectHostSetup } from '../../../../shared/project-types' +import type { Repo } from '../../../../shared/repo-types' +import { getProjectHostSetupProjectionFromState } from '../project-host-setup-selector' +import { fetchProjectHostSetupCompatibility, setupWithFetchedOwner } from './project-host-routing' + +// Crash 3bcc5be3 (v1.4.188, Linux, page.settings boundary): a setup row whose repoId arrived +// null reached Settings' projectByRepoId useMemo and threw "Cannot read properties of null +// (reading 'trim')". Normalizing at ingest is what must make that unreachable — by the time a +// row is in the store, its declared field types hold. +const repos = [ + { id: 'repo-1', path: '/Users/alice/orca', displayName: 'orca', badgeColor: '#000', addedAt: 1 } +] satisfies Repo[] + +const projects = [ + { + id: 'repo:repo-1', + displayName: 'orca', + badgeColor: '#000', + sourceRepoIds: ['repo-1'], + createdAt: 1, + updatedAt: 1 + } +] satisfies Project[] + +// Mirrors the Settings.tsx projectByRepoId useMemo that crashed. +function buildProjectByRepoIdLikeSettings( + setups: readonly ProjectHostSetup[], + projectList: readonly Project[] +): Map { + const projectById = new Map(projectList.map((project) => [project.id, project])) + const byRepoId = new Map() + for (const setup of setups) { + const project = projectById.get(setup.projectId) + if (project && setup.repoId.trim()) { + byRepoId.set(setup.repoId, project) + } + } + return byRepoId +} + +// Mirrors project-grouping.ts's checkout identity, which runs on the sidebar render path. +function checkoutIdentityLikeSidebar(setup: ProjectHostSetup): string { + return setup.path.trim() || setup.repoId || setup.id +} + +// Why Reflect.set and not a cast: the second row deliberately violates its declared types, which +// is exactly what an older host publishes over the wire. +function badSetups(): ProjectHostSetup[] { + const base = (id: string, displayName: string): ProjectHostSetup => ({ + id, + projectId: 'repo:repo-1', + hostId: 'local', + repoId: 'repo-1', + path: '/Users/alice/orca', + displayName, + setupState: 'ready', + setupMethod: 'legacy-repo', + createdAt: 1, + updatedAt: 1 + }) + const corrupted = base('repo:repo-1::local::2', 'orca-2') + Reflect.set(corrupted, 'repoId', null) + Reflect.set(corrupted, 'path', null) + return [base('repo:repo-1::local', 'orca'), corrupted] +} + +function stubProjectsApi(setups: ProjectHostSetup[], projectRows: Project[] = projects): void { + vi.stubGlobal('window', { + api: { + projects: { + list: vi.fn().mockResolvedValue(projectRows), + listHostSetups: vi.fn().mockResolvedValue(setups) + } + } + }) +} + +describe('project catalog ingest with non-string row fields', () => { + it('coerces a null repoId and path on the local IPC boundary', async () => { + stubProjectsApi(badSetups()) + const projection = await fetchProjectHostSetupCompatibility({ kind: 'local' }, repos) + const row = projection.setups.find((setup) => setup.id === 'repo:repo-1::local::2') + expect(row?.repoId).toBe('') + expect(row?.path).toBe('') + }) + + // Why: a remote host on a different Orca version is a first-class source of these rows, and + // decoders hand them over verbatim — the client cannot assume the host already repaired them. + it('coerces on the remote adoption boundary too', () => { + const adopted = setupWithFetchedOwner(badSetups()[1]!, { + kind: 'environment', + environmentId: 'env-1' + }) + expect(adopted.repoId).toBe('') + expect(adopted.path).toBe('') + expect(adopted.hostId).toBe('runtime:env-1') + }) + + it('lets the Settings projectByRepoId memo run instead of throwing on .trim()', async () => { + stubProjectsApi(badSetups()) + const ingested = await fetchProjectHostSetupCompatibility({ kind: 'local' }, repos) + const projection = getProjectHostSetupProjectionFromState({ + repos, + projects: [...ingested.projects], + projectHostSetups: [...ingested.setups] + }) + expect(() => + buildProjectByRepoIdLikeSettings(projection.setups, projection.projects) + ).not.toThrow() + // Why: an empty repoId is not an openable repo row, so it must not be indexed. + const byRepoId = buildProjectByRepoIdLikeSettings(projection.setups, projection.projects) + expect(byRepoId.has('')).toBe(false) + expect([...byRepoId.keys()]).toEqual(['repo-1']) + }) + + // Why: a null path takes down the whole main window rather than one error boundary, because + // the sidebar grouping index is built on every render. + it('lets the sidebar checkout identity run instead of throwing on a null path', async () => { + stubProjectsApi(badSetups()) + const ingested = await fetchProjectHostSetupCompatibility({ kind: 'local' }, repos) + expect(() => ingested.setups.map(checkoutIdentityLikeSidebar)).not.toThrow() + }) + + // Why: a coerced field must change one value, never which rows exist. + it('does not add or drop rows because a field was repaired', async () => { + stubProjectsApi(badSetups()) + const repaired = await fetchProjectHostSetupCompatibility({ kind: 'local' }, repos) + const clean = badSetups() + ;(clean[1] as { repoId: string; path: string }).repoId = '' + ;(clean[1] as { repoId: string; path: string }).path = '' + stubProjectsApi(clean) + const untouched = await fetchProjectHostSetupCompatibility({ kind: 'local' }, repos) + expect(repaired.setups.map((setup) => setup.id)).toEqual( + untouched.setups.map((setup) => setup.id) + ) + expect(repaired.projects.map((project) => project.id)).toEqual( + untouched.projects.map((project) => project.id) + ) + }) + + // Why: this is a zustand selector compared with Object.is, so instability is a re-render storm. + it('keeps the projection reference-stable per (repos, projects, setups) input', async () => { + stubProjectsApi(badSetups()) + const ingested = await fetchProjectHostSetupCompatibility({ kind: 'local' }, repos) + const args = { + repos, + projects: [...ingested.projects], + projectHostSetups: [...ingested.setups] + } + expect(getProjectHostSetupProjectionFromState(args)).toBe( + getProjectHostSetupProjectionFromState(args) + ) + }) + + // Why: nothing else in the ingest path allocates, so a clean catalog must hand back the very + // rows it was given — TerminalPane and TabBarQuickCommandsButton use them as useMemo deps. + it('preserves row identity when the catalog is already clean', async () => { + const clean = badSetups() + ;(clean[1] as { repoId: string; path: string }).repoId = '' + ;(clean[1] as { repoId: string; path: string }).path = '' + stubProjectsApi(clean) + const projection = await fetchProjectHostSetupCompatibility({ kind: 'local' }, repos) + expect(projection.setups[0]).toBe(clean[0]) + expect(projection.setups[1]).toBe(clean[1]) + }) +}) diff --git a/src/renderer/src/store/projects/project-host-routing.ts b/src/renderer/src/store/projects/project-host-routing.ts index 43e32524ad3..1a4a45ea03a 100644 --- a/src/renderer/src/store/projects/project-host-routing.ts +++ b/src/renderer/src/store/projects/project-host-routing.ts @@ -9,6 +9,11 @@ import { projectHostSetupProjectionFromRepos, type ProjectHostSetupProjection } from '../../../../shared/project-host-setup-projection' +import { + normalizeProjectHostSetupRow, + normalizeProjectHostSetupRows, + normalizeProjectRows +} from '../../../../shared/project-catalog-row-normalization' import { PROJECT_HOST_SETUP_RUNTIME_CAPABILITY, WORKSPACE_RUN_CONTEXT_RUNTIME_CAPABILITY @@ -51,13 +56,16 @@ export function setupWithFetchedOwner( setup: ProjectHostSetup, target: RuntimeClientTarget ): ProjectHostSetup { + // Why here: every setup row crossing IPC or RPC into the renderer passes through this + // adoption step, so it is where the declared field types stop being aspirational. + const adopted = normalizeProjectHostSetupRow(setup) const hostId = getRuntimeTargetHostId(target) if (target.kind !== 'environment') { - return setup + return adopted } - const executionHostId = setup.executionHostId ?? setup.hostId + const executionHostId = adopted.executionHostId ?? adopted.hostId return { - ...setup, + ...adopted, hostId, executionHostId: executionHostId === LOCAL_EXECUTION_HOST_ID ? hostId : executionHostId, runtimeOwnerEnvironmentId: target.environmentId, @@ -66,6 +74,15 @@ export function setupWithFetchedOwner( } } +function normalizeProjectCatalogProjection( + projection: ProjectHostSetupProjection +): ProjectHostSetupProjection { + return { + projects: normalizeProjectRows([...projection.projects]), + setups: normalizeProjectHostSetupRows([...projection.setups]) + } +} + async function assertProjectHostSetupRuntimeCapability(target: RuntimeClientTarget): Promise { if (target.kind !== 'environment') { return @@ -95,10 +112,10 @@ export async function fetchProjectHostSetupCompatibility( if (!projectsApi?.list || !projectsApi.listHostSetups) { throw new Error('projects_api_unavailable') } - return { + return normalizeProjectCatalogProjection({ projects: await projectsApi.list(), setups: await projectsApi.listHostSetups() - } + }) } await assertProjectHostSetupRuntimeCapability(target) const [projectResponse, setupResponse] = await Promise.all([ @@ -110,7 +127,9 @@ export async function fetchProjectHostSetupCompatibility( }) ]) return { - projects: projectResponse.projects, + // Why projects too: the same wire response carries them, and a remote host on another + // Orca version can publish a row whose declared field types do not hold. + projects: normalizeProjectRows([...projectResponse.projects]), setups: setupResponse.setups.map((setup) => setupWithFetchedOwner(setup, target)) } } catch { diff --git a/src/renderer/src/store/projects/project-host-setup-actions.ts b/src/renderer/src/store/projects/project-host-setup-actions.ts index 97e74dbc9b8..dbc106a6afa 100644 --- a/src/renderer/src/store/projects/project-host-setup-actions.ts +++ b/src/renderer/src/store/projects/project-host-setup-actions.ts @@ -16,6 +16,7 @@ import { getRepoExecutionHostId, parseExecutionHostId } from '../../../../shared import type { RepoSlice } from '../repos/repo-state' import { ERROR_TOAST_DURATION } from '../repos/repo-state' import { repoWithFetchedOwner } from '../repos/owner-routing' +import { normalizeProjectRow } from '../../../../shared/project-catalog-row-normalization' import { assertProjectHostSetupMutationRuntimeCapabilities, getProjectSetupRuntimeTarget, @@ -57,6 +58,7 @@ export function createProjectHostSetupActions( const repo = repoWithFetchedOwner(result.repo, target) const repoHostId = getRepoExecutionHostId(repo) const setup = setupWithFetchedOwner(result.setup, target) + const project = normalizeProjectRow(result.project) set((s) => { const nextRepos = s.repos.some((entry) => repoMatchesHostIdentity(entry, repo.id, repoHostId) @@ -65,9 +67,9 @@ export function createProjectHostSetupActions( repoMatchesHostIdentity(entry, repo.id, repoHostId) ? repo : entry ) : [...s.repos, repo] - const nextProjects = s.projects.some((entry) => entry.id === result.project.id) - ? s.projects.map((entry) => (entry.id === result.project.id ? result.project : entry)) - : [...s.projects, result.project] + const nextProjects = s.projects.some((entry) => entry.id === project.id) + ? s.projects.map((entry) => (entry.id === project.id ? project : entry)) + : [...s.projects, project] const nextSetups = s.projectHostSetups.some((entry) => entry.id === setup.id) ? s.projectHostSetups.map((entry) => (entry.id === setup.id ? setup : entry)) : [...s.projectHostSetups, setup] @@ -80,7 +82,7 @@ export function createProjectHostSetupActions( toast.success(translate('auto.store.slices.repos.8bb3ad7935', 'Project added'), { description: repo.displayName }) - return { ...result, repo, setup } + return { ...result, project, repo, setup } } catch (err) { console.error('Failed to set up project on host:', err) const message = err instanceof Error ? err.message : String(err) @@ -108,15 +110,16 @@ export function createProjectHostSetupActions( ) ).result const setup = setupWithFetchedOwner(result.setup, target) + const project = normalizeProjectRow(result.project) set((s) => ({ - projects: s.projects.some((entry) => entry.id === result.project.id) - ? s.projects.map((entry) => (entry.id === result.project.id ? result.project : entry)) - : [...s.projects, result.project], + projects: s.projects.some((entry) => entry.id === project.id) + ? s.projects.map((entry) => (entry.id === project.id ? project : entry)) + : [...s.projects, project], projectHostSetups: s.projectHostSetups.some((entry) => entry.id === setup.id) ? s.projectHostSetups.map((entry) => (entry.id === setup.id ? setup : entry)) : [...s.projectHostSetups, setup] })) - return { project: result.project, setup } + return { project, setup } } catch (err) { console.error('Failed to create project host setup:', err) const message = err instanceof Error ? err.message : String(err) @@ -147,6 +150,7 @@ export function createProjectHostSetupActions( ) ).result const setup = setupWithFetchedOwner(result.setup, target) + const project = normalizeProjectRow(result.project) const repo = result.repo ? repoWithFetchedOwner(result.repo, target) : undefined const repoHostId = repo ? getRepoExecutionHostId(repo) : null set((s) => ({ @@ -157,14 +161,14 @@ export function createProjectHostSetupActions( ) : [...s.repos, repo] : s.repos, - projects: s.projects.some((entry) => entry.id === result.project.id) - ? s.projects.map((entry) => (entry.id === result.project.id ? result.project : entry)) - : [...s.projects, result.project], + projects: s.projects.some((entry) => entry.id === project.id) + ? s.projects.map((entry) => (entry.id === project.id ? project : entry)) + : [...s.projects, project], projectHostSetups: s.projectHostSetups.some((entry) => entry.id === setup.id) ? s.projectHostSetups.map((entry) => (entry.id === setup.id ? setup : entry)) : [...s.projectHostSetups, setup] })) - return { ...result, repo, setup } + return { ...result, project, repo, setup } } catch (err) { console.error('Failed to update project host setup:', err) const message = err instanceof Error ? err.message : String(err) diff --git a/src/renderer/src/store/projects/project-update.ts b/src/renderer/src/store/projects/project-update.ts index 7b3ece6fd62..75c65f24ee3 100644 --- a/src/renderer/src/store/projects/project-update.ts +++ b/src/renderer/src/store/projects/project-update.ts @@ -6,6 +6,7 @@ import { notifyInstalledAgentSkillsChanged } from '@/hooks/installed-agent-skill import type { RepoSlice } from '../repos/repo-state' import { getProjectUpdateRuntimeTarget } from './project-host-routing' import { mergeUpdatedProjectCompatibilityProject } from './project-compatibility-core' +import { normalizeProjectRow } from '../../../../shared/project-catalog-row-normalization' export function createProjectUpdateActions( set: Parameters>[0], @@ -29,11 +30,13 @@ export function createProjectUpdateActions( if (!updatedProject) { return false } + // Why: the merge spreads updatedProject.sourceRepoIds, which throws if the host sent a non-array. + const normalizedProject = normalizeProjectRow(updatedProject) const runtimePreferenceChanged = 'localWindowsRuntimePreference' in updates set((state) => ({ projects: state.projects.map((project) => project.id === projectId - ? mergeUpdatedProjectCompatibilityProject(project, updatedProject, updates) + ? mergeUpdatedProjectCompatibilityProject(project, normalizedProject, updates) : project ), folderWorkspacePathStatuses: {} diff --git a/src/shared/project-catalog-row-normalization.test.ts b/src/shared/project-catalog-row-normalization.test.ts new file mode 100644 index 00000000000..acab501f41e --- /dev/null +++ b/src/shared/project-catalog-row-normalization.test.ts @@ -0,0 +1,207 @@ +import { describe, expect, it } from 'vitest' +import type { Project, ProjectHostSetup } from './project-types' +import { + normalizeProjectHostSetupRow, + normalizeProjectHostSetupRows, + normalizeProjectRow, + normalizeProjectRows +} from './project-catalog-row-normalization' + +// Why Reflect.set and not a cast: these fixtures deliberately violate the declared types, which is +// the whole point — writing the bad value onto a valid row says that outright. +function corrupt(row: T, overrides: Record): T { + for (const [key, value] of Object.entries(overrides)) { + Reflect.set(row, key, value) + } + return row +} + +function makeSetup(overrides: Record = {}): ProjectHostSetup { + const setup: ProjectHostSetup = { + id: 'setup-1', + projectId: 'project-1', + hostId: 'local', + repoId: 'repo-1', + path: '/Users/alice/orca', + displayName: 'orca', + setupState: 'ready', + setupMethod: 'legacy-repo', + createdAt: 1, + updatedAt: 2 + } + return corrupt(setup, overrides) +} + +function makeProject(overrides: Record = {}): Project { + const project: Project = { + id: 'project-1', + displayName: 'Project', + badgeColor: '#737373', + sourceRepoIds: ['repo-1'], + createdAt: 1, + updatedAt: 2 + } + return corrupt(project, overrides) +} + +describe('normalizeProjectHostSetupRow', () => { + it('returns the input reference when every declared type already holds', () => { + const setup = makeSetup() + expect(normalizeProjectHostSetupRow(setup)).toBe(setup) + }) + + it.each([ + ['null', null], + ['undefined', undefined], + ['a number', 42], + ['an object', {}] + ])('coerces a %s repoId to an empty string', (_label, repoId) => { + expect(normalizeProjectHostSetupRow(makeSetup({ repoId })).repoId).toBe('') + }) + + // Why path: `project-grouping.ts` calls `.trim()` and `parseWslUncPath()` on it during + // sidebar render, so the same bad row takes down the main window, not one error boundary. + it.each([ + ['null', null], + ['a number', 7] + ])('coerces a %s path to an empty string', (_label, path) => { + expect(normalizeProjectHostSetupRow(makeSetup({ path })).path).toBe('') + }) + + it('coerces every other required string field', () => { + const normalized = normalizeProjectHostSetupRow( + makeSetup({ id: null, projectId: null, displayName: null }) + ) + expect(normalized.id).toBe('') + expect(normalized.projectId).toBe('') + expect(normalized.displayName).toBe('') + }) + + it('falls back to the local host id for a non-string hostId', () => { + expect(normalizeProjectHostSetupRow(makeSetup({ hostId: null })).hostId).toBe('local') + }) + + // Why 0: the catalog merge already reads 0 as "unknown", so it degrades instead of + // restamping a fake timestamp that would win min()/max() against a real one. + it.each([ + ['null', null], + ['NaN', Number.NaN], + ['a string', '5'] + ])('coerces a %s timestamp to 0', (_label, value) => { + const normalized = normalizeProjectHostSetupRow( + makeSetup({ createdAt: value, updatedAt: value }) + ) + expect(normalized.createdAt).toBe(0) + expect(normalized.updatedAt).toBe(0) + }) + + // Why: these are only ever compared, never string-method'd — inventing a fallback would + // silently change what a corrupt row claims about itself. + it('leaves the setupState and setupMethod unions untouched', () => { + const normalized = normalizeProjectHostSetupRow( + makeSetup({ repoId: null, setupState: 'bogus', setupMethod: null }) + ) + expect(normalized.setupState).toBe('bogus') + expect(normalized.setupMethod).toBeNull() + }) + + // Why: `mergeProjectCompatibilityProject` and the setup projection branch on `in` / + // `!== undefined`, so materialising an absent optional key changes merge behaviour. + it('never adds or removes an optional key', () => { + const withoutOptionals = normalizeProjectHostSetupRow(makeSetup({ repoId: null })) + expect('connectionId' in withoutOptionals).toBe(false) + expect('worktreeBasePath' in withoutOptionals).toBe(false) + const withOptionals = normalizeProjectHostSetupRow( + makeSetup({ repoId: null, connectionId: null, worktreeBasePath: '/base' }) + ) + expect('connectionId' in withOptionals).toBe(true) + expect(withOptionals.worktreeBasePath).toBe('/base') + }) +}) + +describe('normalizeProjectRow', () => { + it('returns the input reference when every declared type already holds', () => { + const project = makeProject() + expect(normalizeProjectRow(project)).toBe(project) + }) + + // Why: the compatibility merge spreads `sourceRepoIds`, which throws on a non-array. + it.each([ + ['null', null, []], + ['a string', 'repo-1', []], + ['an array with holes', ['repo-1', null, 3], ['repo-1']] + ])('repairs a %s sourceRepoIds', (_label, sourceRepoIds, expected) => { + expect(normalizeProjectRow(makeProject({ sourceRepoIds })).sourceRepoIds).toEqual(expected) + }) + + it('coerces the required string fields', () => { + const normalized = normalizeProjectRow( + makeProject({ id: null, displayName: null, badgeColor: 42 }) + ) + expect(normalized.id).toBe('') + expect(normalized.displayName).toBe('') + expect(normalized.badgeColor).toBe('') + }) + + // Why: `'localWindowsRuntimePreference' in overlay` decides whether a preference clear wins. + it('never materialises an absent localWindowsRuntimePreference', () => { + const normalized = normalizeProjectRow(makeProject({ id: null })) + expect('localWindowsRuntimePreference' in normalized).toBe(false) + }) +}) + +describe('row array normalization', () => { + it('repairs null rows without throwing', () => { + const setups = [null] as unknown as ProjectHostSetup[] + const projects = [null] as unknown as Project[] + expect(normalizeProjectHostSetupRows(setups)[0]).toMatchObject({ + id: '', + repoId: '', + path: '' + }) + expect(normalizeProjectRows(projects)[0]).toMatchObject({ + id: '', + sourceRepoIds: [] + }) + }) + + it('returns the input array when nothing needed repair', () => { + const setups = [makeSetup(), makeSetup({ id: 'setup-2' })] + expect(normalizeProjectHostSetupRows(setups)).toBe(setups) + const projects = [makeProject()] + expect(normalizeProjectRows(projects)).toBe(projects) + }) + + // Why: TerminalPane and TabBarQuickCommandsButton use these arrays as useMemo deps, so an + // untouched row must keep its object identity across normalization. + it('reuses untouched rows and only reallocates the repaired one', () => { + const setups = [ + makeSetup(), + makeSetup({ id: 'setup-2', repoId: null }), + makeSetup({ id: 's3' }) + ] + const normalized = normalizeProjectHostSetupRows(setups) + expect(normalized).not.toBe(setups) + expect(normalized[0]).toBe(setups[0]) + expect(normalized[1]).not.toBe(setups[1]) + expect(normalized[2]).toBe(setups[2]) + }) + + // Why: coercing a field must change one value, never which rows exist — setting a "changed" + // flag on the old selector normalizer unioned in repo-derived rows and invented phantom rows. + it('preserves row count and order', () => { + const setups = [makeSetup({ repoId: null }), makeSetup({ id: 'setup-2' })] + expect(normalizeProjectHostSetupRows(setups).map((setup) => setup.id)).toEqual([ + 'setup-1', + 'setup-2' + ]) + }) + + it('treats a non-array as empty', () => { + // Why JSON.parse: a persisted catalog really can hold a non-array here, and parsing is how it + // arrives — the parameter type cannot express it. + const notAnArray: never[] = JSON.parse('null') + expect(normalizeProjectHostSetupRows(notAnArray)).toEqual([]) + expect(normalizeProjectRows(notAnArray)).toEqual([]) + }) +}) diff --git a/src/shared/project-catalog-row-normalization.ts b/src/shared/project-catalog-row-normalization.ts new file mode 100644 index 00000000000..437545e639a --- /dev/null +++ b/src/shared/project-catalog-row-normalization.ts @@ -0,0 +1,123 @@ +import { LOCAL_EXECUTION_HOST_ID } from './execution-host' +import type { Project, ProjectHostSetup } from './project-types' + +/** + * Repairs untrusted `Project` / `ProjectHostSetup` rows so their declared field types are true. + * + * These rows reach typed code from persisted JSON and from remote Orca hosts running a different + * version, where a field the type promises is a `string` can arrive `null`, missing, or another + * type entirely — while consumers call `.trim()` on it unconditionally (crash 3bcc5be3). Call + * these at every boundary such a row enters the app, so nothing downstream has to re-guard. + * + * Coercion only. A normalizer never drops a row, never adds or removes an optional key, and + * returns its input reference untouched when it already conforms — callers keep row and array + * identity, which selectors and `useMemo` deps depend on. + * + * The string-literal unions (`setupState`, `setupMethod`) are deliberately left alone: they are + * only ever compared, so an unknown value degrades instead of crashing, and inventing a fallback + * would silently change what a corrupt row claims about itself. + */ + +function isString(value: unknown): value is string { + return typeof value === 'string' +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +// Why 0 and not now(): the catalog merge already reads 0 as "timestamp unknown", not as the epoch. +function isTimestamp(value: unknown): value is number { + return typeof value === 'number' && Number.isFinite(value) +} + +function stringValue(value: unknown, fallback = ''): string { + return isString(value) ? value : fallback +} + +function timestampValue(value: unknown): number { + return isTimestamp(value) ? value : 0 +} + +export function normalizeProjectHostSetupRow(setup: ProjectHostSetup): ProjectHostSetup { + const row: Record = isRecord(setup) ? setup : {} + const needsRepair = + !isString(row.id) || + !isString(row.projectId) || + !isString(row.hostId) || + !isString(row.repoId) || + !isString(row.path) || + !isString(row.displayName) || + !isTimestamp(row.createdAt) || + !isTimestamp(row.updatedAt) + if (!needsRepair) { + return setup + } + return { + ...row, + id: stringValue(row.id), + projectId: stringValue(row.projectId), + hostId: stringValue(row.hostId, LOCAL_EXECUTION_HOST_ID) as ProjectHostSetup['hostId'], + repoId: stringValue(row.repoId), + path: stringValue(row.path), + displayName: stringValue(row.displayName), + setupState: (row.setupState === undefined + ? 'error' + : row.setupState) as ProjectHostSetup['setupState'], + setupMethod: (row.setupMethod === undefined + ? 'legacy-repo' + : row.setupMethod) as ProjectHostSetup['setupMethod'], + createdAt: timestampValue(row.createdAt), + updatedAt: timestampValue(row.updatedAt) + } +} + +export function normalizeProjectRow(project: Project): Project { + const row: Record = isRecord(project) ? project : {} + const sourceRepoIdsConform = Array.isArray(row.sourceRepoIds) && row.sourceRepoIds.every(isString) + const needsRepair = + !isString(row.id) || + !isString(row.displayName) || + !isString(row.badgeColor) || + !sourceRepoIdsConform || + !isTimestamp(row.createdAt) || + !isTimestamp(row.updatedAt) + if (!needsRepair) { + return project + } + return { + ...row, + id: stringValue(row.id), + displayName: stringValue(row.displayName), + badgeColor: stringValue(row.badgeColor), + sourceRepoIds: Array.isArray(row.sourceRepoIds) + ? row.sourceRepoIds.filter((repoId): repoId is string => isString(repoId)) + : [], + createdAt: timestampValue(row.createdAt), + updatedAt: timestampValue(row.updatedAt) + } +} + +function normalizeRows(rows: T[], normalize: (row: T) => T): T[] { + if (!Array.isArray(rows)) { + return [] + } + let repaired: T[] | null = null + for (let index = 0; index < rows.length; index += 1) { + const row = rows[index]! + const normalized = normalize(row) + if (normalized !== row && !repaired) { + repaired = rows.slice(0, index) + } + repaired?.push(normalized) + } + return repaired ?? rows +} + +export function normalizeProjectHostSetupRows(setups: ProjectHostSetup[]): ProjectHostSetup[] { + return normalizeRows(setups, normalizeProjectHostSetupRow) +} + +export function normalizeProjectRows(projects: Project[]): Project[] { + return normalizeRows(projects, normalizeProjectRow) +} diff --git a/src/shared/project-host-setup-projection.ts b/src/shared/project-host-setup-projection.ts index 8c3e5d15643..090f421ccdd 100644 --- a/src/shared/project-host-setup-projection.ts +++ b/src/shared/project-host-setup-projection.ts @@ -1,4 +1,8 @@ import { getRepoExecutionHostId } from './execution-host' +import { + normalizeProjectHostSetupRow, + normalizeProjectRow +} from './project-catalog-row-normalization' import { normalizeGitHubRemoteHost } from './git-remote-host-alias' import { githubRepoIdentityKey, isDefaultGitHubHost } from './github/repository-identity-key' import type { Project, ProjectHostSetup, ProjectProviderIdentity } from './project-types' @@ -312,7 +316,9 @@ export function projectHostSetupProjectionFromRepos( const project = existing ? mergeProjectRepo(existing.project, repo) : createProjectFromRepo(repo) - const setup = createSetupFromRepo(repo, projectId) + // Why normalize here: a repo row is untrusted persisted/wire data too, and these + // constructors copy repo.path / repo.id straight onto fields consumers call .trim() on. + const setup = normalizeProjectHostSetupRow(createSetupFromRepo(repo, projectId)) projectById.set(projectId, { project }) @@ -320,7 +326,7 @@ export function projectHostSetupProjectionFromRepos( } return { - projects: [...projectById.values()].map((entry) => entry.project), + projects: [...projectById.values()].map((entry) => normalizeProjectRow(entry.project)), setups } } From 3558cf943f2284b6d3335df386d711cf1adf5b10 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 27 Aug 2026 13:05:12 -0700 Subject: [PATCH 03/22] fix(codex): heal WSL hooks before typed launches (#16535) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(codex): heal WSL hooks before typed launches * test(codex): keep launcher fixture type-safe on Windows * fix(build): list codex-home-wsl-env in the CLI typecheck project `managed-home-shell-preflight.ts` is already in the CLI project's include list and now imports `wslCodexRuntimeHomeForGuestHome` from `src/main/pty/codex-home-wsl-env.ts`, which the list did not cover — TS6307, so the CLI typecheck failed on every push. Added the single module rather than a `src/main/pty/**` glob: it is a 31-line leaf with no imports of its own, so it does not widen what the CLI bundle can reach. * fix(codex): converge the two WSL hook install lanes onto one writer Two independent readiness reviews agreed the Orca-terminal boundary holds, but Codex Sol found a P1 the other rated P2: the new just-in-time repair raced the existing relay installer and the two produced DIFFERENT hook and trust representations for the same managed home. Two unserialized writers emitting different formats is worse than the bug this PR fixes, because it fails intermittently rather than cleanly — a pane works or does not depending on which lane won. - Relay Codex installs now delegate to the runtime-home writer, so there is one canonical representation instead of two. Redirected scripts use the runtime path, the readable wrapper, and the prepended group. - `installForRuntimeHomeSerialized` puts every asynchronous WSL caller for a given home on one queue (`wslInstallQueues`), so concurrent panes cannot interleave writes. Also rewrites the stale pin test the new `-x` guard broke. It asserted the defect — "would run the impostor if the preflight carried an unqualified command name", expecting the hijack marker to exist. The guard is a security improvement, so the test now asserts the contract: an unqualified preflight is skipped and the marker is never written. Rewritten to the new behavior, not loosened or deleted. 818 tests pass across the affected suites; typecheck clean. The changed-file quality gate could not run locally — its pnpm engine-warning JSON parser fails under Node 26 — so CI covers it. The boundary both reviews verified is untouched: paired/relay/mobile clients stay hard-blocked from the RPC, params remain shape-locked to the managed home suffix with traversal rejection, nothing is written outside the managed home, and macOS/Linux stay inert. * fix(codex): serialize resolved WSL hook homes * fix(codex): recover managed WSL homes after restart * fix(wsl): translate Codex preflight through WSLENV * fix(cli): cover bounded WSL Codex repair * fix(codex): coalesce duplicate WSL hook repairs * fix(codex): verify reconstructed WSL homes --- src/cli/handlers/agent-hooks.test.ts | 28 ++++ src/cli/handlers/agent-hooks.ts | 19 +++ .../daemon-bash-rcfile.txt | 2 +- .../daemon-fish-init.txt | 2 +- .../daemon-zsh-zshenv.txt | 2 +- .../local-bash-rcfile.txt | 2 +- .../local-fish-init.txt | 2 +- .../local-zsh-zshenv.txt | 2 +- .../remote-hook-service-installers.test.ts | 2 +- src/main/agent-hooks/wsl-hook-fs-adapter.ts | 31 +++- src/main/agent-hooks/wsl-hook-relay-deps.ts | 8 + .../wsl-hook-relay-guest-install.ts | 6 +- .../wsl-hook-relay-live.integration.test.ts | 17 +- .../wsl-hook-relay-manager.test.ts | 39 ++++- .../agent-hooks/wsl-hook-relay-manager.ts | 35 ++++- .../codex-trust-config-mutation-queue.test.ts | 32 ++++ .../codex/hook-service-wsl-runtime.test.ts | 63 +++++++- src/main/codex/hook-service.ts | 43 +++++- .../managed-home-shell-preflight.test.ts | 137 +++++++++++++++++ .../codex/managed-wsl-codex-home-registry.ts | 115 ++++++++++++++ .../codex/managed-wsl-home-shell-preflight.ts | 75 +++++++++ .../ipc/pty-spawn-env-terminal-basics.test.ts | 27 ++++ src/main/ipc/pty/host-env/assembly.ts | 2 +- ...dex-preflight-profile-path-rewrite.test.ts | 6 +- .../pty/codex-shell-launch-preflight.test.ts | 71 ++++++++- src/main/pty/codex-shell-launch-preflight.ts | 15 +- src/main/pty/wsl-orca-env.test.ts | 2 +- src/main/pty/wsl-orca-env.ts | 1 + .../runtime/rpc/methods/agent-hooks.test.ts | 145 ++++++++++++++++++ src/main/runtime/rpc/methods/agent-hooks.ts | 38 +++++ src/main/runtime/rpc/methods/index.ts | 2 + 31 files changed, 917 insertions(+), 54 deletions(-) create mode 100644 src/main/codex/managed-wsl-codex-home-registry.ts create mode 100644 src/main/codex/managed-wsl-home-shell-preflight.ts create mode 100644 src/main/runtime/rpc/methods/agent-hooks.test.ts create mode 100644 src/main/runtime/rpc/methods/agent-hooks.ts diff --git a/src/cli/handlers/agent-hooks.test.ts b/src/cli/handlers/agent-hooks.test.ts index c42531364a8..4fcc186b0d8 100644 --- a/src/cli/handlers/agent-hooks.test.ts +++ b/src/cli/handlers/agent-hooks.test.ts @@ -92,6 +92,7 @@ describe('agent hooks CLI handler', () => { }) afterEach(() => { + vi.unstubAllEnvs() vi.restoreAllMocks() rmSync(userDataPath, { recursive: true, force: true }) }) @@ -139,6 +140,33 @@ describe('agent hooks CLI handler', () => { }) }) + it('forwards WSL pane routing to the runtime exactly once without using the host installer', async () => { + const home = '/home/jin/.local/share/orca/codex-runtime-home/home' + vi.stubEnv('CODEX_HOME', home) + vi.stubEnv('ORCA_CODEX_HOME', home) + vi.stubEnv('WSL_DISTRO_NAME', 'Ubuntu-24.04') + callMock.mockResolvedValue({ result: { state: 'installed' } }) + + await main(['agent', 'hooks', 'prepare-codex'], userDataPath) + + expect(callMock).toHaveBeenCalledExactlyOnceWith( + 'agentHooks.prepareCodexForWslPane', + { codexHome: home, orcaCodexHome: home, wslDistro: 'Ubuntu-24.04' }, + { timeoutMs: 50_000 } + ) + expect(prepareManagedCodexHomeBeforeShellLaunchMock).not.toHaveBeenCalled() + }) + + it('fails open when WSL runtime preparation is unavailable', async () => { + vi.stubEnv('CODEX_HOME', '/home/jin/.local/share/orca/codex-runtime-home/home') + vi.stubEnv('ORCA_CODEX_HOME', '/home/jin/.local/share/orca/codex-runtime-home/home') + vi.stubEnv('WSL_DISTRO_NAME', 'Ubuntu') + callMock.mockRejectedValue(new Error('method_not_found')) + + await expect(main(['agent', 'hooks', 'prepare-codex'], userDataPath)).resolves.toBeUndefined() + expect(prepareManagedCodexHomeBeforeShellLaunchMock).not.toHaveBeenCalled() + }) + it('honors Codex-specific disablement when the runtime is unavailable', async () => { const state = getDefaultPersistedState(userDataPath) state.settings.disabledTuiAgents = ['codex'] diff --git a/src/cli/handlers/agent-hooks.ts b/src/cli/handlers/agent-hooks.ts index 57419518d4b..bf44211b4a7 100644 --- a/src/cli/handlers/agent-hooks.ts +++ b/src/cli/handlers/agent-hooks.ts @@ -28,6 +28,9 @@ type AgentHookCommandResult = { statuses: AgentHookInstallStatus[] } +// Covers managed-home verification, WSL identity, trust grant, and bounded app-server reap. +const WSL_CODEX_PREPARE_TIMEOUT_MS = 50_000 + function getDataPath(): string { const userDataPath = getDefaultUserDataPath() const indexPath = join(userDataPath, 'orca-profile-index.json') @@ -207,6 +210,22 @@ async function setAgentHooksEnabled( export const AGENT_HOOK_HANDLERS: Record = { 'agent hooks prepare-codex': async ({ client }) => { + if (process.env.WSL_DISTRO_NAME?.trim()) { + try { + await client.call( + 'agentHooks.prepareCodexForWslPane', + { + codexHome: process.env.CODEX_HOME ?? '', + orcaCodexHome: process.env.ORCA_CODEX_HOME ?? '', + wslDistro: process.env.WSL_DISTRO_NAME + }, + { timeoutMs: WSL_CODEX_PREPARE_TIMEOUT_MS } + ) + } catch { + // Best effort: old or unavailable runtimes must not block Codex launch. + } + return + } const settings = await readHookSettings(client) await prepareManagedCodexHomeBeforeShellLaunch({ userDataPath: getDefaultUserDataPath(), diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/daemon-bash-rcfile.txt b/src/main/__fixtures__/shell-wrapper-snapshots/daemon-bash-rcfile.txt index 079d23ec734..63748ee93af 100644 --- a/src/main/__fixtures__/shell-wrapper-snapshots/daemon-bash-rcfile.txt +++ b/src/main/__fixtures__/shell-wrapper-snapshots/daemon-bash-rcfile.txt @@ -72,7 +72,7 @@ fi # Why || : twice — zsh alone aborts inside the substitution, but every shell's # assignment adopts its exit status, so an absent codex trips set -e in bash too. __orca_codex_binary="$(unalias codex 2>/dev/null || :; command -v codex 2>/dev/null || :)" -if [[ -n "${ORCA_CODEX_LAUNCH_PREFLIGHT:-}" && -n "${__orca_codex_binary:-}" && -x "${__orca_codex_binary}" ]]; then +if [[ -n "${ORCA_CODEX_LAUNCH_PREFLIGHT:-}" && -x "${ORCA_CODEX_LAUNCH_PREFLIGHT}" && -n "${__orca_codex_binary:-}" && -x "${__orca_codex_binary}" ]]; then # Why the function reserved word: it suppresses alias expansion of the name, # which otherwise rewrites this header at parse time and aborts the whole file. function codex { diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/daemon-fish-init.txt b/src/main/__fixtures__/shell-wrapper-snapshots/daemon-fish-init.txt index 9c8b9179a81..660c8f2d1f9 100644 --- a/src/main/__fixtures__/shell-wrapper-snapshots/daemon-fish-init.txt +++ b/src/main/__fixtures__/shell-wrapper-snapshots/daemon-fish-init.txt @@ -2,7 +2,7 @@ function __orca_shell_ready_marker --on-event fish_prompt builtin printf "\033]777;orca-shell-ready\007" functions -e __orca_shell_ready_marker end -if test -n "$ORCA_CODEX_LAUNCH_PREFLIGHT"; and test (type -t codex 2>/dev/null) = file +if test -x "$ORCA_CODEX_LAUNCH_PREFLIGHT"; and test (type -t codex 2>/dev/null) = file function codex command "$ORCA_CODEX_LAUNCH_PREFLIGHT" agent hooks prepare-codex >/dev/null 2>&1; or true command codex $argv diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/daemon-zsh-zshenv.txt b/src/main/__fixtures__/shell-wrapper-snapshots/daemon-zsh-zshenv.txt index f0ec07cfcfd..e734f8c1867 100644 --- a/src/main/__fixtures__/shell-wrapper-snapshots/daemon-zsh-zshenv.txt +++ b/src/main/__fixtures__/shell-wrapper-snapshots/daemon-zsh-zshenv.txt @@ -110,7 +110,7 @@ __orca_deferred_init() { # Why || : twice — zsh alone aborts inside the substitution, but every shell's # assignment adopts its exit status, so an absent codex trips set -e in bash too. __orca_codex_binary="$(unalias codex 2>/dev/null || :; command -v codex 2>/dev/null || :)" - if [[ -n "${ORCA_CODEX_LAUNCH_PREFLIGHT:-}" && -n "${__orca_codex_binary:-}" && -x "${__orca_codex_binary}" ]]; then + if [[ -n "${ORCA_CODEX_LAUNCH_PREFLIGHT:-}" && -x "${ORCA_CODEX_LAUNCH_PREFLIGHT}" && -n "${__orca_codex_binary:-}" && -x "${__orca_codex_binary}" ]]; then # Why the function reserved word: it suppresses alias expansion of the name, # which otherwise rewrites this header at parse time and aborts the whole file. function codex { diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/local-bash-rcfile.txt b/src/main/__fixtures__/shell-wrapper-snapshots/local-bash-rcfile.txt index 895434cc688..9d1e2922b45 100644 --- a/src/main/__fixtures__/shell-wrapper-snapshots/local-bash-rcfile.txt +++ b/src/main/__fixtures__/shell-wrapper-snapshots/local-bash-rcfile.txt @@ -75,7 +75,7 @@ fi # Why || : twice — zsh alone aborts inside the substitution, but every shell's # assignment adopts its exit status, so an absent codex trips set -e in bash too. __orca_codex_binary="$(unalias codex 2>/dev/null || :; command -v codex 2>/dev/null || :)" -if [[ -n "${ORCA_CODEX_LAUNCH_PREFLIGHT:-}" && -n "${__orca_codex_binary:-}" && -x "${__orca_codex_binary}" ]]; then +if [[ -n "${ORCA_CODEX_LAUNCH_PREFLIGHT:-}" && -x "${ORCA_CODEX_LAUNCH_PREFLIGHT}" && -n "${__orca_codex_binary:-}" && -x "${__orca_codex_binary}" ]]; then # Why the function reserved word: it suppresses alias expansion of the name, # which otherwise rewrites this header at parse time and aborts the whole file. function codex { diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/local-fish-init.txt b/src/main/__fixtures__/shell-wrapper-snapshots/local-fish-init.txt index 9c8b9179a81..660c8f2d1f9 100644 --- a/src/main/__fixtures__/shell-wrapper-snapshots/local-fish-init.txt +++ b/src/main/__fixtures__/shell-wrapper-snapshots/local-fish-init.txt @@ -2,7 +2,7 @@ function __orca_shell_ready_marker --on-event fish_prompt builtin printf "\033]777;orca-shell-ready\007" functions -e __orca_shell_ready_marker end -if test -n "$ORCA_CODEX_LAUNCH_PREFLIGHT"; and test (type -t codex 2>/dev/null) = file +if test -x "$ORCA_CODEX_LAUNCH_PREFLIGHT"; and test (type -t codex 2>/dev/null) = file function codex command "$ORCA_CODEX_LAUNCH_PREFLIGHT" agent hooks prepare-codex >/dev/null 2>&1; or true command codex $argv diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/local-zsh-zshenv.txt b/src/main/__fixtures__/shell-wrapper-snapshots/local-zsh-zshenv.txt index 92802c53dca..af7cab85587 100644 --- a/src/main/__fixtures__/shell-wrapper-snapshots/local-zsh-zshenv.txt +++ b/src/main/__fixtures__/shell-wrapper-snapshots/local-zsh-zshenv.txt @@ -110,7 +110,7 @@ __orca_deferred_init() { # Why || : twice — zsh alone aborts inside the substitution, but every shell's # assignment adopts its exit status, so an absent codex trips set -e in bash too. __orca_codex_binary="$(unalias codex 2>/dev/null || :; command -v codex 2>/dev/null || :)" - if [[ -n "${ORCA_CODEX_LAUNCH_PREFLIGHT:-}" && -n "${__orca_codex_binary:-}" && -x "${__orca_codex_binary}" ]]; then + if [[ -n "${ORCA_CODEX_LAUNCH_PREFLIGHT:-}" && -x "${ORCA_CODEX_LAUNCH_PREFLIGHT}" && -n "${__orca_codex_binary:-}" && -x "${__orca_codex_binary}" ]]; then # Why the function reserved word: it suppresses alias expansion of the name, # which otherwise rewrites this header at parse time and aborts the whole file. function codex { diff --git a/src/main/agent-hooks/remote-hook-service-installers.test.ts b/src/main/agent-hooks/remote-hook-service-installers.test.ts index bcb446b10ee..a1168326ac2 100644 --- a/src/main/agent-hooks/remote-hook-service-installers.test.ts +++ b/src/main/agent-hooks/remote-hook-service-installers.test.ts @@ -284,7 +284,7 @@ describe('remote hook service installers', () => { hooks: Record } expect(hooks.hooks.Stop?.[0]?.hooks?.[0]?.command).toContain( - '/home/dev/.orca/agent-hooks/codex-hook.sh' + '/home/dev/.local/share/orca/codex-runtime-home/home/.orca/agent-hooks/codex-hook.sh' ) expect(fs.files.get(`${runtimeHome}/config.toml`)).toContain( `${runtimeHome}/hooks.json:stop:0:0` diff --git a/src/main/agent-hooks/wsl-hook-fs-adapter.ts b/src/main/agent-hooks/wsl-hook-fs-adapter.ts index 12563b65bd8..ce2e9dd75f7 100644 --- a/src/main/agent-hooks/wsl-hook-fs-adapter.ts +++ b/src/main/agent-hooks/wsl-hook-fs-adapter.ts @@ -12,19 +12,23 @@ import { type ManagedHookDetectionSettings } from './managed-hook-detection-commands' import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer' -import { wslCodexRuntimeHomeForGuestHome } from '../pty/codex-home-wsl-env' import { WSL_HOOK_FS_METHODS, type WslFsResult } from '../../shared/wsl-hook-relay-contract' +import type { AgentHookInstallStatus } from '../../shared/agent-hook-types' /** Run the shared remote hook installers against a WSL guest over the relay's fs bridge. */ export async function installWslGuestHooks(options: { mux: SshChannelMultiplexer guestHome: string + codexHomePath: string | null distro: string installHooks: typeof installRemoteManagedAgentHooks settings: ManagedHookDetectionSettings warn: (message: string) => void + /** Canonical runtime-host installer for redirected Codex homes. */ + installCodex: (runtimeHomePath: string, distro: string) => Promise }): Promise { - const { mux, guestHome, distro, installHooks, settings, warn } = options + const { mux, guestHome, codexHomePath, distro, installHooks, settings, warn, installCodex } = + options let agents try { const detected = (await mux.request('preflight.detectAgents', { @@ -42,12 +46,25 @@ export async function installWslGuestHooks(options: { if (agents.length === 0) { return } + if (agents.includes('codex') && codexHomePath) { + try { + const status = await installCodex(codexHomePath, distro) + if (status?.state === 'error') { + warn( + `[agent-hooks] WSL Codex hook install for '${distro}' failed: ${status.detail ?? 'unknown error'}` + ) + } + } catch (error) { + warn( + `[agent-hooks] WSL Codex hook install for '${distro}' failed: ${error instanceof Error ? error.message : String(error)}` + ) + } + } + // Codex is redirected into the runtime home and must use the canonical + // runtime-host writer above; the relay adapter owns all other agents. + const remoteAgents = agents.filter((agent) => agent !== 'codex') const results = await installHooks(createWslHookSftpAdapter(mux), guestHome, { - agents, - // WSL Codex launches use Orca's managed runtime CODEX_HOME, not ~/.codex. - // Keep relay hooks in that active home so status callbacks are received. - codexHomeDir: wslCodexRuntimeHomeForGuestHome(guestHome), - deferTrustUntilConfigToml: true + agents: remoteAgents }) const failed = results.filter((r) => r.state === 'error').length if (failed > 0) { diff --git a/src/main/agent-hooks/wsl-hook-relay-deps.ts b/src/main/agent-hooks/wsl-hook-relay-deps.ts index a7f1ef54bb4..dcff332e57b 100644 --- a/src/main/agent-hooks/wsl-hook-relay-deps.ts +++ b/src/main/agent-hooks/wsl-hook-relay-deps.ts @@ -9,6 +9,8 @@ import { agentHookServer } from './server' import type { ManagedHookDetectionSettings } from './managed-hook-detection-commands' import { installRemoteManagedAgentHooks } from './remote-managed-hook-installers' import { getOpenCodePluginSource } from '../opencode/hook-service' +import { codexHookService } from '../codex/hook-service' +import type { AgentHookInstallStatus } from '../../shared/agent-hook-types' import type { PluginSources } from '../../relay/plugin-overlay' import { isWslDistroRunning, @@ -61,6 +63,7 @@ export type WslHookRelayManagerDeps = { waitForSentinel: typeof waitForWslRelaySentinel ingest: (envelope: Record, connectionId: string) => void installHooks: typeof installRemoteManagedAgentHooks + installCodex: (runtimeHomePath: string, distro: string) => Promise managedHookSettings: () => ManagedHookDetectionSettings /** Plugin source strings shipped to the guest relay so an Orca update needn't redeploy the relay bundle. */ pluginSources: () => PluginSources @@ -102,6 +105,11 @@ export const defaultWslHookRelayDeps: WslHookRelayManagerDeps = { connectionId ), installHooks: installRemoteManagedAgentHooks, + installCodex: (runtimeHomePath, distro) => + codexHookService.installForRuntimeHomeSerialized(runtimeHomePath, { + runtime: 'wsl', + wslDistro: distro + }), managedHookSettings: () => null, // Why: only OpenCode is in scope for WSL now; the payload shape stays identical to SSH so Pi/OMP are additive later. pluginSources: () => ({ opencodePluginSource: getOpenCodePluginSource() }), diff --git a/src/main/agent-hooks/wsl-hook-relay-guest-install.ts b/src/main/agent-hooks/wsl-hook-relay-guest-install.ts index d7e59651a52..84cf4bfa57a 100644 --- a/src/main/agent-hooks/wsl-hook-relay-guest-install.ts +++ b/src/main/agent-hooks/wsl-hook-relay-guest-install.ts @@ -6,13 +6,14 @@ import type { ManagedHookDetectionSettings } from './managed-hook-detection-comm import type { installRemoteManagedAgentHooks } from './remote-managed-hook-installers' import { requestGuestOpenCodeOverlayDir } from './wsl-guest-plugin-install' import { installWslGuestHooks } from './wsl-hook-fs-adapter' -import { REINSTALL_MIN_INTERVAL_MS } from './wsl-hook-relay-deps' +import { REINSTALL_MIN_INTERVAL_MS, type WslHookRelayManagerDeps } from './wsl-hook-relay-deps' import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer' import type { PluginSources } from '../../relay/plugin-overlay' /** Structural slice of WslHookRelayManagerDeps — only what an install pass uses. */ type GuestInstallDeps = { installHooks: typeof installRemoteManagedAgentHooks + installCodex: WslHookRelayManagerDeps['installCodex'] managedHookSettings: () => ManagedHookDetectionSettings pluginSources: () => PluginSources warn: (message: string) => void @@ -23,6 +24,7 @@ type GuestInstallState = { distro: string mux?: SshChannelMultiplexer guestHome?: string + codexHomePath?: string opencodeOverlayDir?: string lastInstallAt?: number } @@ -37,8 +39,10 @@ export async function runWslRelayGuestInstall( await installWslGuestHooks({ mux, guestHome, + codexHomePath: state.codexHomePath ?? null, distro: state.distro, installHooks: deps.installHooks, + installCodex: deps.installCodex, settings: deps.managedHookSettings(), warn: deps.warn }) diff --git a/src/main/agent-hooks/wsl-hook-relay-live.integration.test.ts b/src/main/agent-hooks/wsl-hook-relay-live.integration.test.ts index ea6856e9862..365e4d5fcd8 100644 --- a/src/main/agent-hooks/wsl-hook-relay-live.integration.test.ts +++ b/src/main/agent-hooks/wsl-hook-relay-live.integration.test.ts @@ -13,7 +13,8 @@ import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest' import { AgentHookServer } from './server' import { WslHookRelayManager } from './wsl-hook-relay-manager' -import { wslCodexRuntimeHomeForGuestHome } from '../pty/codex-home-wsl-env' +import { createManagedHookLocalFilesystem } from './managed-hook-local-filesystem' +import { codexHookService } from '../codex/hook-service' const BUNDLE_DIR = join(process.cwd(), 'out', 'relay', 'wsl') const BUNDLE_JS = join(BUNDLE_DIR, 'wsl-agent-hook-relay.js') @@ -75,6 +76,7 @@ describe.skipIf(process.platform === 'win32')( const server = orcaServer const warns: string[] = [] + const codexHome = join(fakeHome, '.local', 'share', 'orca', 'codex-runtime-home', 'home') manager = new WslHookRelayManager({ platform: () => 'win32', remoteHooksEnabled: () => true, @@ -102,6 +104,11 @@ describe.skipIf(process.platform === 'win32')( envelope as Parameters[0], connectionId ), + installCodex: (runtimeHomePath) => + codexHookService.installRemote(createManagedHookLocalFilesystem(), fakeHome, { + codexHomeDir: runtimeHomePath, + deferTrustUntilConfigToml: true + }), managedHookSettings: () => ({ agentCmdOverrides: { claude: process.execPath, @@ -112,15 +119,17 @@ describe.skipIf(process.platform === 'win32')( transientRetryDelayMs: 1 }) - manager.ensureForDistro('LiveDistro') + manager.ensureForDistro('LiveDistro', codexHome) // Waiting on Codex's artifact (not Claude's, which is written first) keeps the // assertions behind the still-running 14-agent installer loop. - const codexHome = wslCodexRuntimeHomeForGuestHome(fakeHome) await vi.waitFor(() => expect(existsSync(join(codexHome, 'hooks.json'))).toBe(true), { timeout: 15_000 }) - expect(existsSync(join(fakeHome, '.claude', 'settings.json'))).toBe(true) + await vi.waitFor( + () => expect(existsSync(join(fakeHome, '.claude', 'settings.json'))).toBe(true), + { timeout: 15_000 } + ) const claudeScript = readFileSync( join(fakeHome, '.orca', 'agent-hooks', 'claude-hook.sh'), 'utf8' diff --git a/src/main/agent-hooks/wsl-hook-relay-manager.test.ts b/src/main/agent-hooks/wsl-hook-relay-manager.test.ts index 797b461d133..9d92f5d131a 100644 --- a/src/main/agent-hooks/wsl-hook-relay-manager.test.ts +++ b/src/main/agent-hooks/wsl-hook-relay-manager.test.ts @@ -147,6 +147,8 @@ describe('WslHookRelayManager', () => { // hosts — installHooks is mocked here, so the fs bridge only ever serves // the wslfs.home request and never touches the real filesystem. const home = '/home/wsl-test-user' + const codexHome = + '\\\\wsl.localhost\\Ubuntu\\home\\wsl-test-user\\.local\\share\\orca\\codex-runtime-home\\home' const opencodeOverlayDir = `${home}/.orca-relay/opencode-overlays/deadbeefcafe` let harnesses: GuestHarness[] @@ -229,6 +231,13 @@ describe('WslHookRelayManager', () => { waitForSentinel: vi.fn(async () => guestTransport()), ingest: vi.fn(), installHooks: vi.fn(async () => []), + installCodex: vi.fn(async () => ({ + agent: 'codex' as const, + state: 'installed' as const, + configPath: `${home}/.local/share/orca/codex-runtime-home/home/hooks.json`, + managedHooksPresent: true, + detail: null + })), managedHookSettings: () => null, pluginSources: () => ({ opencodePluginSource: '// opencode plugin source' }), warn: vi.fn(), @@ -240,14 +249,14 @@ describe('WslHookRelayManager', () => { it('starts one relay per distro, installs hooks, exposes the guest endpoint path, and forwards envelopes', async () => { const { manager, deps } = createManager({}) - manager.ensureForDistro('Ubuntu') - manager.ensureForDistro('Ubuntu') + manager.ensureForDistro('Ubuntu', codexHome) + manager.ensureForDistro('Ubuntu', codexHome) await vi.waitFor(() => expect(deps.installHooks).toHaveBeenCalledTimes(1)) expect(deps.spawnRelay).toHaveBeenCalledTimes(1) + expect(deps.installCodex).toHaveBeenCalledWith(codexHome, 'Ubuntu') + // Codex is owned by the canonical runtime-host writer, not the relay adapter. expect(deps.installHooks).toHaveBeenCalledWith(expect.anything(), home, { - agents: ['codex'], - codexHomeDir: `${home}/.local/share/orca/codex-runtime-home/home`, - deferTrustUntilConfigToml: true + agents: [] }) expect(manager.getGuestEndpointFilePath('Ubuntu')).toBe( @@ -272,9 +281,23 @@ describe('WslHookRelayManager', () => { manager.disposeAll() }) + it('reinstalls into a newly resolved runtime home without restarting the relay', async () => { + const { manager, deps } = createManager({}) + manager.ensureForDistro('Ubuntu', codexHome) + await vi.waitFor(() => expect(deps.installCodex).toHaveBeenCalledTimes(1)) + const nextHome = codexHome.replace('codex-runtime-home', 'codex-accounts\\account-2') + + manager.ensureForDistro('ubuntu', nextHome) + await vi.waitFor(() => expect(deps.installCodex).toHaveBeenCalledTimes(2)) + + expect(deps.installCodex).toHaveBeenLastCalledWith(nextHome, 'Ubuntu') + expect(deps.spawnRelay).toHaveBeenCalledTimes(1) + manager.disposeAll() + }) + it('ships the OpenCode plugin to the guest and exposes the overlay dir', async () => { const { manager } = createManager({}) - manager.ensureForDistro('Ubuntu') + manager.ensureForDistro('Ubuntu', codexHome) await vi.waitFor(() => expect(manager.getOpenCodeOverlayDir('Ubuntu')).toBe(opencodeOverlayDir)) manager.disposeAll() }) @@ -397,7 +420,7 @@ describe('WslHookRelayManager', () => { it('stops live relays and refuses to revive them once agent status hooks are switched off', async () => { const settings = { agentStatusHooksEnabled: true } const { manager, deps } = createManager({ managedHookSettings: () => settings }) - manager.ensureForDistro('Ubuntu') + manager.ensureForDistro('Ubuntu', codexHome) await vi.waitFor(() => expect(deps.installHooks).toHaveBeenCalledTimes(1)) settings.agentStatusHooksEnabled = false @@ -414,6 +437,8 @@ describe('WslHookRelayManager', () => { settings.agentStatusHooksEnabled = true manager.resumeStoppedRelays() await vi.waitFor(() => expect(deps.spawnRelay).toHaveBeenCalledTimes(2)) + await vi.waitFor(() => expect(deps.installCodex).toHaveBeenCalledTimes(2)) + expect(deps.installCodex).toHaveBeenLastCalledWith(codexHome, 'Ubuntu') manager.disposeAll() }) diff --git a/src/main/agent-hooks/wsl-hook-relay-manager.ts b/src/main/agent-hooks/wsl-hook-relay-manager.ts index 74d163ca42f..1a414df5898 100644 --- a/src/main/agent-hooks/wsl-hook-relay-manager.ts +++ b/src/main/agent-hooks/wsl-hook-relay-manager.ts @@ -29,6 +29,10 @@ import { WSL_HOOK_FS_METHODS, wslHookRelayEndpointFilePath } from '../../shared/wsl-hook-relay-contract' +import { + recordManagedWslCodexHome, + wslRuntimeHomePathsEqual +} from '../codex/managed-wsl-codex-home-registry' type DistroState = { /** Original casing for wsl.exe argv and breadcrumbs; map keys are lowercased. */ @@ -37,6 +41,7 @@ type DistroState = { child?: ChildProcessWithoutNullStreams mux?: SshChannelMultiplexer guestHome?: string + codexHomePath?: string guestEndpointFilePath?: string opencodeOverlayDir?: string failures: number @@ -52,7 +57,7 @@ export class WslHookRelayManager { private recovery: WslRelayRecovery private states = new Map() /** Distros a hooks-off teardown stopped, so re-enabling can put them back. */ - private stoppedByHooksOff = new Set() + private stoppedByHooksOff = new Map() private defaultDistro: string | null = null private disposed = false private warnedBundleMissing = false @@ -64,7 +69,7 @@ export class WslHookRelayManager { warn: (message) => this.deps.warn(message), isDisposed: () => this.disposed, isCurrent: (state) => this.states.get(wslHookRelayStateKey(state.distro)) === state, - restart: (distro) => this.ensureForDistro(distro), + restart: (distro) => this.ensureForDistro(distro, this.stateFor(distro)?.codexHomePath), dropState: (state) => { // Why: identity-guarded — a fresh ensure() may own this key by now; // deleting by key alone would orphan its live relay child. @@ -81,11 +86,11 @@ export class WslHookRelayManager { } /** Fire-and-forget from every WSL PTY spawn-env build; errors breadcrumb. */ - ensureForDistro(distro: string | null): void { + ensureForDistro(distro: string | null, codexHomePath?: string | null): void { if (this.disposed || !isWslHookRelayAllowed(this.deps)) { return } - void this.ensureInternal(distro).catch((err) => { + void this.ensureInternal(distro, codexHomePath ?? undefined).catch((err) => { const detail = err instanceof Error ? err.message : String(err) this.deps.warn(`[agent-hooks] WSL hook relay ensure failed: ${detail}`) }) @@ -118,7 +123,7 @@ export class WslHookRelayManager { state.mux?.dispose() state.child?.kill() if (!permanent) { - this.stoppedByHooksOff.add(state.distro) + this.stoppedByHooksOff.set(state.distro, state.codexHomePath) } } this.states.clear() @@ -129,26 +134,39 @@ export class WslHookRelayManager { resumeStoppedRelays(): void { const distros = [...this.stoppedByHooksOff] this.stoppedByHooksOff.clear() - for (const distro of distros) { + for (const [distro, codexHomePath] of distros) { void this.deps .isDistroRunning(distro) .then((running) => { if (running) { - this.ensureForDistro(distro) + this.ensureForDistro(distro, codexHomePath) } }) .catch(() => undefined) } } - private async ensureInternal(requestedDistro: string | null): Promise { + private async ensureInternal( + requestedDistro: string | null, + requestedCodexHomePath?: string + ): Promise { const distro = requestedDistro ?? (await this.resolveDefaultDistro()) if (!distro || this.disposed) { return } const key = wslHookRelayStateKey(distro) const existing = this.states.get(key) + if (requestedCodexHomePath) { + recordManagedWslCodexHome(distro, requestedCodexHomePath) + } if (existing) { + if ( + requestedCodexHomePath && + !wslRuntimeHomePathsEqual(existing.codexHomePath, requestedCodexHomePath) + ) { + existing.codexHomePath = requestedCodexHomePath + existing.lastInstallAt = 0 + } if (existing.phase === 'running') { void maybeRerunWslRelayGuestInstall(this.deps, existing) return @@ -184,6 +202,7 @@ export class WslHookRelayManager { // Why: instance-keyed and on the distro's persistent fs, so it outlives a relay // crash — dropping it would blank status on panes spawned mid-relaunch. opencodeOverlayDir: existing?.opencodeOverlayDir, + codexHomePath: requestedCodexHomePath ?? existing?.codexHomePath, cooldownUntil: 0 } this.states.set(key, state) diff --git a/src/main/codex/codex-trust-config-mutation-queue.test.ts b/src/main/codex/codex-trust-config-mutation-queue.test.ts index e2d775b4ed7..f07c047c203 100644 --- a/src/main/codex/codex-trust-config-mutation-queue.test.ts +++ b/src/main/codex/codex-trust-config-mutation-queue.test.ts @@ -108,4 +108,36 @@ describe('runExclusivelyForCodexTrustConfig', () => { await queued expect(secondStarted).toBe(true) }) + + it('coalesces WSL UNC aliases without folding the case-sensitive Linux path', async () => { + const aliasGate = deferred() + let aliasStarted = false + const blockedAlias = runExclusivelyForCodexTrustConfig( + String.raw`\\wsl.localhost\Ubuntu\home\Alice\.codex\config.toml`, + () => aliasGate.promise + ) + const queuedAlias = runExclusivelyForCodexTrustConfig( + String.raw`\\wsl$\ubuntu\home\Alice\.codex\config.toml`, + () => { + aliasStarted = true + return Promise.resolve() + } + ) + await Promise.resolve() + expect(aliasStarted).toBe(false) + + let distinctStarted = false + await runExclusivelyForCodexTrustConfig( + String.raw`\\wsl.localhost\Ubuntu\home\alice\.codex\config.toml`, + async () => { + distinctStarted = true + } + ) + expect(distinctStarted).toBe(true) + + aliasGate.resolve() + await blockedAlias + await queuedAlias + expect(aliasStarted).toBe(true) + }) }) diff --git a/src/main/codex/hook-service-wsl-runtime.test.ts b/src/main/codex/hook-service-wsl-runtime.test.ts index 5694e4fe9bb..05baab50912 100644 --- a/src/main/codex/hook-service-wsl-runtime.test.ts +++ b/src/main/codex/hook-service-wsl-runtime.test.ts @@ -16,6 +16,7 @@ import { type CodexTrustEntry } from './config-toml-trust' import { + CodexHookService, _internals, createCodexWslRuntimeHookInstallPlan, type CodexWslRuntimeHookInstallPlan @@ -82,7 +83,67 @@ function expectedManagedCommand(scriptPath: string): string { } describe('Codex WSL runtime hook install', () => { - it('plans WSL hook files with Linux command and trust paths', async () => { + it('coalesces aliases of one runtime home without blocking independent homes', async () => { + const service = new CodexHookService() + const releases: (() => void)[] = [] + const started: string[] = [] + vi.spyOn(service, 'installForRuntimeHome').mockImplementation(async (runtimeHomePath) => { + started.push(runtimeHomePath!) + await new Promise((resolve) => releases.push(resolve)) + return null + }) + const firstHome = '\\\\wsl$\\Ubuntu\\home\\Alice\\.local\\share\\orca\\codex-runtime-home\\home' + const alias = firstHome.replace('\\\\wsl$', '\\\\wsl.localhost') + const independent = firstHome.replace('\\Alice\\', '\\Bob\\') + + const first = service.installForRuntimeHomeSerialized(firstHome) + const second = service.installForRuntimeHomeSerialized(alias) + const third = service.installForRuntimeHomeSerialized(independent) + await vi.waitFor(() => expect(started).toEqual([firstHome, independent])) + expect(second).toBe(first) + + releases.splice(0).forEach((release) => release()) + await Promise.all([first, second, third]) + expect(started).toEqual([firstHome, independent]) + }) + + it('does not coalesce one drive-backed home across WSL distros', async () => { + const service = new CodexHookService() + const started: string[] = [] + vi.spyOn(service, 'installForRuntimeHome').mockImplementation(async (_runtimeHome, target) => { + started.push(target?.wslDistro ?? '') + return null + }) + const home = 'D:\\wsl-home\\.local\\share\\orca\\codex-runtime-home\\home' + + await Promise.all([ + service.installForRuntimeHomeSerialized(home, { runtime: 'wsl', wslDistro: 'Ubuntu' }), + service.installForRuntimeHomeSerialized(home, { runtime: 'wsl', wslDistro: 'Debian' }) + ]) + + expect(started).toEqual(['Ubuntu', 'Debian']) + }) + + it('keeps case-distinct Linux runtime homes on separate queues', async () => { + const service = new CodexHookService() + const started: string[] = [] + vi.spyOn(service, 'installForRuntimeHome').mockImplementation(async (runtimeHomePath) => { + started.push(runtimeHomePath!) + return null + }) + const upper = + '\\\\wsl.localhost\\Ubuntu\\home\\Alice\\.local\\share\\orca\\codex-runtime-home\\home' + const lower = upper.replace('\\Alice\\', '\\alice\\') + + await Promise.all([ + service.installForRuntimeHomeSerialized(upper), + service.installForRuntimeHomeSerialized(lower) + ]) + + expect(started).toEqual([upper, lower]) + }) + + it('plans WSL hook files with Linux command and trust paths', () => { const runtimeHome = '\\\\wsl.localhost\\Ubuntu\\home\\alice\\.local\\share\\orca\\codex-runtime-home\\home' diff --git a/src/main/codex/hook-service.ts b/src/main/codex/hook-service.ts index 0ea7837f154..4e58f947dee 100644 --- a/src/main/codex/hook-service.ts +++ b/src/main/codex/hook-service.ts @@ -1075,6 +1075,7 @@ export class CodexHookService { } private readonly wslReconciliationGeneration = new Map() + private readonly wslInstallsInFlight = new Map>() private supersedeWslReconciliation(runtimeHomePath: string | null | undefined): number { if (!runtimeHomePath) { @@ -1171,6 +1172,30 @@ export class CodexHookService { } } + installForRuntimeHomeSerialized( + runtimeHomePath: string | null | undefined, + target?: CodexWslRuntimeHookTarget + ): Promise { + if (!runtimeHomePath) { + return Promise.resolve(null) + } + const targetKey = target?.runtime === 'wsl' ? target.wslDistro?.trim().toLowerCase() : '' + const key = `${getWslReconciliationKey(runtimeHomePath)}\0${targetKey ?? ''}` + const active = this.wslInstallsInFlight.get(key) + if (active) { + return active + } + const install = this.installForRuntimeHome(runtimeHomePath, target) + this.wslInstallsInFlight.set(key, install) + const clear = (): void => { + if (this.wslInstallsInFlight.get(key) === install) { + this.wslInstallsInFlight.delete(key) + } + } + void install.then(clear, clear) + return install + } + refreshRuntimeUserHooksForRuntimeHome( runtimeHomePath: string | null | undefined, target?: CodexWslRuntimeHookTarget @@ -1486,7 +1511,13 @@ export class CodexHookService { options?.codexHomeDir?.replace(/\/$/, '') ?? `${remoteHome.replace(/\/$/, '')}/.codex` const remoteConfigPath = `${codexHomeBase}/hooks.json` const remoteTomlPath = `${codexHomeBase}/config.toml` - const remoteScriptPath = `${remoteHome.replace(/\/$/, '')}/.orca/agent-hooks/codex-hook.sh` + // Redirected WSL homes must use the same script location and command shape + // as the runtime installer; two representations of one hooks.json race + // into stale trust keys. Plain SSH keeps its guest-home script contract. + const redirectedCodexHome = options?.codexHomeDir?.replace(/\/$/, '') + const remoteScriptPath = redirectedCodexHome + ? `${redirectedCodexHome}/.orca/agent-hooks/codex-hook.sh` + : `${remoteHome.replace(/\/$/, '')}/.orca/agent-hooks/codex-hook.sh` try { const config = await readHooksJsonRemote(sftp, remoteConfigPath) if (!config) { @@ -1499,7 +1530,9 @@ export class CodexHookService { } } - const command = wrapPosixHookCommand(remoteScriptPath) + const command = redirectedCodexHome + ? wrapReadablePosixHookCommand(remoteScriptPath) + : wrapPosixHookCommand(remoteScriptPath) const nextHooks = { ...config.hooks } const managedEvents = new Set(CODEX_EVENTS) const isManagedCommand = createManagedCommandMatcher('codex-hook.sh') @@ -1523,11 +1556,13 @@ export class CodexHookService { const definition: HookDefinition = { hooks: [buildManagedCommandHook(command)] } - nextHooks[eventName] = [...cleaned, definition] + nextHooks[eventName] = redirectedCodexHome + ? [definition, ...cleaned] + : [...cleaned, definition] trustEntries.push({ sourcePath: remoteConfigPath, eventLabel: CODEX_EVENT_LABEL[eventName], - groupIndex: cleaned.length, + groupIndex: redirectedCodexHome ? 0 : cleaned.length, handlerIndex: 0, command, timeoutSec: MANAGED_HOOK_TIMEOUT_SECONDS diff --git a/src/main/codex/managed-home-shell-preflight.test.ts b/src/main/codex/managed-home-shell-preflight.test.ts index 8cc519d3c79..31a98a30b2f 100644 --- a/src/main/codex/managed-home-shell-preflight.test.ts +++ b/src/main/codex/managed-home-shell-preflight.test.ts @@ -2,10 +2,25 @@ import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' + +const { wslRealpathMock } = vi.hoisted(() => ({ + wslRealpathMock: vi.fn(async (path: string) => path) +})) + +vi.mock('node:fs/promises', () => ({ realpath: wslRealpathMock })) + import { prepareManagedCodexHomeBeforeShellLaunch, resolveManagedCodexShellPreflightHome } from './managed-home-shell-preflight' +import { + prepareManagedWslCodexHomeBeforeShellLaunch, + resolveManagedWslCodexShellPreflightTarget +} from './managed-wsl-home-shell-preflight' +import { + _internals as managedWslHomeRegistryInternals, + recordManagedWslCodexHome +} from './managed-wsl-codex-home-registry' const roots: string[] = [] @@ -16,6 +31,8 @@ function makeRoot(): string { } afterEach(() => { + wslRealpathMock.mockClear() + managedWslHomeRegistryInternals.clearRecordedManagedWslCodexHomes() for (const root of roots.splice(0)) { rmSync(root, { recursive: true, force: true }) } @@ -160,3 +177,123 @@ describe('managed Codex shell preflight', () => { ).toBeNull() }) }) + +describe('managed WSL Codex shell preflight', () => { + const home = '/home/jin/.local/share/orca/codex-runtime-home/home' + const runtimeHome = + '\\\\wsl.localhost\\Ubuntu-24.04\\home\\jin\\.local\\share\\orca\\codex-runtime-home\\home' + const env = { + CODEX_HOME: home, + ORCA_CODEX_HOME: home, + WSL_DISTRO_NAME: 'Ubuntu-24.04' + } + + it('targets the pane-selected managed home through its UNC twin', () => { + recordManagedWslCodexHome('Ubuntu-24.04', runtimeHome) + expect(resolveManagedWslCodexShellPreflightTarget(env)).toEqual({ + runtimeHomePath: runtimeHome, + wslDistro: 'Ubuntu-24.04' + }) + }) + + it('reconstructs a validated managed home when a restarted runtime has no record', () => { + expect(resolveManagedWslCodexShellPreflightTarget(env)).toEqual({ + runtimeHomePath: runtimeHome, + wslDistro: 'Ubuntu-24.04' + }) + }) + + it('installs once through the WSL runtime-home lane while hooks are enabled', async () => { + recordManagedWslCodexHome('Ubuntu-24.04', runtimeHome) + const status = { + agent: 'codex' as const, + state: 'installed' as const, + configPath: 'config.toml', + managedHooksPresent: true, + detail: null + } + const install = vi.fn(() => status) + + await expect( + prepareManagedWslCodexHomeBeforeShellLaunch({ env, hooksEnabled: true, install }) + ).resolves.toBe(status) + expect(install).toHaveBeenCalledExactlyOnceWith(runtimeHome, { + runtime: 'wsl', + wslDistro: 'Ubuntu-24.04' + }) + + await expect( + prepareManagedWslCodexHomeBeforeShellLaunch({ env, hooksEnabled: false, install }) + ).resolves.toBeNull() + expect(install).toHaveBeenCalledTimes(1) + }) + + it.each([ + [ + 'a user home', + { ...env, CODEX_HOME: '/home/jin/.codex', ORCA_CODEX_HOME: '/home/jin/.codex' } + ], + ['unequal routing markers', { ...env, ORCA_CODEX_HOME: `${home}-other` }], + [ + 'a parent traversal', + { + ...env, + CODEX_HOME: `/home/jin/../jin${home.slice('/home/jin'.length)}`, + ORCA_CODEX_HOME: `/home/jin/../jin${home.slice('/home/jin'.length)}` + } + ], + [ + 'a dot segment', + { + ...env, + CODEX_HOME: `/home/./jin${home.slice('/home/jin'.length)}`, + ORCA_CODEX_HOME: `/home/./jin${home.slice('/home/jin'.length)}` + } + ], + [ + 'a host path', + { ...env, CODEX_HOME: 'C:\\Users\\jin\\.codex', ORCA_CODEX_HOME: 'C:\\Users\\jin\\.codex' } + ], + ['a missing distro', { ...env, WSL_DISTRO_NAME: '' }], + ['a distro path escape', { ...env, WSL_DISTRO_NAME: 'Ubuntu\\..\\host' }], + [ + 'a repeated separator', + { + ...env, + CODEX_HOME: home.replace('/jin/', '/jin//'), + ORCA_CODEX_HOME: home.replace('/jin/', '/jin//') + } + ] + ])('rejects %s', (_label, candidate) => { + recordManagedWslCodexHome('Ubuntu-24.04', runtimeHome) + expect(resolveManagedWslCodexShellPreflightTarget(candidate)).toBeNull() + }) + + it('preserves a recorded runtime spelling for a managed account home', () => { + const directHome = '/home/jin/.local/share/orca/codex-accounts/account-1/home' + const directRuntimeHome = + '\\\\wsl$\\Ubuntu-24.04\\home\\jin\\.local\\share\\orca\\codex-accounts\\account-1\\home' + recordManagedWslCodexHome('Ubuntu-24.04', directRuntimeHome) + + expect( + resolveManagedWslCodexShellPreflightTarget({ + CODEX_HOME: directHome, + ORCA_CODEX_HOME: directHome, + WSL_DISTRO_NAME: 'ubuntu-24.04' + }) + ).toEqual({ runtimeHomePath: directRuntimeHome, wslDistro: 'ubuntu-24.04' }) + }) + + it('never authorizes a WSL system home even when it was offered by a PTY lane', () => { + const systemHome = '/home/jin/.codex' + recordManagedWslCodexHome('Ubuntu-24.04', '\\\\wsl.localhost\\Ubuntu-24.04\\home\\jin\\.codex') + + expect( + resolveManagedWslCodexShellPreflightTarget({ + CODEX_HOME: systemHome, + ORCA_CODEX_HOME: systemHome, + WSL_DISTRO_NAME: 'Ubuntu-24.04' + }) + ).toBeNull() + }) +}) diff --git a/src/main/codex/managed-wsl-codex-home-registry.ts b/src/main/codex/managed-wsl-codex-home-registry.ts new file mode 100644 index 00000000000..3641f991d24 --- /dev/null +++ b/src/main/codex/managed-wsl-codex-home-registry.ts @@ -0,0 +1,115 @@ +import { win32 as pathWin32 } from 'node:path' +import { + foldWslUncPathCaseInsensitiveParts, + parseWslUncPath, + toWindowsWslPath +} from '../../shared/wsl-paths' + +type ManagedWslCodexHome = { + runtimeHomePath: string + linuxHomePath: string +} + +const managedHomesByDistro = new Map>() + +function distroKey(distro: string): string { + return distro.trim().toLowerCase() +} + +export function isAbsolutePosixPathWithoutDotSegments(value: string): boolean { + const segments = value.split('/').slice(1) + return ( + value.startsWith('/') && + !value.startsWith('//') && + !value.includes('\\') && + segments.every((segment) => segment && segment !== '.' && segment !== '..') + ) +} + +function isOrcaManagedWslCodexHome(linuxHomePath: string): boolean { + const segments = linuxHomePath.split('/').filter(Boolean) + const orcaIndex = segments.findIndex( + (segment, index) => + segment === 'orca' && segments[index - 1] === 'share' && segments[index - 2] === '.local' + ) + if (orcaIndex === -1) { + return false + } + const tail = segments.slice(orcaIndex + 1) + return ( + (tail.length === 2 && tail[0] === 'codex-runtime-home' && tail[1] === 'home') || + (tail.length === 3 && tail[0] === 'codex-accounts' && Boolean(tail[1]) && tail[2] === 'home') + ) +} + +function linuxHomeForRuntimePath(runtimeHomePath: string, distro: string): string | null { + const wsl = parseWslUncPath(runtimeHomePath) + if (wsl) { + return wsl.distro.toLowerCase() === distroKey(distro) ? wsl.linuxPath : null + } + const drive = runtimeHomePath.match(/^([A-Za-z]):[/\\](.*)$/) + return drive ? `/mnt/${drive[1].toLowerCase()}/${drive[2].replace(/\\/g, '/')}` : null +} + +export function recordManagedWslCodexHome(distro: string, runtimeHomePath: string): void { + const normalizedDistro = distro.trim() + const linuxHomePath = linuxHomeForRuntimePath(runtimeHomePath, normalizedDistro) + if ( + !normalizedDistro || + /[\\/\r\n]/.test(normalizedDistro) || + !linuxHomePath || + !isAbsolutePosixPathWithoutDotSegments(linuxHomePath) || + !isOrcaManagedWslCodexHome(linuxHomePath) + ) { + return + } + const homes = managedHomesByDistro.get(distroKey(normalizedDistro)) ?? new Map() + homes.set(linuxHomePath, { runtimeHomePath, linuxHomePath }) + managedHomesByDistro.set(distroKey(normalizedDistro), homes) +} + +export function resolveRecordedManagedWslCodexHome( + distro: string, + linuxHomePath: string +): string | null { + if ( + !distro.trim() || + /[\\/\r\n]/.test(distro) || + !isAbsolutePosixPathWithoutDotSegments(linuxHomePath) + ) { + return null + } + return managedHomesByDistro.get(distroKey(distro))?.get(linuxHomePath)?.runtimeHomePath ?? null +} + +export function resolveManagedWslCodexHome(distro: string, linuxHomePath: string): string | null { + if ( + !distro.trim() || + /[\\/\r\n]/.test(distro) || + !isAbsolutePosixPathWithoutDotSegments(linuxHomePath) || + !isOrcaManagedWslCodexHome(linuxHomePath) + ) { + return null + } + return ( + resolveRecordedManagedWslCodexHome(distro, linuxHomePath) ?? + toWindowsWslPath(linuxHomePath, distro) + ) +} + +export function wslRuntimeHomePathsEqual(left: string | undefined, right: string): boolean { + if (!left) { + return false + } + const leftWsl = foldWslUncPathCaseInsensitiveParts(left) + const rightWsl = foldWslUncPathCaseInsensitiveParts(right) + if (leftWsl || rightWsl) { + return leftWsl !== null && leftWsl === rightWsl + } + return pathWin32.normalize(left).toLowerCase() === pathWin32.normalize(right).toLowerCase() +} + +export const _internals = { + clearRecordedManagedWslCodexHomes: () => managedHomesByDistro.clear(), + isOrcaManagedWslCodexHome +} diff --git a/src/main/codex/managed-wsl-home-shell-preflight.ts b/src/main/codex/managed-wsl-home-shell-preflight.ts new file mode 100644 index 00000000000..854869ab05a --- /dev/null +++ b/src/main/codex/managed-wsl-home-shell-preflight.ts @@ -0,0 +1,75 @@ +import { realpath } from 'node:fs/promises' +import type { AgentHookInstallStatus } from '../../shared/agent-hook-types' +import { withTimeout } from '../../shared/promise-timeout-fallback' +import type { CodexWslRuntimeHookTarget } from './codex-wsl-hook-install-plan' +import { codexHookService } from './hook-service' +import { + isAbsolutePosixPathWithoutDotSegments, + resolveManagedWslCodexHome, + wslRuntimeHomePathsEqual +} from './managed-wsl-codex-home-registry' + +type WslShellPreflightEnvironment = { + CODEX_HOME?: string + ORCA_CODEX_HOME?: string + WSL_DISTRO_NAME?: string +} + +const WSL_MANAGED_HOME_REALPATH_TIMEOUT_MS = 5_000 + +export type ManagedWslCodexShellPreflightTarget = { + runtimeHomePath: string + wslDistro: string +} + +export function resolveManagedWslCodexShellPreflightTarget( + env: WslShellPreflightEnvironment +): ManagedWslCodexShellPreflightTarget | null { + const codexHome = env.CODEX_HOME?.trim() + const orcaCodexHome = env.ORCA_CODEX_HOME?.trim() + const wslDistro = env.WSL_DISTRO_NAME?.trim() + if ( + !codexHome || + codexHome !== orcaCodexHome || + !wslDistro || + /[\\/\r\n]/.test(wslDistro) || + !isAbsolutePosixPathWithoutDotSegments(codexHome) + ) { + return null + } + const runtimeHomePath = resolveManagedWslCodexHome(wslDistro, codexHome) + return runtimeHomePath ? { runtimeHomePath, wslDistro } : null +} + +export async function prepareManagedWslCodexHomeBeforeShellLaunch(args: { + env: WslShellPreflightEnvironment + hooksEnabled: boolean + install?: ( + runtimeHomePath: string, + target: CodexWslRuntimeHookTarget + ) => AgentHookInstallStatus | Promise | null +}): Promise { + if (!args.hooksEnabled) { + return null + } + const target = resolveManagedWslCodexShellPreflightTarget(args.env) + if (!target) { + return null + } + const realHome = await withTimeout( + realpath(target.runtimeHomePath), + WSL_MANAGED_HOME_REALPATH_TIMEOUT_MS, + null + ) + if (!realHome || !wslRuntimeHomePathsEqual(realHome, target.runtimeHomePath)) { + return null + } + const install = + args.install ?? + ((home: string, hookTarget: CodexWslRuntimeHookTarget) => + codexHookService.installForRuntimeHomeSerialized(home, hookTarget)) + return await install(target.runtimeHomePath, { + runtime: 'wsl', + wslDistro: target.wslDistro + }) +} diff --git a/src/main/ipc/pty-spawn-env-terminal-basics.test.ts b/src/main/ipc/pty-spawn-env-terminal-basics.test.ts index 18fbf7019f2..54ec3e7663c 100644 --- a/src/main/ipc/pty-spawn-env-terminal-basics.test.ts +++ b/src/main/ipc/pty-spawn-env-terminal-basics.test.ts @@ -7,6 +7,7 @@ import { LocalPtyProvider } from '../providers/local-pty-provider' import { __resetPersistedWindowsPathCacheForTests } from '../pty/windows-environment-path' import { __setWindowsPathRegistryLoaderForTests } from '../pty/windows-path-registry-reader' import { hasLiveClaudePtys, markClaudePtySpawned } from '../claude-accounts/live-pty-gate' +import { wslHookRelayManager } from '../agent-hooks/wsl-hook-relay-manager' import { registerPtyHandlers, buildPtyHostEnv, clearProviderPtyState } from './pty' vi.mock('electron', () => import('./pty-ipc-mock-registry').then((m) => m.electronModuleMock())) @@ -57,6 +58,32 @@ describe('registerPtyHandlers', () => { const { handlers, mainWindow, spawnAndGetEnv, withBundledCli } = setupPtyIpcSuite() describe('spawn environment', () => { + it('passes the PTY-resolved Codex home to the WSL relay lane', () => { + const runtimeHome = + '\\\\wsl.localhost\\Ubuntu\\home\\jin\\.local\\share\\orca\\codex-runtime-home\\home' + const ensureForDistro = vi + .spyOn(wslHookRelayManager, 'ensureForDistro') + .mockImplementation(() => {}) + + try { + buildPtyHostEnv( + 'pty-wsl', + {}, + { + isPackaged: true, + userDataPath: '/tmp/orca-user-data', + selectedCodexHomePath: runtimeHome, + isWsl: true, + wslDistro: 'Ubuntu', + agentStatusHooksEnabled: true + } + ) + expect(ensureForDistro).toHaveBeenCalledExactlyOnceWith('Ubuntu', runtimeHome) + } finally { + ensureForDistro.mockRestore() + } + }) + it('refreshes the outer Windows PATH for a WSL spawn without forwarding it', async () => { const originalPlatform = process.platform Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) diff --git a/src/main/ipc/pty/host-env/assembly.ts b/src/main/ipc/pty/host-env/assembly.ts index 569b40052df..cee3bf5c50b 100644 --- a/src/main/ipc/pty/host-env/assembly.ts +++ b/src/main/ipc/pty/host-env/assembly.ts @@ -122,7 +122,7 @@ export function buildPtyHostEnv( if (opts.isWsl === true) { // Why: hook POSTs to 127.0.0.1 die inside WSL's NAT namespace; use the guest-resident relay's endpoint instead of the Windows one. const distro = opts.wslDistro ?? null - wslHookRelayManager.ensureForDistro(distro) + wslHookRelayManager.ensureForDistro(distro, opts.selectedCodexHomePath) const guestEndpoint = wslHookRelayManager.getGuestEndpointFilePath(distro) if (guestEndpoint) { baseEnv.ORCA_AGENT_HOOK_ENDPOINT = guestEndpoint diff --git a/src/main/pty/codex-preflight-profile-path-rewrite.test.ts b/src/main/pty/codex-preflight-profile-path-rewrite.test.ts index abf189de356..4649b5636a3 100644 --- a/src/main/pty/codex-preflight-profile-path-rewrite.test.ts +++ b/src/main/pty/codex-preflight-profile-path-rewrite.test.ts @@ -162,13 +162,13 @@ describe.skipIf(!bashAvailable)('Codex preflight under a profile-rewritten PATH' expect(readFileSync(fixture.codexMarker, 'utf-8').trim()).toBe('--alias-flag --version') }) - // Why: pins the defect itself, so a regression back to an unqualified name fails here. - it('would run the impostor if the preflight carried an unqualified command name', () => { + // Why: an unqualified preflight is rejected so profile PATH entries cannot hijack it. + it('skips an unqualified preflight value while still launching codex', () => { const fixture = buildFixture() launchCodexThroughRcfile(fixture, 'orca') - expect(existsSync(fixture.hijackMarker)).toBe(true) + expect(existsSync(fixture.hijackMarker)).toBe(false) expect(existsSync(fixture.intendedMarker)).toBe(false) expect(existsSync(fixture.codexMarker)).toBe(true) }) diff --git a/src/main/pty/codex-shell-launch-preflight.test.ts b/src/main/pty/codex-shell-launch-preflight.test.ts index 2aff785446b..be8d84537c9 100644 --- a/src/main/pty/codex-shell-launch-preflight.test.ts +++ b/src/main/pty/codex-shell-launch-preflight.test.ts @@ -170,6 +170,30 @@ describe.skipIf(process.platform === 'win32')('Codex shell launch preflight', () ) }) + it('does not trigger a preflight outside an Orca terminal', () => { + const root = mkdtempSync(join(tmpdir(), 'orca-codex-plain-shell-')) + roots.push(root) + const bin = join(root, 'bin') + const marker = join(root, 'preflight-ran') + mkdirSync(bin) + writeExecutable(join(bin, 'codex'), '#!/bin/sh\nprintf launched\n') + writeExecutable(join(bin, 'orca-test'), `#!/bin/sh\nprintf ran > ${JSON.stringify(marker)}\n`) + + const output = execFileSync( + '/bin/bash', + ['--noprofile', '--norc', '-c', `${getPosixCodexShellLaunchPreflight()}\ncodex`], + { + encoding: 'utf-8', + env: { + PATH: `${bin}${delimiter}${process.env.PATH ?? ''}` + } + } + ) + + expect(output.trim()).toBe('launched') + expect(existsSync(marker)).toBe(false) + }) + // Why a loop over it.each: an early `return` reported green on hosts without the // shell, so the zsh half silently never ran on Linux. skipIf reports it as a skip. for (const [shell, strict] of [ @@ -319,6 +343,24 @@ describe('Codex shell launch preflight command', () => { ).toBe(launcherPath) }) + it('carries the packaged Windows launcher for WSLENV path translation', () => { + const { userDataPath, resourcesPath } = makeCliRoot() + const launcherPath = join(resourcesPath, 'bin', 'orca.exe') + writeExecutable(launcherPath, '#!/bin/sh\nexit 0\n') + + expect( + resolveCodexShellLaunchPreflightCommand({ + hooksEnabled: true, + isPackaged: true, + isWsl: true, + managedHomePath: '/home/jin/.local/share/orca/codex-runtime-home/home', + userDataPath, + resourcesPath, + platform: 'win32' + }) + ).toBe(launcherPath) + }) + it('never returns an unqualified command name that a profile-rewritten PATH could hijack', () => { const { userDataPath, resourcesPath } = makeCliRoot() writeExecutable(join(resourcesPath, 'bin', 'orca'), '#!/bin/sh\nexit 0\n') @@ -340,16 +382,12 @@ describe('Codex shell launch preflight command', () => { it.each([ { label: 'the launcher file is missing', create: null }, - { label: 'the launcher is not executable', create: 0o644 }, { label: 'the launcher path is a directory', create: 'directory' as const } ])('skips the preflight when $label', (config) => { const { userDataPath, resourcesPath } = makeCliRoot() const launcherPath = join(resourcesPath, 'bin', 'orca') if (config.create === 'directory') { mkdirSync(launcherPath) - } else if (config.create !== null) { - writeFileSync(launcherPath, '#!/bin/sh\nexit 0\n') - chmodSync(launcherPath, config.create) } expect( @@ -364,6 +402,27 @@ describe('Codex shell launch preflight command', () => { ).toBeNull() }) + it.skipIf(process.platform === 'win32')( + 'skips the preflight when the launcher is not executable', + () => { + const { userDataPath, resourcesPath } = makeCliRoot() + const launcherPath = join(resourcesPath, 'bin', 'orca') + writeFileSync(launcherPath, '#!/bin/sh\nexit 0\n') + chmodSync(launcherPath, 0o644) + + expect( + resolveCodexShellLaunchPreflightCommand({ + hooksEnabled: true, + isPackaged: true, + managedHomePath: '/managed/home', + userDataPath, + resourcesPath, + platform: 'darwin' + }) + ).toBeNull() + } + ) + it('skips the preflight when the packaged build exposes no resources root', () => { const { userDataPath } = makeCliRoot() @@ -381,7 +440,7 @@ describe('Codex shell launch preflight command', () => { it.each([ { hooksEnabled: false, isWsl: false, managedHomePath: '/managed/home' }, - { hooksEnabled: true, isWsl: true, managedHomePath: '/managed/home' }, + { hooksEnabled: true, isWsl: true, managedHomePath: '/managed/home', isPackaged: false }, { hooksEnabled: true, isWsl: false, managedHomePath: null } ])('does not enable an unsupported preflight for %o', (options) => { const { userDataPath, resourcesPath } = makeCliRoot() @@ -390,7 +449,7 @@ describe('Codex shell launch preflight command', () => { expect( resolveCodexShellLaunchPreflightCommand({ ...options, - isPackaged: true, + isPackaged: options.isPackaged ?? true, userDataPath, resourcesPath, platform: 'darwin' diff --git a/src/main/pty/codex-shell-launch-preflight.ts b/src/main/pty/codex-shell-launch-preflight.ts index 4b2429f50f1..537933fcdba 100644 --- a/src/main/pty/codex-shell-launch-preflight.ts +++ b/src/main/pty/codex-shell-launch-preflight.ts @@ -29,7 +29,7 @@ export type CodexShellLaunchPreflightCommandOptions = { export function resolveCodexShellLaunchPreflightCommand( options: CodexShellLaunchPreflightCommandOptions ): string | null { - if (!options.hooksEnabled || options.isWsl || !options.managedHomePath) { + if (!options.hooksEnabled || !options.managedHomePath) { return null } const platform = options.platform ?? process.platform @@ -42,7 +42,14 @@ export function resolveCodexShellLaunchPreflightCommand( ...DEV_LAUNCHER_DIR, platform === 'win32' ? `${DEV_COMMAND_NAME}.cmd` : DEV_COMMAND_NAME ) - return candidate && isExecutableFileOnDisk(candidate, platform) ? candidate : null + if (!candidate || !isExecutableFileOnDisk(candidate, platform)) { + return null + } + if (!options.isWsl) { + return candidate + } + // Why: WSLENV /p translates the verified Windows launcher with the distro's configured automount root. + return platform === 'win32' && options.isPackaged ? candidate : null } function isExecutableFileOnDisk(path: string, platform: NodeJS.Platform): boolean { @@ -65,7 +72,7 @@ export function getPosixCodexShellLaunchPreflight(): string { # Why || : twice — zsh alone aborts inside the substitution, but every shell's # assignment adopts its exit status, so an absent codex trips set -e in bash too. __orca_codex_binary="$(unalias codex 2>/dev/null || :; command -v codex 2>/dev/null || :)" -if [[ -n "\${ORCA_CODEX_LAUNCH_PREFLIGHT:-}" && -n "\${__orca_codex_binary:-}" && -x "\${__orca_codex_binary}" ]]; then +if [[ -n "\${ORCA_CODEX_LAUNCH_PREFLIGHT:-}" && -x "\${ORCA_CODEX_LAUNCH_PREFLIGHT}" && -n "\${__orca_codex_binary:-}" && -x "\${__orca_codex_binary}" ]]; then # Why the function reserved word: it suppresses alias expansion of the name, # which otherwise rewrites this header at parse time and aborts the whole file. function codex { @@ -78,7 +85,7 @@ unset __orca_codex_binary } export function getFishCodexShellLaunchPreflight(): string { - return `if test -n "$ORCA_CODEX_LAUNCH_PREFLIGHT"; and test (type -t codex 2>/dev/null) = file + return `if test -x "$ORCA_CODEX_LAUNCH_PREFLIGHT"; and test (type -t codex 2>/dev/null) = file function codex command "$ORCA_CODEX_LAUNCH_PREFLIGHT" agent hooks prepare-codex >/dev/null 2>&1; or true command codex $argv diff --git a/src/main/pty/wsl-orca-env.test.ts b/src/main/pty/wsl-orca-env.test.ts index 133844dec78..cc79e2bb054 100644 --- a/src/main/pty/wsl-orca-env.test.ts +++ b/src/main/pty/wsl-orca-env.test.ts @@ -85,7 +85,7 @@ describe('addOrcaWslInteropEnv', () => { expect(env.WSLENV).toContain('ORCA_TERMINAL_HANDLE/u') expect(env.WSLENV).toContain('ORCA_USER_DATA_PATH/p') expect(env.WSLENV).toContain('ORCA_CLI_COMMAND/u') - expect(env.WSLENV).not.toContain('ORCA_CODEX_LAUNCH_PREFLIGHT') + expect(env.WSLENV).toContain('ORCA_CODEX_LAUNCH_PREFLIGHT/p') expect(env.WSLENV).toContain('ORCA_OMP_STATUS_EXTENSION/p') expect(env.WSLENV).not.toContain('ORCA_PRIME_AGENT_STATUS_EXTENSION') expect(env.WSLENV).toContain('ORCA_PANE_KEY/u') diff --git a/src/main/pty/wsl-orca-env.ts b/src/main/pty/wsl-orca-env.ts index 0fe855ba3b0..262df20b3b2 100644 --- a/src/main/pty/wsl-orca-env.ts +++ b/src/main/pty/wsl-orca-env.ts @@ -77,6 +77,7 @@ export function addOrcaWslInteropEnv(env: Record): void { // and it cannot derive the hash segment from ORCA_USER_DATA_PATH alone. 'ORCA_SHELL_READY_ROOT/p', 'ORCA_CLI_COMMAND/u', + 'ORCA_CODEX_LAUNCH_PREFLIGHT/p', 'ORCA_PANE_KEY/u', 'ORCA_TAB_ID/u', 'ORCA_WORKTREE_ID/u', diff --git a/src/main/runtime/rpc/methods/agent-hooks.test.ts b/src/main/runtime/rpc/methods/agent-hooks.test.ts new file mode 100644 index 00000000000..5781045a1f7 --- /dev/null +++ b/src/main/runtime/rpc/methods/agent-hooks.test.ts @@ -0,0 +1,145 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { OrcaRuntimeService } from '../../orca-runtime' +import { isStreamingMethod, type RpcContext } from '../core' + +const { installForRuntimeHomeSerializedMock, realpathMock } = vi.hoisted(() => ({ + installForRuntimeHomeSerializedMock: vi.fn(), + realpathMock: vi.fn() +})) + +vi.mock('../../../codex/hook-service', () => ({ + codexHookService: { installForRuntimeHomeSerialized: installForRuntimeHomeSerializedMock } +})) +vi.mock('node:fs/promises', () => ({ realpath: realpathMock })) + +import { AGENT_HOOK_METHODS } from './agent-hooks' +import { + _internals as managedWslHomeRegistryInternals, + recordManagedWslCodexHome +} from '../../../codex/managed-wsl-codex-home-registry' + +const LINUX_HOME = '/home/jin/.local/share/orca/codex-runtime-home/home' +const RUNTIME_HOME = + '\\\\wsl.localhost\\Ubuntu-24.04\\home\\jin\\.local\\share\\orca\\codex-runtime-home\\home' + +function prepareMethod() { + const method = AGENT_HOOK_METHODS.find( + (candidate) => candidate.name === 'agentHooks.prepareCodexForWslPane' + ) + if (!method || isStreamingMethod(method)) { + throw new Error('Missing agentHooks.prepareCodexForWslPane request method') + } + return method +} + +function runtimeWithSettings(enabled = true, disabledTuiAgents: string[] = []): OrcaRuntimeService { + return { + getClientSettings: vi.fn(() => ({ + agentStatusHooksEnabled: enabled, + disabledTuiAgents + })) + } as unknown as OrcaRuntimeService +} + +describe('agent hook RPC methods', () => { + beforeEach(() => { + installForRuntimeHomeSerializedMock.mockReset() + realpathMock.mockReset() + realpathMock.mockImplementation(async (path: string) => path) + managedWslHomeRegistryInternals.clearRecordedManagedWslCodexHomes() + recordManagedWslCodexHome('Ubuntu-24.04', RUNTIME_HOME) + }) + + it('installs the pane-selected WSL home once and returns its status', async () => { + const status = { agent: 'codex', state: 'installed' } + installForRuntimeHomeSerializedMock.mockResolvedValue(status) + const method = prepareMethod() + const params = method.params!.parse({ + codexHome: LINUX_HOME, + orcaCodexHome: LINUX_HOME, + wslDistro: 'Ubuntu-24.04' + }) + + await expect(method.handler(params, { runtime: runtimeWithSettings() })).resolves.toBe(status) + expect(installForRuntimeHomeSerializedMock).toHaveBeenCalledExactlyOnceWith(RUNTIME_HOME, { + runtime: 'wsl', + wslDistro: 'Ubuntu-24.04' + }) + }) + + it.each([ + [false, []], + [true, ['codex']] + ])('does not install when hooks are disabled (%s, %j)', async (enabled, disabledTuiAgents) => { + const method = prepareMethod() + const params = method.params!.parse({ + codexHome: '/home/jin/.local/share/orca/codex-runtime-home/home', + orcaCodexHome: '/home/jin/.local/share/orca/codex-runtime-home/home', + wslDistro: 'Ubuntu-24.04' + }) + + await expect( + method.handler(params, { runtime: runtimeWithSettings(enabled, disabledTuiAgents) }) + ).resolves.toBeNull() + expect(installForRuntimeHomeSerializedMock).not.toHaveBeenCalled() + }) + + it.each(['runtime', 'mobile'] as const)('rejects non-local %s callers', async (clientKind) => { + const method = prepareMethod() + const params = method.params!.parse({ + codexHome: '/home/jin/.local/share/orca/codex-runtime-home/home', + orcaCodexHome: '/home/jin/.local/share/orca/codex-runtime-home/home', + wslDistro: 'Ubuntu-24.04' + }) + + await expect( + method.handler(params, { + runtime: runtimeWithSettings(), + clientKind + } as RpcContext) + ).rejects.toThrow(/only available to the local Orca CLI/) + expect(installForRuntimeHomeSerializedMock).not.toHaveBeenCalled() + }) + + it('propagates an attempted installer failure', async () => { + installForRuntimeHomeSerializedMock.mockRejectedValue(new Error('install failed')) + const method = prepareMethod() + const params = method.params!.parse({ + codexHome: '/home/jin/.local/share/orca/codex-runtime-home/home', + orcaCodexHome: '/home/jin/.local/share/orca/codex-runtime-home/home', + wslDistro: 'Ubuntu-24.04' + }) + + await expect(method.handler(params, { runtime: runtimeWithSettings() })).rejects.toThrow( + 'install failed' + ) + expect(installForRuntimeHomeSerializedMock).toHaveBeenCalledOnce() + }) + + it('rejects a managed-looking home that resolves through a symlink', async () => { + realpathMock.mockResolvedValue( + '\\\\wsl.localhost\\Ubuntu-24.04\\home\\jin\\outside-managed-home' + ) + const method = prepareMethod() + const params = method.params!.parse({ + codexHome: LINUX_HOME, + orcaCodexHome: LINUX_HOME, + wslDistro: 'Ubuntu-24.04' + }) + + await expect(method.handler(params, { runtime: runtimeWithSettings() })).resolves.toBeNull() + expect(installForRuntimeHomeSerializedMock).not.toHaveBeenCalled() + }) + + it('rejects malformed distro names at the RPC schema', () => { + const method = prepareMethod() + + expect(() => + method.params!.parse({ + codexHome: '/home/jin/.local/share/orca/codex-runtime-home/home', + orcaCodexHome: '/home/jin/.local/share/orca/codex-runtime-home/home', + wslDistro: 'Ubuntu\\..\\host' + }) + ).toThrow() + }) +}) diff --git a/src/main/runtime/rpc/methods/agent-hooks.ts b/src/main/runtime/rpc/methods/agent-hooks.ts new file mode 100644 index 00000000000..b26b117bd32 --- /dev/null +++ b/src/main/runtime/rpc/methods/agent-hooks.ts @@ -0,0 +1,38 @@ +import { z } from 'zod' +import { prepareManagedWslCodexHomeBeforeShellLaunch } from '../../../codex/managed-wsl-home-shell-preflight' +import { defineMethod, type RpcMethod } from '../core' + +const PrepareCodexForWslPaneParams = z + .object({ + codexHome: z.string().max(4_096), + orcaCodexHome: z.string().max(4_096), + wslDistro: z + .string() + .trim() + .min(1) + .max(255) + .regex(/^[^\\/\r\n]+$/) + }) + .strict() + +export const AGENT_HOOK_METHODS: readonly RpcMethod[] = [ + defineMethod({ + name: 'agentHooks.prepareCodexForWslPane', + params: PrepareCodexForWslPaneParams, + handler: async (params, { runtime, clientKind }) => { + if (clientKind !== undefined) { + throw new Error('Codex hook preparation is only available to the local Orca CLI.') + } + const settings = runtime.getClientSettings() + return await prepareManagedWslCodexHomeBeforeShellLaunch({ + env: { + CODEX_HOME: params.codexHome, + ORCA_CODEX_HOME: params.orcaCodexHome, + WSL_DISTRO_NAME: params.wslDistro + }, + hooksEnabled: + settings.agentStatusHooksEnabled && !settings.disabledTuiAgents.includes('codex') + }) + } + }) +] diff --git a/src/main/runtime/rpc/methods/index.ts b/src/main/runtime/rpc/methods/index.ts index 94b5f236562..d919a62c289 100644 --- a/src/main/runtime/rpc/methods/index.ts +++ b/src/main/runtime/rpc/methods/index.ts @@ -43,12 +43,14 @@ import { PAIRING_METHODS } from './pairing' import { UPDATER_METHODS } from './updater' import { AGENT_SESSION_METHODS } from './agent-session' import { ARTIFACT_METHODS } from './artifacts' +import { AGENT_HOOK_METHODS } from './agent-hooks' // Why: a flat manifest keeps registration order explicit and provides one // grep-point for "what methods does the RPC server expose?" — useful when // auditing the security boundary or wiring new CLI commands. export const ALL_RPC_METHODS: readonly RpcAnyMethod[] = [ ...STATUS_METHODS, + ...AGENT_HOOK_METHODS, ...AI_VAULT_METHODS, ...ARTIFACT_METHODS, ...AUTOMATION_METHODS, From 61c8f85d52df27503ca7add3a248537190ec0e19 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Thu, 27 Aug 2026 13:28:52 -0700 Subject: [PATCH 04/22] Always enable Tasks button and provider shortcuts (#16838) * Always enable Tasks button and provider shortcuts Allows the task page to show an empty state explanation when no git repos are available, instead of disabling the button entirely. * Make task provider shortcuts keyboard-accessible sibling buttons Convert task provider shortcuts from non-semantic spans to proper button elements and position them as siblings of the Tasks button rather than children. This keeps them in the keyboard tab order while using opacity instead of display for visibility, ensuring they remain discoverable by keyboard navigation alongside the main Tasks button. --- .../components/sidebar/SidebarNav.test.tsx | 29 +++--- .../sidebar/SidebarTaskNavButton.tsx | 92 ++++++++----------- 2 files changed, 54 insertions(+), 67 deletions(-) diff --git a/src/renderer/src/components/sidebar/SidebarNav.test.tsx b/src/renderer/src/components/sidebar/SidebarNav.test.tsx index ac4208547a7..19c93a2d1c4 100644 --- a/src/renderer/src/components/sidebar/SidebarNav.test.tsx +++ b/src/renderer/src/components/sidebar/SidebarNav.test.tsx @@ -464,22 +464,27 @@ describe('SidebarNav', () => { expect(searchButton?.querySelector('kbd')).toBeNull() }) - it('hides task source shortcuts until the Tasks row is hovered or focused', async () => { + it('keeps task source shortcuts keyboard-reachable and revealed on Tasks row hover or focus', async () => { const container = await renderSidebarNav() const tasksButton = getButtonByText(container, 'Tasks') - const shortcuts = tasksButton.querySelector('[aria-label="Open GitHub tasks"]')?.parentElement + const githubShortcut = tasksButton.parentElement?.querySelector( + 'button[aria-label="Open GitHub tasks"]' + ) + expect(githubShortcut).not.toBeNull() + expect(githubShortcut?.tabIndex).toBe(0) + expect(tasksButton.contains(githubShortcut ?? null)).toBe(false) - expect(shortcuts?.className).toContain('hidden') - expect(shortcuts?.className).toContain('group-hover:flex') - expect(shortcuts?.className).toContain('group-focus-within:flex') + const shortcuts = githubShortcut?.parentElement + expect(shortcuts?.className).toContain('can-hover:opacity-0') + expect(shortcuts?.className).toContain('can-hover:group-hover:opacity-100') + expect(shortcuts?.className).toContain('can-hover:group-focus-within:opacity-100') }) it('hides available Tasks from its sidebar context menu', async () => { const container = await renderSidebarNav() const tasksButton = getButtonByText(container, 'Tasks') - expect(tasksButton.getAttribute('aria-disabled')).toBe('false') const tasksMenu = tasksButton.closest('[data-testid="context-menu"]') expect(tasksMenu).not.toBeNull() @@ -488,18 +493,20 @@ describe('SidebarNav', () => { expect(mocks.updateSettings).toHaveBeenCalledWith({ showTasksButton: false }) }) - it('keeps unavailable Tasks context-menu-capable while left click remains inert', async () => { + it('keeps Tasks enabled with no git repos so the page can explain the empty state', async () => { setSidebarState({ repos: [folderRepo()] }) const container = await renderSidebarNav() const tasksButton = getButtonByText(container, 'Tasks') - expect(tasksButton.getAttribute('aria-disabled')).toBe('true') + expect(tasksButton.getAttribute('aria-disabled')).toBeNull() expect(tasksButton.disabled).toBe(false) - expect(tasksButton.querySelectorAll('[role="button"]')).toHaveLength(0) - expect(tasksButton.querySelector('[aria-label="Open GitHub tasks"]')).toBeNull() + expect(tasksButton.className).not.toContain('opacity-50') + expect( + tasksButton.parentElement?.querySelector('button[aria-label="Open GitHub tasks"]') + ).not.toBeNull() await clickButton(tasksButton) - expect(mocks.openTaskPage).not.toHaveBeenCalled() + expect(mocks.openTaskPage).toHaveBeenCalled() const tasksMenu = tasksButton.closest('[data-testid="context-menu"]') expect(tasksMenu).not.toBeNull() diff --git a/src/renderer/src/components/sidebar/SidebarTaskNavButton.tsx b/src/renderer/src/components/sidebar/SidebarTaskNavButton.tsx index 7a29ba2eb12..426d80de67f 100644 --- a/src/renderer/src/components/sidebar/SidebarTaskNavButton.tsx +++ b/src/renderer/src/components/sidebar/SidebarTaskNavButton.tsx @@ -34,36 +34,23 @@ function HideTaskSidebarMenu({ onHide }: { onHide: () => void }): React.JSX.Elem } function TaskProviderShortcut({ - canBrowseTasks, label, onOpen, children }: { - canBrowseTasks: boolean label: string onOpen: () => void children: React.ReactNode }): React.JSX.Element { return ( - { - e.stopPropagation() - if (!canBrowseTasks) { - return - } - onOpen() - }} - className={cn( - 'rounded p-0.5 text-muted-foreground/70', - canBrowseTasks ? 'transition-colors hover:text-foreground' : 'cursor-default' - )} - aria-label={canBrowseTasks ? label : undefined} - aria-hidden={canBrowseTasks ? undefined : true} + ) } @@ -73,7 +60,6 @@ export function SidebarTaskNavButton(): React.JSX.Element | null { const activeView = useAppStore((s) => s.activeView) const repos = useAppStore((s) => s.repos) const repoMap = useRepoMap() - const canBrowseTasks = repos.some((repo) => isGitRepoKind(repo)) const showTasksButton = useAppStore((s) => s.settings?.showTasksButton !== false) const rawVisibleTaskProviders = useAppStore((s) => s.settings?.visibleTaskProviders) const defaultTaskSource = useAppStore((s) => s.settings?.defaultTaskSource ?? 'github') @@ -134,7 +120,7 @@ export function SidebarTaskNavButton(): React.JSX.Element | null { ]) const handlePrefetch = React.useCallback(() => { - if (!canBrowseTasks || resolvedDefaultTaskSource !== 'github') { + if (resolvedDefaultTaskSource !== 'github') { return } const activeRepo = activeRepoId ? (repoMap.get(activeRepoId) ?? null) : null @@ -150,7 +136,6 @@ export function SidebarTaskNavButton(): React.JSX.Element | null { } }, [ activeRepoId, - canBrowseTasks, defaultTaskViewPreset, prefetchWorkItems, repoMap, @@ -171,38 +156,36 @@ export function SidebarTaskNavButton(): React.JSX.Element | null { return ( - + {visibleTaskProviders.includes('github') ? ( ) : null} - + From 3179a5761f3262e676197086067d6b6e894e0d45 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Thu, 27 Aug 2026 13:32:09 -0700 Subject: [PATCH 05/22] Allow select-all shortcuts in quick command dialogs (#16851) Add isSelectAllShortcut() utility to detect Cmd+A (Mac) or Ctrl+A (Linux/Windows). Use it to preserve native select-all behavior in editable fields instead of being intercepted by dialog keyboard handlers. --- ...tab-bar-quick-command-search-input.test.ts | 34 +++++++++++--- .../use-tab-bar-quick-command-search-input.ts | 5 +++ .../TerminalQuickCommandDialog.test.tsx | 44 ++++++++++++++++++- .../TerminalQuickCommandDialog.tsx | 11 +++++ src/renderer/src/lib/editable-target.ts | 14 ++++++ 5 files changed, 102 insertions(+), 6 deletions(-) diff --git a/src/renderer/src/components/tab-bar/use-tab-bar-quick-command-search-input.test.ts b/src/renderer/src/components/tab-bar/use-tab-bar-quick-command-search-input.test.ts index 25058e3f44a..1883cadde2c 100644 --- a/src/renderer/src/components/tab-bar/use-tab-bar-quick-command-search-input.test.ts +++ b/src/renderer/src/components/tab-bar/use-tab-bar-quick-command-search-input.test.ts @@ -30,18 +30,23 @@ function keyEvent(init: { key: string keyCode: number ctrlKey?: boolean + metaKey?: boolean + shiftKey?: boolean + altKey?: boolean isComposing?: boolean + preventDefault?: () => void + stopPropagation?: () => void }): never { return { key: init.key, keyCode: init.keyCode, ctrlKey: init.ctrlKey ?? false, - altKey: false, - metaKey: false, - shiftKey: false, + altKey: init.altKey ?? false, + metaKey: init.metaKey ?? false, + shiftKey: init.shiftKey ?? false, nativeEvent: { isComposing: init.isComposing ?? false }, - preventDefault: () => {}, - stopPropagation: () => {} + preventDefault: init.preventDefault ?? (() => {}), + stopPropagation: init.stopPropagation ?? (() => {}) } as never } @@ -67,6 +72,25 @@ describe('useTabBarQuickCommandSearchInput IME Enter ownership', () => { expect(onCommandValueChange).toHaveBeenCalledWith(entries[1].key) }) + it('lets the native select-all behavior run in the search input', () => { + const { result } = setup() + const stopPropagation = vi.fn() + const preventDefault = vi.fn() + + result.result.current.onKeyDown( + keyEvent({ + key: 'a', + keyCode: 65, + ctrlKey: true, + stopPropagation, + preventDefault + }) + ) + + expect(stopPropagation).toHaveBeenCalledOnce() + expect(preventDefault).not.toHaveBeenCalled() + }) + it('does not run the command on the bare redispatch after a confirm', () => { const { onRun, result } = setup() result.result.current.onCompositionStart() diff --git a/src/renderer/src/components/tab-bar/use-tab-bar-quick-command-search-input.ts b/src/renderer/src/components/tab-bar/use-tab-bar-quick-command-search-input.ts index cd10dab214a..e0beabace4f 100644 --- a/src/renderer/src/components/tab-bar/use-tab-bar-quick-command-search-input.ts +++ b/src/renderer/src/components/tab-bar/use-tab-bar-quick-command-search-input.ts @@ -1,6 +1,7 @@ import { useCallback, type KeyboardEvent, type RefObject } from 'react' import { useImeEnterGestureOwnership } from '@/lib/ime-composition-keyboard-event' +import { isSelectAllShortcut } from '@/lib/editable-target' type SearchInputOptions = { commandListRef: RefObject @@ -33,6 +34,10 @@ export function useTabBarQuickCommandSearchInput({ if (imeEnter.ownsKeyDown(event)) { return } + if (isSelectAllShortcut(event)) { + event.stopPropagation() + return + } if (event.key === 'Enter' && selectedCommand) { event.preventDefault() event.stopPropagation() diff --git a/src/renderer/src/components/terminal-quick-commands/TerminalQuickCommandDialog.test.tsx b/src/renderer/src/components/terminal-quick-commands/TerminalQuickCommandDialog.test.tsx index e25152d0c32..a23a3367b12 100644 --- a/src/renderer/src/components/terminal-quick-commands/TerminalQuickCommandDialog.test.tsx +++ b/src/renderer/src/components/terminal-quick-commands/TerminalQuickCommandDialog.test.tsx @@ -2,7 +2,7 @@ import { act } from 'react' import { createRoot, type Root } from 'react-dom/client' -import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { TerminalQuickCommand } from '../../../../shared/terminal-quick-command-types' import { TerminalQuickCommandDialog } from './TerminalQuickCommandDialog' @@ -43,6 +43,10 @@ function findAnimatedRowContaining(text: string): HTMLElement { } describe('TerminalQuickCommandDialog animation structure', () => { + beforeEach(() => { + vi.stubGlobal('navigator', { userAgent: 'Macintosh' }) + }) + afterEach(async () => { await act(async () => { for (const root of mountedRoots.splice(0)) { @@ -50,6 +54,44 @@ describe('TerminalQuickCommandDialog animation structure', () => { } }) document.body.innerHTML = '' + vi.unstubAllGlobals() + }) + + it('selects all text in editable fields with Cmd+A', async () => { + await renderDialog({ + id: 'qc-select-all', + label: 'Start dev server', + action: 'terminal-command', + command: 'npm run dev', + appendEnter: true, + scope: { type: 'global' } + }) + + const fields = [ + document.body.querySelector('input'), + document.body.querySelector('textarea[aria-label="Command"]') + ] + + for (const field of fields) { + expect(field).not.toBeNull() + if (!field) { + continue + } + field.setSelectionRange(field.value.length, field.value.length) + await act(async () => { + field.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'a', + code: 'KeyA', + metaKey: true, + bubbles: true, + cancelable: true + }) + ) + }) + + expect([field.selectionStart, field.selectionEnd]).toEqual([0, field.value.length]) + } }) it('keeps agent-only fields mounted as collapsed animated rows in terminal mode', async () => { diff --git a/src/renderer/src/components/terminal-quick-commands/TerminalQuickCommandDialog.tsx b/src/renderer/src/components/terminal-quick-commands/TerminalQuickCommandDialog.tsx index 784469d779a..c0d5a26c2e1 100644 --- a/src/renderer/src/components/terminal-quick-commands/TerminalQuickCommandDialog.tsx +++ b/src/renderer/src/components/terminal-quick-commands/TerminalQuickCommandDialog.tsx @@ -22,6 +22,7 @@ import { import { Label } from '@/components/ui/label' import { getAgentCatalog } from '@/lib/agent-catalog' import { getScreenSubmitShortcutLabel, isScreenSubmitShortcut } from '@/lib/screen-submit-shortcut' +import { isSelectAllShortcut } from '@/lib/editable-target' import { TerminalQuickCommandActionToggle } from './TerminalQuickCommandActionToggle' import { TerminalQuickCommandAdvancedSection } from './TerminalQuickCommandAdvancedSection' import { TerminalQuickCommandContentSection } from './TerminalQuickCommandContentSection' @@ -196,6 +197,16 @@ export function TerminalQuickCommandDialog({
{ + if ( + isSelectAllShortcut(event) && + (event.target instanceof HTMLInputElement || + event.target instanceof HTMLTextAreaElement) + ) { + event.preventDefault() + event.stopPropagation() + event.target.select() + return + } if (isScreenSubmitShortcut(event) && canSave) { event.preventDefault() saveDraft() diff --git a/src/renderer/src/lib/editable-target.ts b/src/renderer/src/lib/editable-target.ts index b55a1e1a54a..24e4eeaf11b 100644 --- a/src/renderer/src/lib/editable-target.ts +++ b/src/renderer/src/lib/editable-target.ts @@ -1,3 +1,5 @@ +import { getShortcutPlatform } from './shortcut-platform' + // Why: shared across global keyboard listeners (App-level shortcuts and the // onboarding flow) so an in-progress text edit never gets hijacked by a // capture-phase keydown handler. @@ -21,3 +23,15 @@ export function isEditableTarget(target: EventTarget | null): boolean { null ) } + +export function isSelectAllShortcut( + event: Pick +): boolean { + if (event.key.toLowerCase() !== 'a' || event.altKey || event.shiftKey) { + return false + } + const platform = getShortcutPlatform() + return platform === 'darwin' + ? Boolean(event.metaKey) && !event.ctrlKey + : Boolean(event.ctrlKey) && !event.metaKey +} From 58232cd7974620dbe7347f8a93291e8d5a0ad937 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Thu, 27 Aug 2026 13:41:01 -0700 Subject: [PATCH 06/22] Allow drag to reorder tabs on floating workspace (#16828) * Enable drag-to-reorder for floating workspace tabs - Wrap tab bar with FloatingWorkspaceTabDragContext to reuse workspace tab-drag-split logic and gesture model - Disable sensors while panel is closed to avoid DndContext conflicts with main workspace - Extract isFloatingTerminalDragTarget to separate module for clarity - Add tests for reorder behavior and titlebar drag-target detection * Add client-hosted row support to floating panel drag-to-reorder - Include `[data-client-hosted-browser-row-id]` in no-drag selectors alongside other tab types - Broaden type check from HTMLElement to Element for better SVG support - Add test coverage for client-hosted rows and SVG icon interactions * Improve floating terminal drag target detection with Element safety chec Handle undefined Element in non-DOM contexts by explicitly checking for its existence before type-checking. Clarify the logic by replacing double negatives with explicit null comparison, making the intent clearer and the code more resilient. --- ...oatingTerminalPanel.tab-lifecycle.test.tsx | 26 +++ .../FloatingTerminalPanel.tsx | 12 +- .../FloatingWorkspaceTabDragContext.tsx | 49 +++++ .../floating-terminal-titlebar-drag-target.ts | 13 ++ .../floating-workspace-tab-reorder.test.ts | 204 ++++++++++++++++++ 5 files changed, 296 insertions(+), 8 deletions(-) create mode 100644 src/renderer/src/components/floating-terminal/FloatingWorkspaceTabDragContext.tsx create mode 100644 src/renderer/src/components/floating-terminal/floating-terminal-titlebar-drag-target.ts create mode 100644 src/renderer/src/components/floating-terminal/floating-workspace-tab-reorder.test.ts diff --git a/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tab-lifecycle.test.tsx b/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tab-lifecycle.test.tsx index 2bb123cc19f..b67dba62e06 100644 --- a/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tab-lifecycle.test.tsx +++ b/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tab-lifecycle.test.tsx @@ -499,3 +499,29 @@ describe('FloatingTerminalPanel close behavior', () => { expect(mocks.closeTab).not.toHaveBeenCalledWith('tab-c') }) }) + +describe('FloatingTerminalPanel tab drag wiring', () => { + beforeEach(setupFloatingTerminalPanelTest) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('hosts the tab strip in a drag context so floating tabs can be reordered', async () => { + setFloatingTabs([makeTab({ id: 'tab-1' }), makeTab({ id: 'tab-2' })]) + + const element = await renderPanel(true) + const dragContext = findByTypeName(element, 'FloatingWorkspaceTabDragContext') + + expect(dragContext.props.enabled).toBe(true) + expect(findByTypeName(dragContext.props.children, 'TabBar')).toBeDefined() + }) + + it('leaves the drag context inactive while the closed panel stays mounted', async () => { + setFloatingTabs([makeTab({ id: 'tab-1' })]) + + const element = await renderPanel(false) + + expect(findByTypeName(element, 'FloatingWorkspaceTabDragContext').props.enabled).toBe(false) + }) +}) diff --git a/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx b/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx index ca6b25f6e07..94bdc23151a 100644 --- a/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx +++ b/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx @@ -90,6 +90,8 @@ import type { Tab } from '../../../../shared/tab-types' import type { TerminalTab } from '../../../../shared/terminal-tab-types' import { resolveUnifiedTabLabel } from '../../../../shared/tab-title-resolution' import { FloatingBrowserSlot } from './FloatingBrowserSlot' +import { FloatingWorkspaceTabDragContext } from './FloatingWorkspaceTabDragContext' +import { isFloatingTerminalDragTarget } from './floating-terminal-titlebar-drag-target' import { FloatingTerminalOrchestrationDialog } from './FloatingTerminalOrchestrationDialog' import { FloatingTerminalResizeHandles } from './FloatingTerminalResizeHandles' import { FloatingTerminalWindowControls } from './FloatingTerminalWindowControls' @@ -153,8 +155,6 @@ type FloatingPanelShortcutResolution = | { kind: 'index'; index: number } | { kind: 'chrome'; action: KeybindingActionId } -const FLOATING_TERMINAL_NO_DRAG_SELECTOR = - 'button,input,textarea,select,[role="menuitem"],[data-testid="sortable-tab"],[data-floating-terminal-no-drag]' const FLOATING_TERMINAL_SHORTCUT_SURFACE_SELECTOR = '[data-floating-terminal-shortcut-surface]' type FloatingTerminalPanelBoundsState = { @@ -163,10 +163,6 @@ type FloatingTerminalPanelBoundsState = { source: FloatingTerminalPanelBoundsSource } -function isFloatingTerminalDragTarget(target: EventTarget): boolean { - return !(target instanceof HTMLElement && target.closest(FLOATING_TERMINAL_NO_DRAG_SELECTOR)) -} - function readInitialPanelBounds(): FloatingTerminalPanelBoundsState { const defaultCommittedBounds = getDefaultFloatingTerminalCommittedBounds() const defaultRenderedBounds = getDefaultFloatingTerminalBounds() @@ -1825,7 +1821,7 @@ export function FloatingTerminalPanel({ onPointerCancel={handleDragEnd} onDoubleClick={handleTitlebarDoubleClick} > -
+ -
+ +}): React.JSX.Element { + const dragSplit = useTabDragSplit({ worktreeId: FLOATING_TERMINAL_WORKTREE_ID, enabled }) + + return ( + + +
+ {/* Why cloneElement over a render prop: the tab bar stays a plain child element, which keeps the panel's tree shallow-walkable. */} + {cloneElement(children, { hoveredTabInsertion: dragSplit.hoveredTabInsertion })} +
+ {/* Why: the source tab stays anchored in the overflow-hidden strip; this ghost is what follows the cursor. */} + + {dragSplit.activeDrag ? : null} + +
+
+ ) +} diff --git a/src/renderer/src/components/floating-terminal/floating-terminal-titlebar-drag-target.ts b/src/renderer/src/components/floating-terminal/floating-terminal-titlebar-drag-target.ts new file mode 100644 index 00000000000..371dea745d5 --- /dev/null +++ b/src/renderer/src/components/floating-terminal/floating-terminal-titlebar-drag-target.ts @@ -0,0 +1,13 @@ +/** Interactive chrome living inside the draggable titlebar. `[data-tab-id]` and the + * client-hosted row marker cover every tab kind: a press + * on a tab starts a dnd-kit reorder, so it must never also move the panel. */ +const FLOATING_TERMINAL_NO_DRAG_SELECTOR = + 'button,input,textarea,select,[role="menuitem"],[data-tab-id],[data-client-hosted-browser-row-id],[data-floating-terminal-no-drag]' + +export function isFloatingTerminalDragTarget(target: EventTarget): boolean { + if (typeof Element === 'undefined' || !(target instanceof Element)) { + return true + } + + return target.closest(FLOATING_TERMINAL_NO_DRAG_SELECTOR) === null +} diff --git a/src/renderer/src/components/floating-terminal/floating-workspace-tab-reorder.test.ts b/src/renderer/src/components/floating-terminal/floating-workspace-tab-reorder.test.ts new file mode 100644 index 00000000000..f52e08fdfbc --- /dev/null +++ b/src/renderer/src/components/floating-terminal/floating-workspace-tab-reorder.test.ts @@ -0,0 +1,204 @@ +/** + * @vitest-environment happy-dom + */ +import { act, createElement } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' +import type { Tab, TabGroup } from '../../../../shared/tab-types' +import { useAppStore } from '../../store' +import { useTabDragSplit, type TabDragItemData } from '../tab-group/useTabDragSplit' +import { isFloatingTerminalDragTarget } from './floating-terminal-titlebar-drag-target' + +vi.mock('../browser-pane/host-guest/webview-registry', () => ({ + acquireWebviewsDragPassthrough: vi.fn(() => vi.fn()) +})) + +vi.mock('../../runtime/web-runtime-session', () => ({ + isWebRuntimeSessionActive: vi.fn(() => false), + moveWebRuntimeSessionTab: vi.fn() +})) + +const GROUP_ID = 'floating-group' +const mounted: { container: HTMLDivElement; root: Root }[] = [] + +function makeTab(id: string, sortOrder: number): Tab { + return { + id, + groupId: GROUP_ID, + worktreeId: FLOATING_TERMINAL_WORKTREE_ID, + contentType: 'terminal', + entityId: `term-${id}`, + label: id, + customLabel: null, + color: null, + sortOrder, + createdAt: sortOrder + } +} + +function makeDragData(unifiedTabId: string): TabDragItemData { + return { + kind: 'tab', + worktreeId: FLOATING_TERMINAL_WORKTREE_ID, + groupId: GROUP_ID, + unifiedTabId, + visibleTabId: `term-${unifiedTabId}`, + tabType: 'terminal', + label: unifiedTabId + } +} + +function makeDropEvent( + activeData: TabDragItemData, + overData: TabDragItemData | null, + pointerX: number +) { + return { + active: { data: { current: activeData }, rect: { current: { initial: null } } }, + over: overData + ? { + id: overData.visibleTabId, + data: { current: overData }, + // Tabs sit in a 100px-wide strip slot starting at x=200; midpoint is 250. + rect: { left: 200, top: 0, width: 100, height: 32 } + } + : null, + delta: { x: 0, y: 0 }, + activatorEvent: { clientX: pointerX, clientY: 16 } + } +} + +function renderFloatingDragHook(): ReturnType { + let result: ReturnType | null = null + function Probe(): null { + result = useTabDragSplit({ worktreeId: FLOATING_TERMINAL_WORKTREE_ID, enabled: true }) + return null + } + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + act(() => root.render(createElement(Probe))) + mounted.push({ container, root }) + if (!result) { + throw new Error('useTabDragSplit did not render') + } + return result +} + +function dropTab(from: string, over: string, pointerX: number): void { + const drag = renderFloatingDragHook() + const activeData = makeDragData(from) + act(() => { + drag.onDragStart( + makeDropEvent(activeData, null, pointerX) as unknown as Parameters[0] + ) + }) + act(() => { + drag.onDragEnd( + makeDropEvent(activeData, makeDragData(over), pointerX) as unknown as Parameters< + typeof drag.onDragEnd + >[0] + ) + }) +} + +function floatingTabOrder(): string[] { + const groups: TabGroup[] = + useAppStore.getState().groupsByWorktree[FLOATING_TERMINAL_WORKTREE_ID] ?? [] + return groups.find((group) => group.id === GROUP_ID)?.tabOrder ?? [] +} + +beforeEach(() => { + useAppStore.setState({ + // The floating workspace is never the active worktree; its tabs still reorder. + activeWorktreeId: 'wt-main', + activeGroupIdByWorktree: { [FLOATING_TERMINAL_WORKTREE_ID]: GROUP_ID }, + groupsByWorktree: { + [FLOATING_TERMINAL_WORKTREE_ID]: [ + { + id: GROUP_ID, + worktreeId: FLOATING_TERMINAL_WORKTREE_ID, + activeTabId: 'tab-a', + tabOrder: ['tab-a', 'tab-b', 'tab-c'] + } + ] + }, + unifiedTabsByWorktree: { + [FLOATING_TERMINAL_WORKTREE_ID]: [ + makeTab('tab-a', 0), + makeTab('tab-b', 1), + makeTab('tab-c', 2) + ] + }, + layoutByWorktree: {} + }) +}) + +afterEach(() => { + for (const { container, root } of mounted.splice(0)) { + act(() => root.unmount()) + container.remove() + } + document.body.replaceChildren() + vi.clearAllMocks() +}) + +describe('floating workspace tab reorder', () => { + it('moves a tab after the hovered tab when released on its right half', () => { + dropTab('tab-a', 'tab-c', 290) + expect(floatingTabOrder()).toEqual(['tab-b', 'tab-c', 'tab-a']) + }) + + it('moves a tab before the hovered tab when released on its left half', () => { + dropTab('tab-c', 'tab-a', 210) + expect(floatingTabOrder()).toEqual(['tab-c', 'tab-a', 'tab-b']) + }) + + it('leaves the order alone when a tab is dropped on itself', () => { + dropTab('tab-b', 'tab-b', 290) + expect(floatingTabOrder()).toEqual(['tab-a', 'tab-b', 'tab-c']) + }) + + it('republishes sortOrder so the panel renders the committed order', () => { + dropTab('tab-a', 'tab-c', 290) + const tabs = useAppStore.getState().unifiedTabsByWorktree[FLOATING_TERMINAL_WORKTREE_ID] ?? [] + expect(tabs.map((tab) => [tab.id, tab.sortOrder])).toEqual([ + ['tab-a', 2], + ['tab-b', 0], + ['tab-c', 1] + ]) + }) +}) + +describe('floating panel titlebar drag target', () => { + it.each(['sortable-tab', 'browser-tab', 'editor-tab', 'client-hosted-browser-row-id'])( + 'does not move the panel when the press lands on a %s', + (kind) => { + const tab = document.createElement('div') + tab.dataset.tabId = kind + const label = document.createElement('span') + tab.appendChild(label) + document.body.appendChild(tab) + + expect(isFloatingTerminalDragTarget(tab)).toBe(false) + expect(isFloatingTerminalDragTarget(label)).toBe(false) + } + ) + + it('does not move the panel when a tab SVG icon receives the press', () => { + const tab = document.createElement('div') + tab.dataset.tabId = 'terminal-tab' + const icon = document.createElementNS('http://www.w3.org/2000/svg', 'svg') + tab.appendChild(icon) + document.body.appendChild(tab) + + expect(isFloatingTerminalDragTarget(icon)).toBe(false) + }) + + it('still moves the panel from bare titlebar chrome', () => { + const titlebar = document.createElement('div') + document.body.appendChild(titlebar) + expect(isFloatingTerminalDragTarget(titlebar)).toBe(true) + }) +}) From f400f8fd5f35f0f27ad4697af8789f99fd9a4fa4 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Thu, 27 Aug 2026 13:44:03 -0700 Subject: [PATCH 07/22] fix(macos): opt out of press-and-hold so held keys repeat (#14746) (#15589) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(macos): opt out of press-and-hold so held keys repeat (#14746) macOS routes press-and-hold to the accent picker unless an app sets ApplePressAndHoldEnabled=false for its own bundle, so holding j in vim inserted one character instead of repeating. Orca never set it. Written at most once, and never over an explicit value: `defaults read` is domain-scoped and exits 1 when the key is absent, which is the only way to tell "unset" from a deliberate false — Electron's systemPreferences.getUserDefault reports false for both. A recorded decision in userData keeps a later launch from re-clobbering a user who deletes the key to get the accent picker back. * docs(macos): record the revert hazard and CI's macOS test gap Two things a reader of this module cannot otherwise know. A revert leaves the key written in every user's domain forever. AppKit reads the plist, not this file, so removing the code alone keeps press-and-hold disabled for everyone who ran an affected build. The sibling period-substitution module carries the same warning because that fix was already lost once this way. And the real-binary test file that pins the defaults(1) exit-code semantics this design rests on never runs in CI: the e2e workflow and both unit-test jobs are ubuntu and windows, and the only macOS runners in the repo are build and packaging jobs that run no tests. Those six tests plus the real-bundle e2e case pass on a developer Mac and execute zero times in a green PR, so the comment should not imply enforcement that is not there. Refs #14746 * feat(macos): let users turn the accent menu back on (#14746) Orca disables press-and-hold for its own preferences domain so held keys repeat. That is the right default, but the way back was a `defaults write` buried in a source comment: nothing in docs/ or the README mentioned it, and the preference is per-application, so it silently takes the accent picker away from the Markdown editor and every other text field too. Terminal -> Advanced now carries a "Character Accent Menu" switch, macOS and desktop only. A web client cannot write a macOS preference for the machine the user is looking at, so the control and its search-index entry are both gated on that, not on the client's platform alone. Precedence, which is the part that is easy to get wrong: the setting is `undefined` until the user touches it, which is what keeps a hand-run `defaults write` in charge for everyone who never opens the toggle. Once used, Orca owns the key and writes exactly what the switch asks for -- `ApplePressAndHoldEnabled` *is* the accent-menu switch, so it maps straight through with no inversion. The choice is compared against `appliedSetting` in the existing decision record rather than against the domain, so a `defaults write` made *after* using the toggle is still the newer choice and survives the next launch. Re-asserting the value every launch would have reintroduced the clobbering the record exists to prevent. The write lands for the next launch, since AppKit reads the preference as the process starts, so the toggle shows the same restart banner the window-blur setting uses. That banner is now a shared component, keeping its original translation keys. docs/reference/macos-press-and-hold.md records the precedence rules, the `defaults read` rationale, the revert hazard, and the fact that none of this executes in CI: every macOS job builds or packages and runs no tests, so the real-binary and e2e coverage here passes only on a developer Mac. * docs(macos): stop asserting when AppKit re-reads the press-and-hold key Five places stated "AppKit reads the preference as the process starts" as fact. That is the reason given for requiring a relaunch, and it is not something this change ever measured. Evidence points the other way: terminal emulators that register this key after their process has started get key repeat in that same launch, which a read-once-at-startup model cannot explain. The relaunch requirement itself still looks right, but for a different and verifiable reason: the write goes out through a separate `defaults` process, so this app's own cached copy need not observe it. That is what the comments now say, with the AppKit question left open rather than answered. Refs #14746 * docs(macos): correct the startup comment's launch-timing claim The comment said this call site is "the last point that can still matter for this launch", which contradicts the rest of the module: the write is assumed to land for the next launch because it goes out through a separate `defaults` process. Reported on the PR by @innocarpe, who also supplied the replacement wording. Co-authored-by: innocarpe * refactor(macos): probe press-and-hold through the shared spawn chokepoint `src/shared/child-process/child-process-import-boundary.test.ts` forbids a direct `node:child_process` import outside its allowlist, and the allowlist only shrinks — so this module moves to `runProcessSync`, which exists for callers that genuinely cannot await. This one runs before `app.whenReady()`. `runProcessSync` returns a non-zero exit instead of throwing it, so the three-way read decision is re-expressed against `ProcessResult`: exit 0 is an explicit value, exit 1 is a missing key, and a timeout, a signal kill, any other exit, or a child that never started all stay 'unknown'. The throw path is now inside `interpretDefaultsRead` so a spawn failure is reachable from a test rather than hidden in an untested catch, and the write checks the exit code — a refused `defaults write` no longer looks like success. Both boundary-test failures were the same import: with it gone the offender count returns to 155, so no ratchet baseline is bumped. * Revert "feat(macos): let users turn the accent menu back on (#14746)" This reverts commit cc5669f3068fd736a1fcf6017f1ea36b18f9ee04. --------- Co-authored-by: innocarpe --- .gitignore | 1 + docs/reference/macos-press-and-hold.md | 58 ++++ src/main/index.ts | 4 + ...s-and-hold-default.defaults-domain.test.ts | 173 +++++++++++ src/main/macos-press-and-hold-default.test.ts | 286 ++++++++++++++++++ src/main/macos-press-and-hold-default.ts | 257 ++++++++++++++++ .../e2e/macos-press-and-hold-startup.spec.ts | 96 ++++++ 7 files changed, 875 insertions(+) create mode 100644 docs/reference/macos-press-and-hold.md create mode 100644 src/main/macos-press-and-hold-default.defaults-domain.test.ts create mode 100644 src/main/macos-press-and-hold-default.test.ts create mode 100644 src/main/macos-press-and-hold-default.ts create mode 100644 tests/e2e/macos-press-and-hold-startup.spec.ts diff --git a/.gitignore b/.gitignore index a08e1058eeb..176206bacfb 100644 --- a/.gitignore +++ b/.gitignore @@ -103,6 +103,7 @@ docs/** !docs/reference/git-compatibility.md !docs/reference/headless-linux-server.md !docs/reference/linux-glibc-compatibility.md +!docs/reference/macos-press-and-hold.md !docs/reference/orcad-operations.md !docs/reference/relay-grace-time-reconfiguration.md !docs/reference/windows-process-enumeration.md diff --git a/docs/reference/macos-press-and-hold.md b/docs/reference/macos-press-and-hold.md new file mode 100644 index 00000000000..946a1ff7125 --- /dev/null +++ b/docs/reference/macos-press-and-hold.md @@ -0,0 +1,58 @@ +# macOS press-and-hold and key repeat + +macOS opens the accent picker when a key is held unless an application opts out in its preferences +domain. That prevents held keys from repeating in terminal applications such as vim. On the first +eligible launch, Orca writes: + +```sh +defaults write com.stablyai.orca ApplePressAndHoldEnabled -bool false +``` + +The write is scoped to Orca's packaged bundle domain. Bare Electron development bundles and +non-macOS platforms are left untouched. A fresh write is conservatively treated as taking effect +on the next launch. + +## Precedence and decision record + +Orca checks for an explicit domain value before writing. Either `true` or `false` is treated as a +user choice and preserved. Only an unset key receives the `false` default. + +The decision is stored once in +`/macos-press-and-hold-default.json`. An `applied` or +`kept-user-preference` decision prevents future launches from touching the domain again. +Probe and write failures remain retryable so a transient failure does not permanently disable the +fix. + +`defaults read ` is used instead of +`systemPreferences.getUserDefault`: the Electron API cannot distinguish an unset key from an +explicit `false`. Only the missing-key exit status is interpreted as unset; spawn failures, +timeouts, and other exit statuses leave the preference alone. + +## Restoring the accent picker + +Set the preference explicitly, then restart Orca: + +```sh +defaults write com.stablyai.orca ApplePressAndHoldEnabled -bool true +``` + +After Orca has recorded its one-time decision, deleting the key also restores the macOS default +without Orca recreating it: + +```sh +defaults delete com.stablyai.orca ApplePressAndHoldEnabled +``` + +Development and prerelease channels may use a channel-suffixed Orca bundle identifier; use that +domain instead when applicable. + +## Reverting + +Deleting the startup code is not enough. AppKit reads the persisted preference, so a code revert +must also arrange to delete the key for users who ran an affected build. + +## Test coverage + +Unit tests cover platform guards, explicit-value preservation, retry behavior, domain ownership, +record persistence, and subprocess exit interpretation on CI. A macOS-only test additionally pins +the real `defaults(1)` behavior, but current PR CI does not execute tests on macOS. diff --git a/src/main/index.ts b/src/main/index.ts index a142fc6cf7d..c21adc53642 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -13,6 +13,7 @@ import { type Tray, session } from 'electron' +import { applyMacPressAndHoldDefaultAtStartup } from './macos-press-and-hold-default' import { initTccPromptNotice, stopTccPromptNotice } from './macos-tcc-prompt-notice' import { electronApp, is } from '@electron-toolkit/utils' import { @@ -938,6 +939,9 @@ if (hasSingleInstanceLock) { installDevParentSignalQuit(shouldCoupleToDevParent) // Why: run after configureDevUserDataPath but before app.setName('Orca') (whenReady), which changes the resolved path on case-sensitive filesystems. initDataPath() + // Why here: initDataPath above gives the canonical userData path for the record file; the write + // itself lands for the next launch (see macos-press-and-hold-default.ts). + applyMacPressAndHoldDefaultAtStartup(getCanonicalUserDataPath()) // Why: use the canonical userData path — late app.getPath('userData') can resolve differently across restarts, defeating persistence. initSessionParseCachePersistence({ filePath: join(getCanonicalUserDataPath(), 'ai-vault', 'session-parse-cache.json'), diff --git a/src/main/macos-press-and-hold-default.defaults-domain.test.ts b/src/main/macos-press-and-hold-default.defaults-domain.test.ts new file mode 100644 index 00000000000..18dab052450 --- /dev/null +++ b/src/main/macos-press-and-hold-default.defaults-domain.test.ts @@ -0,0 +1,173 @@ +import { execFileSync } from 'node:child_process' +import { randomUUID } from 'node:crypto' +import { chmodSync, mkdirSync, mkdtempSync, rmSync } from 'node:fs' +import { homedir, tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { runProcessSync } from '../shared/child-process/run-process' +import { + PRESS_AND_HOLD_KEY, + ensureMacPressAndHoldDefault, + interpretDefaultsRead, + readDomainPressAndHoldPreference, + writeDomainPressAndHoldPreference, + type PressAndHoldHost, + type PressAndHoldRecord +} from './macos-press-and-hold-default' + +/** + * Runs against the real `/usr/bin/defaults` on a throwaway Orca-owned domain. + * + * The whole design rests on one claim the mocks cannot make: a domain that has never been written + * is distinguishable from one explicitly set to `false`. Electron's `systemPreferences` cannot tell + * them apart, so if `defaults` could not either, "only write when unset" would be unimplementable. + * + * Every domain used here is a throwaway UUID under Orca's own prefix, deleted along with its plist + * in `afterEach`; the real `com.stablyai.orca` domain is never read or written. + */ + +// Why a real Orca-owned domain shape: the ownership guard rejects anything else, so a fake prefix +// would exercise a different branch than production. +const domains: string[] = [] + +/** Pinned against the real binary below, then reused by the platform-agnostic unit tests. */ +const DEFAULTS_MISSING_EXIT_CODE = 1 + +function throwawayDomain(): string { + const domain = `com.stablyai.orca.defaults-domain-test.${randomUUID()}` + domains.push(domain) + return domain +} + +function write(domain: string, value: 'true' | 'false'): void { + execFileSync('/usr/bin/defaults', ['write', domain, PRESS_AND_HOLD_KEY, '-bool', value], { + stdio: 'ignore' + }) +} + +function rawRead(domain: string): string { + return execFileSync('/usr/bin/defaults', ['read', domain, PRESS_AND_HOLD_KEY], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'] + }).trim() +} + +function hostFor(domain: string, record: PressAndHoldRecord | null = null): PressAndHoldHost { + return { + platform: 'darwin', + resolveBundleIdentifier: () => domain, + readRecord: () => record, + writeRecord: () => undefined, + readDomainPreference: readDomainPressAndHoldPreference, + writeDomainPreference: writeDomainPressAndHoldPreference, + now: () => new Date().toISOString() + } +} + +describe.skipIf(process.platform !== 'darwin')( + 'press-and-hold against the real defaults binary', + () => { + afterEach(() => { + for (const domain of domains.splice(0)) { + try { + execFileSync('/usr/bin/defaults', ['delete', domain], { stdio: 'ignore' }) + } catch { + // The domain may never have been created; nothing to clean up. + } + // Why the file too: `defaults delete` empties the domain but leaves its plist behind, so + // without this every run litters the developer's ~/Library/Preferences. + rmSync(join(homedir(), 'Library', 'Preferences', `${domain}.plist`), { force: true }) + } + }) + + it('tells an unset key apart from an explicit false', () => { + const unset = throwawayDomain() + const explicitlyFalse = throwawayDomain() + write(explicitlyFalse, 'false') + + expect(readDomainPressAndHoldPreference(unset)).toBe('unset') + expect(readDomainPressAndHoldPreference(explicitlyFalse)).toBe('set') + expect(rawRead(explicitlyFalse)).toBe('0') + }) + + it('reports an explicit true as set', () => { + const domain = throwawayDomain() + write(domain, 'true') + + expect(readDomainPressAndHoldPreference(domain)).toBe('set') + expect(rawRead(domain)).toBe('1') + }) + + it('writes false into a previously unset domain', () => { + const domain = throwawayDomain() + + expect(ensureMacPressAndHoldDefault(hostFor(domain))).toBe('applied') + expect(rawRead(domain)).toBe('0') + }) + + it('leaves an explicit value untouched, in either direction', () => { + for (const value of ['true', 'false'] as const) { + const domain = throwawayDomain() + write(domain, value) + + expect(ensureMacPressAndHoldDefault(hostFor(domain))).toBe('kept-user-preference') + expect(rawRead(domain)).toBe(value === 'true' ? '1' : '0') + } + }) + + it('refuses to call a failed probe "unset"', () => { + // Real runs, not hand-built results: a binary that is not there must come back 'unknown' + // through the same path production takes, not be mistaken for "the key does not exist". + const spawnFailure = (): ReturnType => + runProcessSync({ + program: '/usr/bin/defaults-does-not-exist', + args: ['read'], + stdio: ['ignore', 'ignore', 'ignore'] + }) + expect(spawnFailure).toThrow() + expect(interpretDefaultsRead(spawnFailure)).toBe('unknown') + + const missingKey = runProcessSync({ + program: '/usr/bin/defaults', + args: ['read', throwawayDomain(), PRESS_AND_HOLD_KEY], + stdio: ['ignore', 'ignore', 'ignore'] + }) + // The one exit code the whole design reads as unset; every other outcome is 'unknown'. + expect(missingKey.code).toBe(DEFAULTS_MISSING_EXIT_CODE) + expect(missingKey.timedOut).toBe(false) + expect(interpretDefaultsRead(() => missingKey)).toBe('unset') + }) + + // Skipped as root, where the read-only directory below would still be writable. + it.skipIf(process.getuid?.() === 0)('reports a write the binary refused', () => { + // Why a real refusal: `defaults write` exits non-zero instead of throwing, so a caller that + // only caught exceptions would record 'applied' for a value that never reached the plist. + const readOnly = join(mkdtempSync(join(tmpdir(), 'orca-press-hold-ro-')), 'locked') + mkdirSync(readOnly) + chmodSync(readOnly, 0o500) + try { + expect(writeDomainPressAndHoldPreference(join(readOnly, 'domain'), false)).toBe(false) + } finally { + chmodSync(readOnly, 0o700) + rmSync(readOnly, { recursive: true, force: true }) + } + }) + + it('sees the key disappear again after the user deletes it', () => { + const domain = throwawayDomain() + write(domain, 'false') + execFileSync('/usr/bin/defaults', ['delete', domain, PRESS_AND_HOLD_KEY], { stdio: 'ignore' }) + + expect(readDomainPressAndHoldPreference(domain)).toBe('unset') + // A launch that already decided must not rewrite it, which is what makes the delete stick. + const decided: PressAndHoldRecord = { + version: 1, + decision: 'applied', + domain, + decidedAt: '2026-01-01T00:00:00.000Z' + } + expect(ensureMacPressAndHoldDefault(hostFor(domain, decided))).toBe('already-decided') + expect(readDomainPressAndHoldPreference(domain)).toBe('unset') + }) + } +) diff --git a/src/main/macos-press-and-hold-default.test.ts b/src/main/macos-press-and-hold-default.test.ts new file mode 100644 index 00000000000..c30aed82d10 --- /dev/null +++ b/src/main/macos-press-and-hold-default.test.ts @@ -0,0 +1,286 @@ +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { ProcessResult } from '../shared/child-process/run-process' +import { + ensureMacPressAndHoldDefault, + interpretDefaultsRead, + isOrcaPreferencesDomain, + readBundleIdentifierFromExecutablePath, + type PressAndHoldDecision, + type PressAndHoldHost, + type PressAndHoldRecord +} from './macos-press-and-hold-default' + +const ORCA_DOMAIN = 'com.stablyai.orca' + +type HostOverrides = Partial & { record?: PressAndHoldRecord | null } + +/** A macOS host whose domain has no explicit value — the state every affected user is in. */ +function createHost(overrides: HostOverrides = {}): { + host: PressAndHoldHost + writes: { domain: string; value: boolean }[] + records: PressAndHoldRecord[] +} { + const writes: { domain: string; value: boolean }[] = [] + const records: PressAndHoldRecord[] = [] + let stored = overrides.record ?? null + const host: PressAndHoldHost = { + platform: 'darwin', + resolveBundleIdentifier: () => ORCA_DOMAIN, + readRecord: () => stored, + writeRecord: (record) => { + stored = record + records.push(record) + }, + readDomainPreference: () => 'unset', + writeDomainPreference: (domain, value) => { + writes.push({ domain, value }) + return true + }, + now: () => '2026-08-20T00:00:00.000Z', + ...overrides + } + return { host, writes, records } +} + +function terminalRecord(decision: PressAndHoldDecision): PressAndHoldRecord { + return { version: 1, decision, domain: ORCA_DOMAIN, decidedAt: '2026-01-01T00:00:00.000Z' } +} + +describe('ensureMacPressAndHoldDefault', () => { + it('writes the opt-out when the domain has no explicit value', () => { + const { host, writes, records } = createHost() + + expect(ensureMacPressAndHoldDefault(host)).toBe('applied') + expect(writes).toEqual([{ domain: ORCA_DOMAIN, value: false }]) + expect(records.at(-1)?.decision).toBe('applied') + }) + + describe('platform guard', () => { + for (const platform of ['win32', 'linux'] as const) { + it(`does nothing at all on ${platform}`, () => { + const probe = vi.fn(() => 'unset' as const) + const readRecord = vi.fn(() => null) + const { host, writes, records } = createHost({ + platform, + readDomainPreference: probe, + readRecord + }) + + expect(ensureMacPressAndHoldDefault(host)).toBe('not-macos') + // Why assert the reads too: "inert" means no `defaults` subprocess and no userData I/O, + // not merely no write. + expect(probe).not.toHaveBeenCalled() + expect(readRecord).not.toHaveBeenCalled() + expect(writes).toEqual([]) + expect(records).toEqual([]) + }) + } + }) + + describe('respects an explicit user setting', () => { + it('leaves the domain alone when the user already set a value', () => { + // One arm covers both `true` and `false`: the probe deliberately reports only presence, so + // whichever way the user's value points it is theirs. That the probe really does report + // presence for both is pinned against the real binary in the .defaults-domain test. + const { host, writes, records } = createHost({ readDomainPreference: () => 'set' }) + + expect(ensureMacPressAndHoldDefault(host)).toBe('kept-user-preference') + expect(writes).toEqual([]) + expect(records.at(-1)?.decision).toBe('kept-user-preference') + }) + + it('never re-applies after a launch already decided', () => { + for (const decision of ['applied', 'kept-user-preference'] as const) { + const probe = vi.fn(() => 'unset' as const) + const { host, writes } = createHost({ + record: terminalRecord(decision), + readDomainPreference: probe + }) + + expect(ensureMacPressAndHoldDefault(host)).toBe('already-decided') + expect(probe).not.toHaveBeenCalled() + expect(writes).toEqual([]) + } + }) + + it('stays out of the way after the user deletes the key we wrote', () => { + // The documented way back to the accent picker is `defaults delete`, which returns the + // domain to "unset". Without the recorded decision the next launch would rewrite it. + const { host, writes } = createHost({ + record: terminalRecord('applied'), + readDomainPreference: () => 'unset' + }) + + expect(ensureMacPressAndHoldDefault(host)).toBe('already-decided') + expect(writes).toEqual([]) + }) + }) + + describe('domains we do not own', () => { + it('skips a bare Electron bundle rather than writing into a shared domain', () => { + const probe = vi.fn(() => 'unset' as const) + const { host, writes, records } = createHost({ + resolveBundleIdentifier: () => 'com.github.Electron', + readDomainPreference: probe + }) + + expect(ensureMacPressAndHoldDefault(host)).toBe('foreign-bundle') + expect(probe).not.toHaveBeenCalled() + expect(writes).toEqual([]) + expect(records.at(-1)?.domain).toBe('com.github.Electron') + }) + + it('skips when the bundle identifier cannot be resolved', () => { + const { host, writes } = createHost({ resolveBundleIdentifier: () => null }) + + expect(ensureMacPressAndHoldDefault(host)).toBe('foreign-bundle') + expect(writes).toEqual([]) + }) + + it('accepts Orca and its channel-scoped bundles, and nothing else', () => { + expect(isOrcaPreferencesDomain('com.stablyai.orca')).toBe(true) + expect(isOrcaPreferencesDomain('com.stablyai.orca.dev')).toBe(true) + expect(isOrcaPreferencesDomain('com.github.Electron')).toBe(false) + // Why: a prefix test without the dot would accept a lookalike bundle id. + expect(isOrcaPreferencesDomain('com.stablyai.orcafake')).toBe(false) + }) + }) + + describe('failures leave the decision open', () => { + it('does not write when the probe cannot tell unset from user-set', () => { + const { host, writes, records } = createHost({ readDomainPreference: () => 'unknown' }) + + expect(ensureMacPressAndHoldDefault(host)).toBe('probe-failed') + expect(writes).toEqual([]) + expect(records.at(-1)?.decision).toBe('probe-failed') + }) + + it('retries on the next launch after a failed write', () => { + const { host, records } = createHost({ writeDomainPreference: () => false }) + expect(ensureMacPressAndHoldDefault(host)).toBe('write-failed') + + const retry = createHost({ record: records.at(-1) }) + expect(ensureMacPressAndHoldDefault(retry.host)).toBe('applied') + expect(retry.writes).toEqual([{ domain: ORCA_DOMAIN, value: false }]) + }) + }) + + it('does not rewrite an unchanged non-terminal record', () => { + const first = createHost({ resolveBundleIdentifier: () => 'com.github.Electron' }) + expect(ensureMacPressAndHoldDefault(first.host)).toBe('foreign-bundle') + + const second = createHost({ + record: first.records.at(-1), + resolveBundleIdentifier: () => 'com.github.Electron' + }) + expect(ensureMacPressAndHoldDefault(second.host)).toBe('foreign-bundle') + expect(second.records).toEqual([]) + }) +}) + +/** + * Why here and not only in the .defaults-domain file: that file is macOS-only and CI has no macOS + * runner, so this three-way rule would otherwise be enforced nowhere in CI. These cases need no + * `defaults` binary — the sibling file pins that the real binary produces them. + */ +describe('interpretDefaultsRead', () => { + function probe(overrides: Partial): () => ProcessResult { + return () => ({ code: 0, signal: null, stdout: '', stderr: '', timedOut: false, ...overrides }) + } + + it('reads a clean exit as an explicit value', () => { + expect(interpretDefaultsRead(probe({ code: 0 }))).toBe('set') + }) + + it('reads only "does not exist" as unset', () => { + expect(interpretDefaultsRead(probe({ code: 1 }))).toBe('unset') + }) + + describe('refuses to call a failed probe unset', () => { + // Each of these would otherwise overwrite a value the user deliberately set. + const failures: [string, () => ProcessResult][] = [ + ['killed before it exited', probe({ code: null, signal: 'SIGTERM' })], + ['timed out', probe({ code: null, timedOut: true })], + [ + 'timed out after somehow reporting the missing-key code', + probe({ code: 1, timedOut: true }) + ], + ['exited with some other error', probe({ code: 2 })], + ['exited with a negative status', probe({ code: -1 })], + [ + 'never started at all', + () => { + throw Object.assign(new Error('spawn ENOENT'), { code: 'ENOENT' }) + } + ] + ] + for (const [name, failing] of failures) { + it(name, () => { + expect(interpretDefaultsRead(failing)).toBe('unknown') + }) + } + }) +}) + +describe('readBundleIdentifierFromExecutablePath', () => { + const roots: string[] = [] + + afterEach(() => { + for (const root of roots.splice(0)) { + rmSync(root, { recursive: true, force: true }) + } + }) + + function bundleWithPlist(body: string): string { + const root = mkdtempSync(join(tmpdir(), 'orca-press-hold-')) + roots.push(root) + mkdirSync(join(root, 'Orca.app', 'Contents', 'MacOS'), { recursive: true }) + writeFileSync(join(root, 'Orca.app', 'Contents', 'Info.plist'), body) + return join(root, 'Orca.app', 'Contents', 'MacOS', 'Orca') + } + + it('reads CFBundleIdentifier from the plist beside the executable', () => { + const exe = bundleWithPlist( + '\nCFBundleName\nOrca\n' + + 'CFBundleIdentifier\n\tcom.stablyai.orca\n' + ) + + expect(readBundleIdentifierFromExecutablePath(exe)).toBe('com.stablyai.orca') + }) + + it('returns null when the plist is missing or carries no identifier', () => { + expect(readBundleIdentifierFromExecutablePath('/nonexistent/App.app/Contents/MacOS/App')).toBe( + null + ) + expect(readBundleIdentifierFromExecutablePath(bundleWithPlist(''))).toBe( + null + ) + expect( + readBundleIdentifierFromExecutablePath( + bundleWithPlist('CFBundleIdentifier') + ) + ).toBe(null) + }) +}) + +describe('startup wiring', () => { + const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') + + it('runs before app.whenReady(), which is the last point AppKit could still see it', () => { + const callIndex = source.indexOf( + 'applyMacPressAndHoldDefaultAtStartup(getCanonicalUserDataPath())' + ) + const initDataPathIndex = source.indexOf('initDataPath()') + const readyIndex = source.indexOf('app.whenReady().then(') + + expect(callIndex).toBeGreaterThanOrEqual(0) + expect(readyIndex).toBeGreaterThanOrEqual(0) + // Why after initDataPath: the record lives beside orca-data.json, and the canonical userData + // path is only captured there. + expect(callIndex).toBeGreaterThan(initDataPathIndex) + expect(callIndex).toBeLessThan(readyIndex) + }) +}) diff --git a/src/main/macos-press-and-hold-default.ts b/src/main/macos-press-and-hold-default.ts new file mode 100644 index 00000000000..41b7819f2b2 --- /dev/null +++ b/src/main/macos-press-and-hold-default.ts @@ -0,0 +1,257 @@ +import { mkdirSync, readFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { runProcessSync, type ProcessResult } from '../shared/child-process/run-process' +import { writeFileAtomically } from './codex-accounts/fs-utils' + +/** + * Turns off the macOS accent picker for Orca's own preferences domain (#14746). + * + * macOS routes press-and-hold to the accent popup unless an app opts out via + * `ApplePressAndHoldEnabled`, so holding `j` in vim inserts one character instead of repeating. + * The key is unset by default, which is why every terminal-hosting Mac app ships this opt-out. + * + * Written once and never again: a user who wants the accent picker back sets + * `defaults write com.stablyai.orca ApplePressAndHoldEnabled -bool true` (or deletes the key), and + * the recorded decision below keeps a later launch from overwriting that choice. + * + * A fresh write is assumed to land for the *next* launch, not the current one: it goes out through + * a separate `defaults` process, so this app's own cached copy may not observe it. Whether AppKit + * re-reads sooner is untested, so the conservative assumption is the one encoded here. + * + * If this is ever reverted, delete the key as well — AppKit reads the plist, not this file, so + * removing the code alone leaves press-and-hold disabled forever for everyone who ran an affected + * build, with nothing left in the tree to explain it. + * + * The `defaults`-binary behaviour this relies on is pinned by the sibling test file, which runs + * only on a developer Mac: CI has no macOS test runner, so those tests never execute there. + */ + +export const PRESS_AND_HOLD_KEY = 'ApplePressAndHoldEnabled' +export const PRESS_AND_HOLD_RECORD_FILE = 'macos-press-and-hold-default.json' +export const PRESS_AND_HOLD_RECORD_VERSION = 1 + +/** Why not `systemPreferences.getUserDefault`: it reads through the whole NSUserDefaults search + * list and is typed non-nullable, so an unset key and an explicit `false` both come back `false` — + * and the system default is unset, so it reports `false` on a Mac where press-and-hold is on. + * `defaults read ` is domain-scoped and exits 1 when the key is absent. */ +const DEFAULTS_BINARY = '/usr/bin/defaults' +/** A hang cutoff, not a latency budget: the probe measures ~6ms, and cutting a slow-but-working + * `defaults` short costs the user the feature for that launch. The shared 30s default is what + * must not apply — this runs before `ready`, so a 30s block is a hung app. */ +const DEFAULTS_TIMEOUT_MS = 5_000 +/** Why: `defaults` exits 1 for "does not exist"; anything else means the probe itself failed. */ +const DEFAULTS_MISSING_STATUS = 1 + +const ORCA_BUNDLE_ID = 'com.stablyai.orca' + +export type PressAndHoldDecision = + /** Not macOS — nothing is read or written. */ + | 'not-macos' + /** A previous launch already decided; the domain is never touched again. */ + | 'already-decided' + /** The running bundle is not Orca's (e.g. a bare `Electron.app`), whose domain we do not own. */ + | 'foreign-bundle' + /** `defaults read` could not answer, so we cannot tell an unset key from a user's choice. */ + | 'probe-failed' + /** `defaults write` failed. */ + | 'write-failed' + /** The domain already carries an explicit value; the user's choice stands. */ + | 'kept-user-preference' + /** The key was unset and we wrote `false`. */ + | 'applied' + +/** Decisions about the domain itself. Anything else is a condition that can change by next launch. */ +const TERMINAL_DECISIONS = new Set(['applied', 'kept-user-preference']) + +export type PressAndHoldRecord = { + version: number + decision: PressAndHoldDecision + domain: string | null + decidedAt: string +} + +export type PressAndHoldHost = { + platform: NodeJS.Platform + resolveBundleIdentifier: () => string | null + readRecord: () => PressAndHoldRecord | null + writeRecord: (record: PressAndHoldRecord) => void + readDomainPreference: (domain: string) => 'set' | 'unset' | 'unknown' + writeDomainPreference: (domain: string, value: boolean) => boolean + now: () => string +} + +/** Only Orca's own bundle: an unpackaged run is `com.github.Electron`, shared with every other + * unpackaged Electron app on the machine. */ +export function isOrcaPreferencesDomain(domain: string): boolean { + return domain === ORCA_BUNDLE_ID || domain.startsWith(`${ORCA_BUNDLE_ID}.`) +} + +/** `/Contents/MacOS/` → `/Contents/Info.plist`. */ +export function readBundleIdentifierFromExecutablePath(execPath: string): string | null { + try { + const plist = readFileSync(join(dirname(dirname(execPath)), 'Info.plist'), 'utf8') + const match = /CFBundleIdentifier<\/key>\s*([^<]*)<\/string>/.exec(plist) + const identifier = match?.[1]?.trim() + return identifier ? identifier : null + } catch { + return null + } +} + +/** + * Why this distinction carries the whole design: only a real "does not exist" answer may be read as + * unset. A killed or timed-out probe never answered, and calling that unset would let a broken + * probe overwrite a value the user chose. + */ +export function interpretDefaultsRead(probe: () => ProcessResult): 'set' | 'unset' | 'unknown' { + let result: ProcessResult + try { + result = probe() + } catch { + // `runProcessSync` throws only when the child never started, which answers nothing at all. + return 'unknown' + } + // Why before the code: a timeout kills mid-run, so any exit it leaves behind is not an answer. + if (result.timedOut) { + return 'unknown' + } + if (result.code === 0) { + return 'set' + } + return result.code === DEFAULTS_MISSING_STATUS ? 'unset' : 'unknown' +} + +export function readDomainPressAndHoldPreference(domain: string): 'set' | 'unset' | 'unknown' { + // Why no captured output: the exit code is the whole answer, and reading only presence is what + // makes an explicit `true` and an explicit `false` both count as the user's. + return interpretDefaultsRead(() => + runProcessSync({ + program: DEFAULTS_BINARY, + args: ['read', domain, PRESS_AND_HOLD_KEY], + timeoutMs: DEFAULTS_TIMEOUT_MS, + stdio: ['ignore', 'ignore', 'ignore'] + }) + ) +} + +export function writeDomainPressAndHoldPreference(domain: string, value: boolean): boolean { + try { + // Why the code check: a non-zero `defaults write` returns rather than throwing, so treating a + // completed run as success would record 'applied' for a write that never landed. + return ( + runProcessSync({ + program: DEFAULTS_BINARY, + args: ['write', domain, PRESS_AND_HOLD_KEY, '-bool', value ? 'true' : 'false'], + timeoutMs: DEFAULTS_TIMEOUT_MS, + stdio: ['ignore', 'ignore', 'ignore'] + }).code === 0 + ) + } catch { + return false + } +} + +export function pressAndHoldRecordPath(userDataPath: string): string { + return join(userDataPath, PRESS_AND_HOLD_RECORD_FILE) +} + +function parseRecord(raw: string): PressAndHoldRecord | null { + const parsed = JSON.parse(raw) as Partial + if (parsed.version !== PRESS_AND_HOLD_RECORD_VERSION || typeof parsed.decision !== 'string') { + return null + } + return { + version: PRESS_AND_HOLD_RECORD_VERSION, + decision: parsed.decision as PressAndHoldDecision, + domain: typeof parsed.domain === 'string' ? parsed.domain : null, + decidedAt: typeof parsed.decidedAt === 'string' ? parsed.decidedAt : '' + } +} + +/** + * Apply Orca's press-and-hold default at most once, leaving any explicit user value alone. + * + * Returns the decision so startup can log it; the same value is persisted for support triage. + */ +export function ensureMacPressAndHoldDefault(host: PressAndHoldHost): PressAndHoldDecision { + // Why first and without touching disk: Windows and Linux must do no work at all here. + if (host.platform !== 'darwin') { + return 'not-macos' + } + + const previous = host.readRecord() + if (previous && TERMINAL_DECISIONS.has(previous.decision)) { + return 'already-decided' + } + + const record = (decision: PressAndHoldDecision, domain: string | null): PressAndHoldDecision => { + // Why skip an unchanged rewrite: the non-terminal decisions are re-evaluated every launch. + if (!previous || previous.decision !== decision || previous.domain !== domain) { + host.writeRecord({ + version: PRESS_AND_HOLD_RECORD_VERSION, + decision, + domain, + decidedAt: host.now() + }) + } + return decision + } + + const domain = host.resolveBundleIdentifier() + if (!domain || !isOrcaPreferencesDomain(domain)) { + return record('foreign-bundle', domain) + } + + const existing = host.readDomainPreference(domain) + if (existing === 'unknown') { + return record('probe-failed', domain) + } + if (existing === 'set') { + return record('kept-user-preference', domain) + } + if (!host.writeDomainPreference(domain, false)) { + return record('write-failed', domain) + } + return record('applied', domain) +} + +/** Why log: silently rewriting a macOS default for the user's login account should leave a trace, + * and the failure paths are otherwise invisible. The quiet decisions are the steady state. */ +const REPORTED_DECISIONS: Partial> = { + applied: `set ${PRESS_AND_HOLD_KEY}=false so held keys repeat; takes effect next launch`, + 'probe-failed': `could not read ${PRESS_AND_HOLD_KEY}; leaving it alone`, + 'write-failed': `could not write ${PRESS_AND_HOLD_KEY}; held keys will not repeat` +} + +/** Wires {@link ensureMacPressAndHoldDefault} to the real bundle, `defaults`, and userData. */ +export function applyMacPressAndHoldDefaultAtStartup(userDataPath: string): PressAndHoldDecision { + const recordPath = pressAndHoldRecordPath(userDataPath) + const decision = ensureMacPressAndHoldDefault({ + platform: process.platform, + resolveBundleIdentifier: () => readBundleIdentifierFromExecutablePath(process.execPath), + readRecord: () => { + try { + return parseRecord(readFileSync(recordPath, 'utf8')) + } catch { + return null + } + }, + writeRecord: (record) => { + try { + // Why: this runs before `ready`, and Electron has not necessarily created userData yet. + mkdirSync(userDataPath, { recursive: true }) + writeFileAtomically(recordPath, `${JSON.stringify(record, null, 2)}\n`) + } catch { + // Best-effort: an unwritten record only costs one repeated probe next launch. + } + }, + readDomainPreference: readDomainPressAndHoldPreference, + writeDomainPreference: writeDomainPressAndHoldPreference, + now: () => new Date().toISOString() + }) + const reported = REPORTED_DECISIONS[decision] + if (reported) { + console.log(`[press-and-hold] ${reported}`) + } + return decision +} diff --git a/tests/e2e/macos-press-and-hold-startup.spec.ts b/tests/e2e/macos-press-and-hold-startup.spec.ts new file mode 100644 index 00000000000..5eb0fba388d --- /dev/null +++ b/tests/e2e/macos-press-and-hold-startup.spec.ts @@ -0,0 +1,96 @@ +/** + * Startup wiring for the macOS press-and-hold opt-out (#14746). + * + * What this can prove: the routine runs during startup, resolves the *real* running bundle's + * identifier from its Info.plist, records a decision, and stays completely inert off macOS. + * + * What it cannot prove: that held keys repeat. Key repeat versus the accent picker is decided by + * AppKit from a preference this process does not re-read after writing, so observing it needs a relaunch and a + * physically held key — neither of which Playwright's synthesized CDP key events go through. + * The preference semantics the fix depends on are pinned against the real `/usr/bin/defaults` in + * src/main/macos-press-and-hold-default.defaults-domain.test.ts. + * + * The E2E app runs unpackaged, so its bundle identifier is Electron's own rather than Orca's. That + * is the ownership guard's case, and asserting it here is the point: `defaults` resolves the user's + * real home regardless of the harness's HOME isolation, so a run that wrote anything would be + * writing into a domain shared with every other unpackaged Electron app on the machine. + */ +import type { ElectronApplication } from '@stablyai/playwright-test' +import { expect, test } from './helpers/orca-app' + +test.use({ seedTestRepo: false }) + +type StartupState = { + recordRaw: string | null + bundleIdentifier: string | null + execPath: string +} + +async function readPressAndHoldStartupState( + electronApp: ElectronApplication +): Promise { + return electronApp.evaluate(({ app }) => { + const nodeFs = process.getBuiltinModule('node:fs') + const nodePath = process.getBuiltinModule('node:path') + const recordPath = nodePath.join(app.getPath('userData'), 'macos-press-and-hold-default.json') + const read = (file: string): string | null => { + try { + return nodeFs.readFileSync(file, 'utf8') + } catch { + return null + } + } + // Why resolved here rather than hardcoded: this is the independent half of the cross-check — + // the spec derives the identifier from the launched bundle without reusing product code. + const plist = read( + nodePath.join(nodePath.dirname(nodePath.dirname(process.execPath)), 'Info.plist') + ) + const match = plist + ? /CFBundleIdentifier<\/key>\s*([^<]*)<\/string>/.exec(plist) + : null + return { + recordRaw: read(recordPath), + bundleIdentifier: match ? match[1].trim() : null, + execPath: process.execPath + } + }) +} + +test.describe('macOS press-and-hold default', () => { + test.skip(process.platform !== 'darwin', 'macOS-only startup routine') + + test('records a decision for the bundle it is actually running from', async ({ electronApp }) => { + const state = await readPressAndHoldStartupState(electronApp) + + expect(state.recordRaw, 'startup must leave a decision record').not.toBeNull() + const record = JSON.parse(state.recordRaw!) as { + version: number + decision: string + domain: string | null + } + + expect(record.version).toBe(1) + expect(state.bundleIdentifier).not.toBeNull() + expect(record.domain).toBe(state.bundleIdentifier) + + const ownsDomain = + record.domain === 'com.stablyai.orca' || record.domain!.startsWith('com.stablyai.orca.') + if (ownsDomain) { + // A packaged or dev-identity bundle: the write path is live and must have settled. + expect(['applied', 'kept-user-preference']).toContain(record.decision) + } else { + // The unpackaged harness. Anything but a refusal means Orca wrote into a foreign domain. + expect(record.decision).toBe('foreign-bundle') + } + }) +}) + +test.describe('off macOS', () => { + test.skip(process.platform === 'darwin', 'covers the non-macOS branch') + + test('writes nothing at all', async ({ electronApp }) => { + const state = await readPressAndHoldStartupState(electronApp) + + expect(state.recordRaw).toBeNull() + }) +}) From 808fc3563dbfb5d4a369d1d874cf2c94e86236bb Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 27 Aug 2026 21:27:52 +0000 Subject: [PATCH 08/22] Update README downloads badge --- docs/assets/readme-downloads.svg | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/assets/readme-downloads.svg b/docs/assets/readme-downloads.svg index d604b83bf97..49201fa15cc 100644 --- a/docs/assets/readme-downloads.svg +++ b/docs/assets/readme-downloads.svg @@ -1,5 +1,5 @@ - - downloads: 30m + + downloads: 31m @@ -15,7 +15,7 @@ downloads downloads - 30m - 30m + 31m + 31m From 249d93bc5d2fd3b04581aae9916afc84bc787c8b Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Thu, 27 Aug 2026 14:34:21 -0700 Subject: [PATCH 09/22] feat(browser-preview): render remote HTML docs locally over an orca-preview scheme (STA-5557) (#16679) --- config/reliability-gates.jsonc | 615 ++++++++++++--- electron.vite.config.ts | 1 + .../browser-manager-annotation-bridge.test.ts | 210 +++++ .../browser-manager-guest-lifecycle.test.ts | 98 +++ ...owser-manager-guest-policy-profile.test.ts | 250 ++++++ .../browser-manager-viewport-override.test.ts | 29 - src/main/browser/browser-manager.ts | 106 ++- ...browser-session-partition-policies.test.ts | 185 +++++ .../browser-session-partition-policies.ts | 33 +- .../doc-preview-download-block-notice.test.ts | 107 +++ .../doc-preview-download-block-notice.ts | 34 + .../doc-preview-failure-notice.test.ts | 70 ++ .../browser/doc-preview-failure-notice.ts | 34 + .../browser/doc-preview-file-reader.test.ts | 333 ++++++++ src/main/browser/doc-preview-file-reader.ts | 198 +++++ .../doc-preview-grant-registry.test.ts | 164 ++++ .../browser/doc-preview-grant-registry.ts | 208 +++++ .../browser/doc-preview-guest-policy.test.ts | 550 +++++++++++++ src/main/browser/doc-preview-guest-policy.ts | 247 ++++++ src/main/browser/doc-preview-protocol.test.ts | 336 ++++++++ src/main/browser/doc-preview-protocol.ts | 144 ++++ ...ffscreen-browser-backend-lifecycle.test.ts | 85 +- src/main/browser/offscreen-browser-backend.ts | 44 +- src/main/index.ts | 11 + src/main/ipc/browser-grab-ipc.ts | 19 + src/main/ipc/browser-guest-view-ipc.ts | 26 +- ...browser-preview-tool-authorization.test.ts | 510 ++++++++++++ .../ipc/browser-tab-registration-wait.test.ts | 108 +++ src/main/ipc/browser-tab-registration-wait.ts | 13 + src/main/ipc/browser.test.ts | 54 +- src/main/ipc/browser.ts | 8 + src/main/ipc/doc-preview-grant-ipc.test.ts | 159 ++++ src/main/ipc/doc-preview-grant-ipc.ts | 95 +++ .../main-window-webview-security.test.ts | 261 ++++++- .../window/main-window-webview-security.ts | 61 +- src/preload/api-types.ts | 2 + src/preload/api/doc-preview-api.ts | 29 + .../doc-preview-link-interception.test.ts | 269 +++++++ src/preload/doc-preview-link-interception.ts | 152 ++++ src/preload/doc-preview-link.ts | 15 + src/preload/index.ts | 27 + .../ClientHostedBrowserPagePane.tsx | 28 +- .../browser-guest-annotate-overlays.tsx | 189 +++++ .../annotate/browser-page-grab-action.ts | 8 +- .../guest-annotation-viewport-bridge.ts | 43 ++ .../use-browser-page-grab-annotations.ts | 16 +- .../assemble-chrome/BrowserAddressBar.tsx | 4 +- .../browser-chrome-address-slot.ts | 13 + .../browser-chrome-toolbar.tsx | 206 +++++ .../browser-navigation-control-row.test.tsx | 66 +- .../browser-navigation-control-row.tsx | 61 +- .../assemble-chrome/browser-page-toolbar.tsx | 282 ++----- .../browser-page-viewport-overlays.tsx | 163 +--- .../browser-reload-control.tsx | 95 +++ .../browser-workspace-pane.tsx | 49 +- .../use-browser-page-webview-lifecycle.ts | 29 +- .../host-guest/use-guest-drag-passthrough.ts | 32 + .../browser-address-bar-navigation.test.ts | 27 + .../browser-address-bar-navigation.ts | 24 +- .../remote-browser-page-toolbar.tsx | 24 +- .../use-remote-browser-page-navigation.ts | 2 +- .../HtmlDocPreview.failure-message.test.tsx | 342 ++++++++ .../HtmlDocPreview.toolbar.test.tsx | 582 ++++++++++++++ .../workspace-doc/HtmlDocPreview.tsx | 413 ++++++++++ .../doc-preview-document-actions.test.ts | 206 +++++ .../doc-preview-document-actions.ts | 97 +++ .../doc-preview-document-chip.tsx | 68 ++ .../doc-preview-document-identity.test.ts | 67 ++ .../doc-preview-document-identity.ts | 38 + .../doc-preview-failure-messages.ts | 70 ++ .../doc-preview-overflow-menu.tsx | 76 ++ .../workspace-doc/doc-preview-toolbar.tsx | 118 +++ .../doc-preview-webview-history.ts | 80 ++ .../use-doc-preview-guest-tools.test.ts | 134 ++++ .../use-doc-preview-guest-tools.ts | 103 +++ .../workspace-doc-page-pane.test.tsx | 109 +++ .../workspace-doc/workspace-doc-page-pane.tsx | 60 ++ .../editor/EditorPanelShell.header.test.tsx | 124 +++ .../components/editor/EditorPanelShell.tsx | 3 +- .../src/components/editor/editor-header.ts | 5 + .../tab-strip-pointer-activation.test.tsx | 38 + .../tab-bar/tab-strip-pointer-activation.ts | 20 +- .../TerminalLinkActionPopover.tsx | 6 +- .../terminal-file-link-actions.test.ts | 62 ++ .../terminal-file-link-actions.ts | 66 +- .../terminal-file-open-routing.ts | 48 +- ...minal-link-remote-runtime-ssh-open.test.ts | 64 +- .../terminal-remote-file-download-open.ts | 37 + .../ipc-events/browser-state-ipc-bridge.ts | 29 + .../doc-preview-external-link-bridge.test.ts | 92 +++ .../src/hooks/useIpcEvents-lifecycle.test.ts | 2 + src/renderer/src/i18n/locales/en.json | 39 +- src/renderer/src/i18n/locales/ko.json | 77 +- src/renderer/src/i18n/locales/zh.json | 85 +- .../lib/browser-page-palette-activation.ts | 21 +- .../lib/browser-workspace-tab-activation.ts | 30 + .../src/lib/doc-preview-grants.test.ts | 169 ++++ src/renderer/src/lib/doc-preview-grants.ts | 86 +++ .../src/lib/execution-host-display-label.ts | 61 ++ .../file-preview-action-visibility.test.tsx | 85 ++ .../file-preview-capability-cleanup.test.ts | 91 --- src/renderer/src/lib/file-preview.test.ts | 409 ++++++---- src/renderer/src/lib/file-preview.ts | 255 +++--- .../src/lib/workspace-file-host-routing.ts | 50 ++ ...ync-runtime-graph-editor-diff-tabs.test.ts | 180 +++++ .../src/runtime/sync-runtime-graph.ts | 18 +- .../browser-workspace-doc-location.test.ts | 412 ++++++++++ src/renderer/src/store/slices/browser.ts | 125 ++- .../src/store/slices/tabs-hydration.test.ts | 63 ++ .../src/store/slices/tabs-hydration.ts | 9 + src/shared/browser-page-doc-location.ts | 27 + src/shared/browser-workspace-types.ts | 17 + src/shared/doc-preview-scheme.test.ts | 45 ++ src/shared/doc-preview-scheme.ts | 105 +++ .../workspace-session-browser-schema.ts | 20 +- .../helpers/paired-client-window-reveal.ts | 31 + .../helpers/paired-html-preview-inventory.ts | 341 +++++--- .../paired-remote-html-browser-focus.spec.ts | 459 ----------- ...d-remote-html-preview-local-render.spec.ts | 731 ++++++++++++++++++ 119 files changed, 12735 insertions(+), 1726 deletions(-) create mode 100644 src/main/browser/browser-manager-annotation-bridge.test.ts create mode 100644 src/main/browser/browser-manager-guest-policy-profile.test.ts create mode 100644 src/main/browser/browser-session-partition-policies.test.ts create mode 100644 src/main/browser/doc-preview-download-block-notice.test.ts create mode 100644 src/main/browser/doc-preview-download-block-notice.ts create mode 100644 src/main/browser/doc-preview-failure-notice.test.ts create mode 100644 src/main/browser/doc-preview-failure-notice.ts create mode 100644 src/main/browser/doc-preview-file-reader.test.ts create mode 100644 src/main/browser/doc-preview-file-reader.ts create mode 100644 src/main/browser/doc-preview-grant-registry.test.ts create mode 100644 src/main/browser/doc-preview-grant-registry.ts create mode 100644 src/main/browser/doc-preview-guest-policy.test.ts create mode 100644 src/main/browser/doc-preview-guest-policy.ts create mode 100644 src/main/browser/doc-preview-protocol.test.ts create mode 100644 src/main/browser/doc-preview-protocol.ts create mode 100644 src/main/ipc/browser-preview-tool-authorization.test.ts create mode 100644 src/main/ipc/browser-tab-registration-wait.test.ts create mode 100644 src/main/ipc/doc-preview-grant-ipc.test.ts create mode 100644 src/main/ipc/doc-preview-grant-ipc.ts create mode 100644 src/preload/api/doc-preview-api.ts create mode 100644 src/preload/doc-preview-link-interception.test.ts create mode 100644 src/preload/doc-preview-link-interception.ts create mode 100644 src/preload/doc-preview-link.ts create mode 100644 src/renderer/src/components/browser-pane/annotate/browser-guest-annotate-overlays.tsx create mode 100644 src/renderer/src/components/browser-pane/annotate/guest-annotation-viewport-bridge.ts create mode 100644 src/renderer/src/components/browser-pane/assemble-chrome/browser-chrome-address-slot.ts create mode 100644 src/renderer/src/components/browser-pane/assemble-chrome/browser-chrome-toolbar.tsx create mode 100644 src/renderer/src/components/browser-pane/assemble-chrome/browser-reload-control.tsx create mode 100644 src/renderer/src/components/browser-pane/host-guest/use-guest-drag-passthrough.ts create mode 100644 src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.failure-message.test.tsx create mode 100644 src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.toolbar.test.tsx create mode 100644 src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.tsx create mode 100644 src/renderer/src/components/browser-pane/workspace-doc/doc-preview-document-actions.test.ts create mode 100644 src/renderer/src/components/browser-pane/workspace-doc/doc-preview-document-actions.ts create mode 100644 src/renderer/src/components/browser-pane/workspace-doc/doc-preview-document-chip.tsx create mode 100644 src/renderer/src/components/browser-pane/workspace-doc/doc-preview-document-identity.test.ts create mode 100644 src/renderer/src/components/browser-pane/workspace-doc/doc-preview-document-identity.ts create mode 100644 src/renderer/src/components/browser-pane/workspace-doc/doc-preview-failure-messages.ts create mode 100644 src/renderer/src/components/browser-pane/workspace-doc/doc-preview-overflow-menu.tsx create mode 100644 src/renderer/src/components/browser-pane/workspace-doc/doc-preview-toolbar.tsx create mode 100644 src/renderer/src/components/browser-pane/workspace-doc/doc-preview-webview-history.ts create mode 100644 src/renderer/src/components/browser-pane/workspace-doc/use-doc-preview-guest-tools.test.ts create mode 100644 src/renderer/src/components/browser-pane/workspace-doc/use-doc-preview-guest-tools.ts create mode 100644 src/renderer/src/components/browser-pane/workspace-doc/workspace-doc-page-pane.test.tsx create mode 100644 src/renderer/src/components/browser-pane/workspace-doc/workspace-doc-page-pane.tsx create mode 100644 src/renderer/src/components/editor/EditorPanelShell.header.test.tsx create mode 100644 src/renderer/src/components/terminal-pane/terminal-remote-file-download-open.ts create mode 100644 src/renderer/src/hooks/ipc-events/doc-preview-external-link-bridge.test.ts create mode 100644 src/renderer/src/lib/browser-workspace-tab-activation.ts create mode 100644 src/renderer/src/lib/doc-preview-grants.test.ts create mode 100644 src/renderer/src/lib/doc-preview-grants.ts create mode 100644 src/renderer/src/lib/execution-host-display-label.ts create mode 100644 src/renderer/src/lib/file-preview-action-visibility.test.tsx delete mode 100644 src/renderer/src/lib/file-preview-capability-cleanup.test.ts create mode 100644 src/renderer/src/lib/workspace-file-host-routing.ts create mode 100644 src/renderer/src/store/slices/browser-workspace-doc-location.test.ts create mode 100644 src/shared/browser-page-doc-location.ts create mode 100644 src/shared/doc-preview-scheme.test.ts create mode 100644 src/shared/doc-preview-scheme.ts delete mode 100644 tests/e2e/paired-remote-html-browser-focus.spec.ts create mode 100644 tests/e2e/paired-remote-html-preview-local-render.spec.ts diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index ac37e3760b9..68dce578bc0 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -2817,16 +2817,16 @@ }, { "id": "browser-session.remote-html-preview-ownership", - "title": "Paired HTML previews keep one host page and one stable client split", + "title": "Paired HTML previews render locally as document browser tabs and create no host browser page", "maturity": "experimental", "protection": "partial", "owner": "browser-runtime", "layer": "renderer-paired-runtime-reconciliation", "surfaces": [ - "paired remote HTML file preview", - "background preview creation and explicit browser activation", - "client-only split placement", - "capability-unknown and mixed-version preview rejection cleanup", + "paired HTML file preview served locally over the orca-preview scheme", + "preview split placement that leaves editor focus alone", + "grant-scoped document reads, including truncation and out-of-worktree refusal", + "capability-unknown and mixed-version browser-creation rejection cleanup", "post-create reconciliation failure and compensating rollback", "remote browser close convergence" ], @@ -2834,8 +2834,9 @@ "providers": ["remote-runtime"], "coveredPlatforms": ["macos"], "coveredProviders": ["remote-runtime"], - "coverageNotes": "A headed macOS desktop host and separate paired client exercise the Explorer eye-button path with a real remote file URL, host browser DOM, renderer split layout, background preview creation, explicit browser activation, browser close, forced capability rejection after caller preflight, a forced post-create reconciliation timeout, and navigation held beyond the client create deadline. The same reconciliation fault oracles pass against a live headless server. Unit contracts cover absent, unknown, and mixed-version capabilities, visible rejection, empty-split cleanup, owning-runtime routing, canonical preallocated page identity, navigation-independent acknowledgement, exact client materialization, confirmed rollback, ambiguous cleanup, and teardown. Linux and Windows live journeys remain gaps.", + "coverageNotes": "A macOS desktop host and a separate paired Electron client exercise the Explorer preview action end to end: the document renders inside the client-local orca-preview guest, the client gains exactly one browser workspace and it is the document one, the host's own page registry and tab snapshot gain nothing, the split does not take focus from the source editor, an explicit click activates the preview, and closing it with its X leaves the editor and terminal intact. Both host oracles are read in a run where an ordinary URL browser tab from the same client does arrive in them, because a host that cannot project browser pages at all would otherwise answer the absence question the same way. A second journey quits that client with a document tab open and relaunches it on the same profile, which is the restore the user's original report turned on: the same workspace row comes back blank and named by the document, renders again, and does so over a grant URL that differs from the one that was quit. The same journey pins the guest URL to the orca-preview scheme, so a regression back to file:// or to a host browser page is visible. The rewritten journey caught a live gap the old one could not see: since previews became browser pages they had no chrome to hand focus to their guest, so main refused every reported link click as coming from an unfocused guest and the one route out of a preview was dead. Main's own log answered it — registered true, grant live, focused false — after the embedder reported the anchor really taking six real presses. The pane now hands focus over when it is the reader's surface, and the journey routes. The preview's own components moved out of the editor folder into browser-pane/workspace-doc/ with this change, since a preview is a browser pane and no longer an editor one; their i18n keys keep the historical auto.components.editor.HtmlDocPreview prefix rather than dropping thirty translated strings across four locales for a tidier key path. Seven gate commands named those files by their old paths and were re-issued at the new ones, and the nine dated runs behind them were retired rather than left naming files that are no longer there, on the same principle this gate already applied twice: vitest treats a missing named file as a filter and exits 0, so such a command reports green while silently running only the half that still exists. Each retired record's finding is carried into the replacement run recorded below it, including the red half of the entry/subresource pair, which was re-measured by re-applying its mutant at the new path rather than being dropped. The fence over the page-management channels was decided channel by channel before it was built, and that decision record is: of the 23 channels that can name a page, 6 are allowed for a workspace-document page — the tool channels the reader drives, which reach it through the one authority that reads both halves of the registry — 9 refuse (download accept and cancel, devtools, certificate proceed, client page metadata publish, viewport override, WebAuthn account response, registration probe, registration repair), 3 cannot name a page at all because they take a profile or a worktree instead, and the rest are the registration and activation doors, refused on membership. What shipped is not the per-channel guard that classification implies: eleven call sites read the browsing map directly, so a guard per channel is exactly the caller-flag seam that has to be remembered at every future call site. The registry was split in two instead, which makes the classification structural — a document guest is absent from the browsing map, so every refusing channel refuses by absence rather than by a guard, and a channel added later inherits the refusal without being classified at all. Unit contracts cover grant scoping and path containment, per-owner read routing, refusal of a truncated or over-cap read, the old-server binary degradation, scheme admission on will-attach-webview, the guest navigation policy, the plan layer's local/SSH/paired/out-of-worktree routing, and the distinct sentence the reader sees for each failure reason. The paired browser-creation surfaces this gate still owns — capability rejection cleanup, canonical page identity, reconciliation rollback, close convergence — keep their existing headed and headless journeys. Linux and Windows live journeys remain gaps. A refused subresource is a notice on a rendered document rather than a failure panel, since only the entry document's failure leaves the reader with nothing; the entry document is served as text by every owner and cannot be refused for its format at all. Grants die with the renderer document that minted them, at window creation and on every reload, because the renderer holds the only record of which preview owns which grant. The mint and revoke channels answer only the trusted renderer, and a preview tab is never republished to mobile clients, which have no grant to read it with. External URLs leave the preview only via a trusted user click on an anchor while the preview is focused; script-driven navigation and popups reach nothing. The guest's navigation sinks are deny-only and route no URL at all, and the guest carries a preview-only preload, pinned by will-attach-webview and denied to every other attach path, whose capture-phase click handler reports an anchor's URL to main; main answers only a report from a preview guest with a live bound grant that is the focused contents, and only for http(s). Two earlier reader-facing records from 2026-08-26, which measured a shape where a subresource failure was filtered out and the panel still carried an unsupported-asset sentence, were retired with that shape rather than left contradicting the 10/10 runs of the command they share. Two more were retired on the same principle when the external-link design changed: a record showing that removing a recent-input gate let the unattended document open two browser tabs, and a record showing that dropping the webview's allowpopups attribute left a target=_blank press with nothing to route. Both measured machinery that no longer exists — the input gate could not tell a script's navigation from the click it followed, which is the case now recorded below, and the preload intercepts the press before Chromium considers a popup at all, so allowpopups was removed. WebRTC is fenced by the guest's IP-handling policy alone: this Chromium answers a webrtc content-security-policy directive as unrecognized and gathers candidates anyway, so the directive is deliberately absent from the served policy and a unit assertion keeps it out. A standalone Electron probe on the same version gathered 7 candidates including a STUN reflexive one without the policy and 0 with it. Two command paths this gate used to carry no longer exist, and their structured evidence was retired with them rather than left naming files that are no longer there: vitest treats a missing named file as a filter and exits 0, so such a command reports green while silently running only the half that still exists. The first is src/renderer/src/lib/file-preview-capability-cleanup.test.ts, deleted in STA-5557 when HTML previews stopped creating paired browser tabs: on 2026-08-12 its five-assertion STA-4063 oracle ran red 5/5 on scan e4c278eb52 and on main bc2e30000b, partially green 2/5 on pre-fix PR #14100, and green 5/5 on candidate 14b5877a75; the surviving half of that oracle is web-runtime-browser-capability-cleanup.test.ts, still commanded above. The second is tests/e2e/paired-remote-html-browser-focus.spec.ts, renamed in STA-5557 to paired-remote-html-preview-local-render.spec.ts because the journey no longer proves anything about browser focus: its 2026-08-12 runs established the owning-runtime routing and no-focus-steal behavior of the then-current browser-tab preview (red on main 346e59c879 and a90a18d43d, green on candidates d05217c40d and b7de9c0961, red again with only the side-preview focus or routing seam disabled), and its 2026-08-26 runs are recorded below under the current path. An adversarial pass after the journey landed found four coverage gaps rather than shipping defects, and all four are pinned here. The page url had a third door: the navigation commit wrote whatever url was reported, unfenced, and while nothing reaches it for a document page today it sat one report away from giving a document page a URL that persistence, the publish boundary and the address bar all read. The group projection was the load-bearing half of the mobile publish boundary that no test had: removing it alone left the whole suite green, because no test in it had tab groups, while the phone payload gained a group whose order and active tab named a document tab it was never sent. The focus handoff was covered only in the narrowing direction, so widening it — dropping the page-activity check, or either half of the reader-surface check — survived both suites. And the handoff itself was offered once and never again, so another app taking the front left the preview's one route out shut until the pane remounted, which is a desktop gap rather than a test-only one.", "motivatingLinks": [ + "https://linear.app/stably/issue/STA-5557/remote-html-preview-render-workspace-files-locally-over-the-file", "https://linear.app/stably/issue/STA-3872/unable-to-open-html-files-in-remote-server", "https://linear.app/stably/issue/STA-4025/p1-browser-create-reports-success-when-tab-reconcile-fails-after-pr", "https://linear.app/stably/issue/STA-4063/p1-paired-html-preview-rejects-outside-cleanup-after-pr-13909", @@ -2843,19 +2844,35 @@ "https://github.com/stablyai/orca/pull/14363", "https://github.com/stablyai/orca/pull/13876" ], - "invariant": "Opening one HTML preview from a paired client succeeds only after exactly one browser page on the worktree-owning runtime materializes as exactly one client browser workspace and unified tab in the requested group. Owner-pinned creation returns the canonical host page identity before navigation readiness; delayed navigation cannot turn a created page into an unidentifiable failure or a duplicate retry. Capability rejection before host mutation must preserve the original error, issue no RPC, surface a failure toast, and remove only a caller-declared newly-created empty split. Post-create reconciliation failure requires exact rollback; ambiguous rollback rejects without local fallback. Preview focus and close preserve the source editor and terminal.", - "oracle": "Run one unchanged contract oracle for direct create and side-preview callers with absent status, unknown capabilities, and a mixed-version host. Require the original unsupported error or visible toast, zero RPCs, and no retained new split. Run it on the scan target, latest main, candidate, and candidate with the cleanup boundary disabled. In headed paired Electron, arm capability rejection after the caller preflight, invoke the visible HTML side-preview action, require the failure toast, and compare exact host/client browser, editor, terminal, and group inventories to baseline. Hold a real navigation response beyond the 15-second client deadline after host creation; require the first RPC to return the exact host inventory page ID, one host page, and no retry. Repeat reconciliation faults against headless serve. Retain the separate exact-page reconciliation rollback oracle.", + "invariant": "Opening one HTML preview from a paired client renders the workspace document in exactly one client-local browser tab, located by that document and served over the orca-preview scheme. The client gains exactly that one browser workspace and it is the document one — blank where a URL page carries a URL, named by the document, with the chip naming the file — while the host gains no browser page at all, neither in its own page registry nor in the tab snapshot its clients publish into. The preview occupies its own split without taking focus from the source editor; an explicit click activates it, and closing it removes only the preview. Following a document from a file link is the other half of that switch and does move the reader to it, tab group included, whether the preview is new or already open, because opening a file is a request to look at it. A document tab quit with the client comes back as the same row on a grant the relaunched client mints afresh. A preview is named by the browser page it is open in, not by a namespace of its own, and the page registry has two halves: a workspace-document guest is registered in its own map and is absent from the browsing one entirely. That absence is the fence. Page, session and profile management, agent tab enumeration and command targeting, download routing and certificate attribution all read the browsing map directly, in more places than a per-channel guard could be remembered in, so none of them can name a document page and none of them carries a guard. Browser tools the reader drives (element grab, hover describe, selection capture, the annotation viewport bridge) are the one operation that legitimately spans the halves, and they go through the single authority that reads both, keyed by the page and its hosting renderer. The halves are disjoint in both directions: browsing registration refuses a page the document half already holds, and minting a grant refuses a page the browsing half already holds, so one id can never name a surface in both. The headless backend acts on that refusal by destroying the window it had already opened rather than leaving a policy-less page behind an id nothing can drive, keeping nothing under that id for its own shutdown to hand back. Registration refuses on the same terms when the guest it was asked about is already gone. The exit door is guarded in both its halves: a preview withdraws by revoking its grant and never through the unregister channel, so a page the document half holds arriving there is refused before either the registration teardown or the grab-state disposal beside it, which would otherwise drop the intent an in-flight preview grab compares by identity and leave that grab answering ok without ever arming its guest. A bridge request whose guest does not resolve is refused without tearing down the page it named, so a misaddressed request cannot cancel a healthy page's in-flight downloads and grabs. The annotation viewport bridge resolves its guest when its serialized op actually runs rather than when the request arrived, so a cross-process navigation while it waited cannot leave the bridge installed in a retired guest while the reader looks at a new one. State main keys by a preview's page is disposed when that page's grant is revoked, which is the only signal a preview's surface is gone. A tool asking for a page whose guest has not attached yet waits for that registration and arms when it arrives, rather than answering not-ready at the reader; that wait resolves only the request already naming this page, never the worktree-wide or any-tab waits the CLI and agents use to ask for a browser tab to drive. Handing the previewed document to the reader's own machine routes on the owners its grant was minted against — the file's own connection owner and the worktree's own runtime owner, neither read from the tab's stored fields. Only a document proven to live on this machine reaches the client OS; one with a resolved remote owner is downloaded first; and one whose owner cannot be resolved at all, workspace root included, is refused with a message naming that, because the download route would otherwise read the same absolute path on the client and hand back a same-named local file under the remote document's name. A runtime-owned path that falls outside its worktree root is refused by that route itself and surfaces as a failure toast rather than a download. Nothing the document does writes a file to this machine either: the preview partition denies downloads outright instead of routing them through the browser download flow, which has no page to attribute a preview's bytes to and would otherwise reserve a name in this desktop's Downloads folder and write them there unprompted. That refusal is visible to the reader and invisible to the document: the preview's shell carries a fixed sentence saying downloads are off, published at most once per preview per interval so a document asking in a loop cannot fill Orca's chrome, while the page itself gets back exactly what it got before, which is nothing. The sentence names no file, because the document chooses the name it offers; and a refusal never takes the document away the way an entry document's own failure does, whatever it names. A preview is a browser tab, not an editor tab in a preview mode: it is named the way a browser tab is named — by the document it shows when that document declares a title, and by the file it shows when it does not — while the chip goes on naming the file and the host whatever the document calls itself. A title is refused on the same terms the url is: a document that declares none has Chromium report the grant URL as its title, and that title is stored, mirrored onto the tab and written to disk, so anything carrying the scheme falls back to the file instead. It is created by the preview action as a page located by its document, it carries the workspace-relative path copy the editor's path header owned, and closing it revokes the grant that made the document readable while a URL tab closing beside it revokes nothing. Chrome persisted by builds that made previews editor tabs is dropped on restore rather than coming back naming a surface no restore can produce, and the ordinary editor tab for the same document is left alone. A document tab is held back at the mobile publish boundary — no client holds its grant, and the wire has no tab kind for it — while an ordinary browser tab beside it still publishes. It is held back from the group projection that publishes tab order, recency and group activity as well as from the tab list itself, so no published group names a tab the phone is never sent. A browser page can be located by a workspace document instead of a URL, and the document is the whole of its stored identity. The grant and the orca-preview URL that document is served over are minted when the page mounts and replaced by a hard reload, so neither is ever written to the page's url, mirrored onto its tab, persisted or published: such a page's url is the blank URL from creation through restore, including when a session written elsewhere carries a grant URL in, and what the session carries is the worktree and path a restored page mints afresh against today's owners. Every door onto a page's url holds that line — creation, the title update, and the navigation commit alike — so a report about a document page cannot give it a URL it never had, and the title fallback and the loading affordance follow the url each door actually wrote. The mirror carries the document too, so a tab entry cannot go on naming a document its active page has left. Every guest in the app is policy-attached through one door: a workspace document takes a restricted profile there rather than a separate installer beside it, so the attachment bookkeeping that door owns — what registration refuses, and what teardown frees — covers a preview on the same terms as a browsing page, and a preview takes none of the browsing machinery that door installs. That authority answers from the moment the embedder hands the guest over rather than only after a later navigation: the guest binds to the grant it is already showing, so the tools reach the document the reader opened and not just one they navigated to. A read the host reports as truncated or over-cap is refused rather than served partially, and a document outside the paired worktree is refused with a message naming that boundary instead of a bare read failure. The rendered document reaches nothing off-machine on its own: every served response carries a self-only content security policy, the preview session cancels any request that is not in-document, subframes cannot navigate outside the grant, a guest no document has yet bound to a grant may not navigate at all, the guest gathers no ICE candidates, and an SSH path that canonicalizes outside the grant root is refused before it is read. The one route out is a link the reader presses: a trusted click on an anchor, reported by the preview's own preload from a guest that holds focus and is still bound to a live grant, leaves as an Orca browser tab rather than a native window or a dead click. The preview hands its guest that focus itself whenever it is the surface the reader is in — a browsing page gets it from the chrome around it, and a preview has no chrome to get it from — and it does so only then, so a preview mounted behind a terminal or an editor never takes the keyboard from what the reader is actually in. It offers again when the window itself takes focus back and nothing in the embedder has claimed that focus, because another app coming to the front lands focus on the embedder rather than the guest and the route out would otherwise stay shut until something remounted the pane — while the same window focus also arrives when the reader presses a tab, that being the guest's own blur returning, and taking focus back from there would fight the reader for their own click. Nothing else does. A navigation or popup the document starts by itself is swallowed whatever else is happening, including immediately after a genuine press elsewhere in the document, so a page that can read its grant cannot hand it to a browser tab; a middle click opens nothing; and a fragment link is answered inside the document. A preview attach carries the preview preload and no renderer-supplied one, and no other attach path can acquire it. A subresource the workspace will not send degrades the document to a notice naming that file, never to a failure panel over a page that rendered. A grant outlives neither the tab that owns it nor the renderer document that minted it, and only the trusted renderer can mint or revoke one. For the browser creations this gate still owns, owner-pinned creation returns the canonical host page identity before navigation readiness; delayed navigation cannot turn a created page into an unidentifiable failure or a duplicate retry. Capability rejection before host mutation must preserve the original error, issue no RPC, surface a failure toast, and remove only a caller-declared newly-created empty split. Post-create reconciliation failure requires exact rollback; ambiguous rollback rejects without local fallback.", + "oracle": "In paired Electron, write an HTML fixture that declares its own title on the host, invoke the Explorer preview action on the client, and require the document text to be readable out of the orca-preview guest before judging any absence. With that presence established, require the client to hold exactly one browser workspace more than its baseline and that workspace to be the document one: page and tab url blank, the document path mirrored onto both, the tab named by the document's title, the chip naming the file, no editor row of the retired preview species anywhere, and the guest URL carrying the orca-preview scheme. Ask the host through its own page registry as well as through the tab snapshot, and in the same run open an ordinary URL browser tab from the same client and require that one to arrive in both — the presence precondition without which “the host gained nothing” is satisfied just as well by an oracle that cannot see browser pages at all. Require the preview to sit in a group other than the source editor's while the active group and tab remain the source editor's. Then click away to the terminal, click the preview tab, require it to reactivate and still render, close it with its own X, and require the document tab to be gone while the host still holds only the URL tab and the source group, source editor and terminal survive. Quit the client with a document tab open and relaunch it on the same profile: require the same workspace row to come back, blank and named by the document, rendering the document again over a grant URL that differs from the one that was quit, with no preview-scheme or document-named page anywhere in what the host holds. Prove the halves are live by flipping one product property at a time and requiring the run to fail: publish document workspaces to the host like ordinary ones, and stop mirroring the document onto the workspace row. Have the fixture document attempt its own egress on every load — an unattended window.open and location.href to an off-machine URL, plus an inline ICE gathering probe — and require the same baseline counts and a candidate count of zero, so the document's own attempts are measured rather than assumed. Then, as a separate phase after the close oracle has already run, bring the client window to the front, press the document's heading with a real mouse event, and require the document to report that the same press drove it to attempt a second window.open and location.href while both browser counts stay at that phase's baseline and nothing routes — the case a recent-input gate cannot distinguish from the press's own effect. Only then press the target=_blank link with a real mouse event and require both a recorded routing call that returned success and a browser count above that baseline, with the preview tab still open. Drive the preload's click policy as a unit oracle over a real document: a dispatched click, a trusted press on an external anchor, an anchor reached through what it wraps, an SVG animated href, a sibling preview link, fragment and percent-encoded fragment targets, a bare hash, and a middle click. Create a browser page located by a workspace document, handing creation a live grant URL, and require its stored url, its mirrored tab url and the written session payload all to be blank with no orca-preview string anywhere in what was written, while an ordinary page created the same way keeps the URL it was given and asks for the address bar the document page never does. Parse the written page and tab through the session schema and require the document to survive both halves. Hydrate them back and require the document page to return blank and still named — including when its page row was salvaged away and only the tab's own copy remains, and when a foreign session carried a grant URL into both rows. Drive the mirror across a page switch out of the document and back, and across a repair in which the document is the only mirrored field that differs. Name a document page from its document and require the tab to take that name, name it with an empty title and require the file, name it with a live grant URL and require the file again with no preview scheme anywhere in the written session, and require an ordinary blank browser tab beside it to still be called New Tab. Dispatch a title update out of a rendered preview's own guest and require it to reach the page state while the identity chip still reads the document's workspace-relative path. Attach a browsing guest and a workspace-document guest through the same method in one run and require the browsing one to take clicked-link routing, popup handling and anti-detection while the document guest takes none of them, stays inside the grant it is showing, denies every window it asks for, and is dropped from the page-keyed document registry by the same teardown that frees its id for a later attach. Register a browsing guest and attach a workspace-document guest in one run against the real manager, require the one door to answer each page with the guest of its own half, and require the document page to be absent from the browsing map and from its enumeration. Drive both browsing registration entry points with a page the document half already holds and require them to register nothing, and drive the mint channel with a page the browsing half already holds and require it to refuse; and drive the offscreen one with a guest that is missing and with one already destroyed, requiring the same refusal. Arm a grab on a live preview target, drive the unregister channel at that same target in the window before the queued operation runs, and require the grab to reach the guest anyway — then drive the same sequence for an ordinary browser page and require its grab state to be disposed after all. Hold one viewport-bridge op open, queue a second behind it, swap the page's guest while that second op waits, and require the injection to land in the guest the page has then. Revoke a grant after a tool has run against its page and require that page's grab state to be cancelled and disposed. Ask a tool for a document page whose guest has not attached, require the request to park in the registration wait, attach the guest, and require the same request to arm on it; require a page nothing ever renders to answer not-ready once that wait elapses. With a document open, ask a tool for a browsing page id and require it to be answered by the browsing half or not at all, with the same channel reaching the document guest under the page it really renders. Drive open-externally for a document whose per-file owner is remote while the workspace-scoped owner is unresolved, for a runtime-owned worktree whose preview tab carries no runtime id of its own, and for a worktree that resolves no runtime owner while the tab still carries one, requiring the download route in each; and for an owner that cannot be resolved at all, and for an unknown workspace root while nothing names another host, requiring a refusal that neither opens nor downloads. Drive the headless backend with a page the document half already holds and require it to reject, destroy the window, and unregister nothing — then shut the backend down and require it still to have unregistered nothing. Navigate a bound preview guest at a second grant through both latch events and require it to stay on the grant it bound to. Mount a preview while a renderer drag is already in flight and require its guest to be click-through at the moment it is appended, not a turn later. Render the editor panel shell in each remaining tab mode and require the path header exactly where the surface does not already name itself. Drive the preview action and require a browser tab located by the document rather than an editor tab, require a second open of the same document to activate the tab it is already in, and require closing that tab to revoke its grant while a URL tab closed beside it revokes none. Hydrate a session carrying preview chrome from a build that made previews editor tabs and require it dropped while the ordinary editor tab for the same document survives. Publish a worktree holding a document tab and a URL tab and require only the URL tab to reach the mobile snapshot. Install the shared partition policies for a preview partition and for an ordinary browsing partition in the same run, fire each one's own will-download listener, and require the preview's to cancel while the browsing one still reaches the download router — then require the preview protocol installer to be what asks for that deny. In the same run, require the cancelled download to raise a reader-facing notice and the routed one to raise none. Drive that notice directly for a guest bound to a live grant, for repeated attempts inside and outside its interval, for two previews at once, and for a contents no preview is bound to; require the guest registry to name the bound grant for a live preview guest and nothing for a contents that is not one, has committed no document, or is gone. Drive the shell with a refusal and require one fixed sentence, still one row after three more refusals, standing beside an asset failure rather than being counted with it, gone behind the failure panel, and ignored when it names another preview's grant. Drive the main-side report gate directly for an unfocused guest, a sender that is no preview guest, a guest with no bound or a revoked grant, and non-web URLs, and drive will-attach-webview in both preload directions. Run the per-owner reader, grant-containment, scheme-admission, guest-policy, and plan-routing contracts as unit oracles, including a host-reported truncation, an over-cap binary, and an out-of-worktree paired path. Drive the reader-facing component with the payloads the reader can actually produce — the entry document fails only as truncated or unreadable, a subresource additionally as a refused format — and require the asset case to leave the guest mounted. Drive the closed-tab cleanup hook, the window installer, and the grant IPC handlers directly, requiring the grant to be released when the preview tab closes, cleared at window creation and on a cross-document main-frame navigation, and refused to any sender that is not the trusted renderer. For the browser creations this gate still owns, run the unchanged contract oracle for direct create and side-preview callers with absent status, unknown capabilities, and a mixed-version host, requiring the original unsupported error or visible toast, zero RPCs, and no retained new split; hold a real navigation response beyond the 15-second client deadline after host creation and require the first RPC to return the exact host inventory page ID, one host page, and no retry; repeat reconciliation faults against headless serve and retain the separate exact-page reconciliation rollback oracle.", "commands": [ "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime-browser.test.ts src/main/runtime/rpc/methods/browser.test.ts src/renderer/src/lib/file-preview.test.ts src/renderer/src/runtime/web-session-browser-placement.test.ts src/renderer/src/runtime/web-runtime-session.test.ts src/renderer/src/runtime/web-session-tabs-sync.test.ts src/renderer/src/runtime/remote-server-parity.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/runtime/web-runtime-browser-materialization.test.ts", - "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/runtime/web-runtime-browser-capability-cleanup.test.ts src/renderer/src/lib/file-preview-capability-cleanup.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/runtime/web-runtime-browser-capability-cleanup.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts src/main/browser/doc-preview-grant-registry.test.ts src/main/browser/doc-preview-file-reader.test.ts src/main/browser/doc-preview-protocol.test.ts src/main/browser/doc-preview-guest-policy.test.ts src/main/browser/doc-preview-failure-notice.test.ts src/shared/doc-preview-scheme.test.ts src/renderer/src/lib/doc-preview-grants.test.ts src/renderer/src/lib/file-preview.test.ts src/renderer/src/lib/file-preview-action-visibility.test.tsx src/main/window/main-window-webview-security.test.ts src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.failure-message.test.tsx src/renderer/src/hooks/ipc-events/doc-preview-external-link-bridge.test.ts src/renderer/src/components/terminal-pane/terminal-file-link-actions.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts src/main/browser/browser-session-partition-policies.test.ts src/main/browser/doc-preview-protocol.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts src/main/browser/doc-preview-download-block-notice.test.ts src/main/browser/browser-session-partition-policies.test.ts src/main/browser/doc-preview-guest-policy.test.ts src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.failure-message.test.tsx", + "pnpm exec vitest run --config config/vitest.config.ts src/main/browser/browser-manager-guest-policy-profile.test.ts src/main/window/main-window-webview-security.test.ts src/main/browser/doc-preview-guest-policy.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/store/slices/browser-workspace-doc-location.test.ts src/renderer/src/lib/file-preview.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/store/slices/browser-workspace-doc-location.test.ts src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.toolbar.test.tsx src/shared/doc-preview-scheme.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/store/slices/browser-workspace-doc-location.test.ts", + // The hardening round: the third url door, the group projection, and both directions of the focus handoff. + "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/store/slices/browser-workspace-doc-location.test.ts src/renderer/src/runtime/sync-runtime-graph-editor-diff-tabs.test.ts src/renderer/src/components/browser-pane/workspace-doc/workspace-doc-page-pane.test.tsx src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.toolbar.test.tsx", + "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.failure-message.test.tsx", + "pnpm exec vitest run --config config/vitest.config.ts src/preload/doc-preview-link-interception.test.ts src/main/browser/doc-preview-guest-policy.test.ts src/main/window/main-window-webview-security.test.ts src/main/ipc/doc-preview-grant-ipc.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/browser-preview-tool-authorization.test.ts src/main/browser/doc-preview-guest-policy.test.ts src/main/ipc/browser.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/browser-preview-tool-authorization.test.ts src/main/browser/browser-manager-annotation-bridge.test.ts src/main/browser/browser-manager-guest-lifecycle.test.ts src/shared/doc-preview-scheme.test.ts src/renderer/src/components/browser-pane/workspace-doc/doc-preview-document-actions.test.ts src/renderer/src/components/browser-pane/workspace-doc/use-doc-preview-guest-tools.test.ts src/renderer/src/components/editor/EditorPanelShell.header.test.tsx src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.toolbar.test.tsx", + "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/browser-preview-tool-authorization.test.ts src/main/browser/browser-manager-annotation-bridge.test.ts src/main/browser/browser-manager-guest-lifecycle.test.ts src/main/browser/offscreen-browser-backend-lifecycle.test.ts src/main/browser/doc-preview-guest-policy.test.ts src/shared/doc-preview-scheme.test.ts src/renderer/src/components/browser-pane/workspace-doc/doc-preview-document-actions.test.ts src/renderer/src/components/browser-pane/workspace-doc/use-doc-preview-guest-tools.test.ts src/renderer/src/components/editor/EditorPanelShell.header.test.tsx src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.toolbar.test.tsx", + "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/doc-preview-grant-ipc.test.ts src/renderer/src/components/terminal-pane/TerminalLinkActionPopover.test.tsx src/renderer/src/runtime/sync-runtime-graph-editor-diff-tabs.test.ts src/renderer/src/store/slices/tabs-hydration.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/browser-preview-tool-authorization.test.ts src/main/ipc/browser-tab-registration-wait.test.ts src/main/ipc/doc-preview-grant-ipc.test.ts src/main/browser/browser-manager-guest-lifecycle.test.ts src/main/browser/browser-manager-guest-policy-profile.test.ts src/main/browser/browser-manager-annotation-bridge.test.ts src/main/browser/offscreen-browser-backend-lifecycle.test.ts src/main/browser/doc-preview-guest-policy.test.ts src/shared/doc-preview-scheme.test.ts src/renderer/src/components/browser-pane/workspace-doc/use-doc-preview-guest-tools.test.ts src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.toolbar.test.tsx", + "SKIP_BUILD=1 ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-remote-html-preview-local-render.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1 --reporter=line", + "SKIP_BUILD=1 ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-remote-html-preview-local-render.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1 --reporter=line --repeat-each=3", + // The changed-spec e2e job's own command, which is the only Linux run this gate has. + "npx playwright test --config tests/playwright.config.ts --project=electron-headless tests/e2e/paired-remote-html-preview-local-render.spec.ts --workers=1", "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime-browser.test.ts src/renderer/src/runtime/web-runtime-session.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/orca-runtime-browser.test.ts src/renderer/src/hooks/useIpcEvents-browser-tab-create.test.ts src/renderer/src/hooks/useIpcEvents-close-routing-browser-pages.test.ts src/renderer/src/hooks/useIpcEvents-close-routing-session-tabs.test.ts src/renderer/src/store/slices/browser.test.ts src/renderer/src/runtime/web-runtime-session.test.ts", "SKIP_BUILD=1 pnpm exec playwright test tests/e2e/paired-browser-create-navigation-deadline.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1", - "ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm exec playwright test tests/e2e/paired-remote-html-browser-focus.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", - "ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm exec playwright test tests/e2e/paired-remote-html-browser-focus.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1 --repeat-each=3", - "SKIP_BUILD=1 ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-remote-html-browser-focus.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", - "ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-remote-html-browser-focus.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", "SKIP_BUILD=1 ORCA_E2E_FORCE_HEADFUL=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm exec playwright test tests/e2e/paired-browser-creation-reconciliation-failure.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1 --reporter=line", "SKIP_BUILD=1 ORCA_E2E_FORCE_HEADFUL=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm exec playwright test tests/e2e/paired-browser-creation-reconciliation-failure.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1 --reporter=line" ], @@ -2863,7 +2880,6 @@ "src/main/runtime/orca-runtime-browser.test.ts", "src/main/runtime/rpc/methods/browser.test.ts", "src/renderer/src/lib/file-preview.test.ts", - "src/renderer/src/lib/file-preview-capability-cleanup.test.ts", "src/renderer/src/runtime/web-session-browser-placement.test.ts", "src/renderer/src/runtime/web-runtime-browser-materialization.test.ts", "src/renderer/src/runtime/web-runtime-browser-capability-cleanup.test.ts", @@ -2874,7 +2890,38 @@ "src/renderer/src/hooks/useIpcEvents-close-routing-browser-pages.test.ts", "src/renderer/src/hooks/useIpcEvents-close-routing-session-tabs.test.ts", "src/renderer/src/store/slices/browser.test.ts", - "tests/e2e/paired-remote-html-browser-focus.spec.ts", + "src/main/browser/doc-preview-grant-registry.test.ts", + "src/main/browser/doc-preview-file-reader.test.ts", + "src/main/browser/doc-preview-protocol.test.ts", + "src/main/browser/browser-session-partition-policies.test.ts", + "src/main/browser/doc-preview-download-block-notice.test.ts", + "src/main/browser/browser-manager-guest-policy-profile.test.ts", + "src/main/browser/doc-preview-guest-policy.test.ts", + "src/main/ipc/browser-tab-registration-wait.test.ts", + "src/main/browser/doc-preview-failure-notice.test.ts", + "src/renderer/src/store/slices/browser-workspace-doc-location.test.ts", + "src/preload/doc-preview-link-interception.test.ts", + "src/main/window/main-window-webview-security.test.ts", + "src/main/ipc/browser-preview-tool-authorization.test.ts", + "src/main/browser/browser-manager-annotation-bridge.test.ts", + "src/main/browser/browser-manager-guest-lifecycle.test.ts", + "src/main/browser/offscreen-browser-backend-lifecycle.test.ts", + "src/renderer/src/components/browser-pane/workspace-doc/doc-preview-document-actions.test.ts", + "src/renderer/src/components/browser-pane/workspace-doc/use-doc-preview-guest-tools.test.ts", + "src/renderer/src/components/editor/EditorPanelShell.header.test.tsx", + "src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.toolbar.test.tsx", + "src/renderer/src/components/browser-pane/workspace-doc/workspace-doc-page-pane.test.tsx", + "src/shared/doc-preview-scheme.test.ts", + "src/renderer/src/lib/doc-preview-grants.test.ts", + "src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.failure-message.test.tsx", + "src/renderer/src/lib/file-preview-action-visibility.test.tsx", + "src/renderer/src/hooks/ipc-events/doc-preview-external-link-bridge.test.ts", + "src/renderer/src/components/terminal-pane/terminal-file-link-actions.test.ts", + "src/main/ipc/doc-preview-grant-ipc.test.ts", + "src/renderer/src/store/slices/tabs-hydration.test.ts", + "src/renderer/src/components/terminal-pane/TerminalLinkActionPopover.test.tsx", + "src/renderer/src/runtime/sync-runtime-graph-editor-diff-tabs.test.ts", + "tests/e2e/paired-remote-html-preview-local-render.spec.ts", "tests/e2e/paired-browser-create-navigation-deadline.spec.ts", "tests/e2e/paired-browser-creation-reconciliation-failure.spec.ts" ], @@ -2886,18 +2933,13 @@ "direct creation removes the caller-declared new split without issuing an RPC" ] }, - { - "file": "src/renderer/src/lib/file-preview-capability-cleanup.test.ts", - "assertions": [ - "known unavailability reports visibly without creating a split", - "capability loss after caller preflight reports visibly without duplicating transaction cleanup" - ] - }, { "file": "src/renderer/src/lib/file-preview.test.ts", "assertions": [ - "a failed side preview reports visibly without caller-owned split cleanup", - "overlapping side previews reuse the split without the first failure deleting it" + "a paired-runtime HTML preview opens a local doc preview instead of a runtime browser tab", + "a local workspace still opens the embedded browser tab at a file URL", + "a paired document outside the worktree names that boundary and creates no split", + "an SSH document outside the worktree root still previews" ] }, { @@ -2973,11 +3015,152 @@ ] }, { - "file": "tests/e2e/paired-remote-html-browser-focus.spec.ts", + "file": "tests/e2e/paired-remote-html-preview-local-render.spec.ts", "assertions": [ - "one HTML open produces one client workspace, one client unified tab, and one host page", - "the source editor and remote frame remain visible in separate groups", - "preview creation leaves the editor active, an explicit browser click is authoritative, and close preserves the editor and terminal" + "the host's HTML document renders inside the client-local orca-preview guest", + "neither the client browser-workspace count nor the host browser-tab count moves off baseline", + "the preview opens in its own split with mode html-preview and leaves the source editor active", + "an explicit click reactivates the preview, and closing it preserves the source editor, its group, and the terminal", + "the document reports having attempted both its unattended and its post-press egress, so neither absence assertion can pass on a document that never tried", + "a scripted window.open and location.href driven by a real press elsewhere in the document route nothing and move no browser count", + "a real target=_blank press in the rendered document routes out as an Orca browser tab while the preview stays open" + ] + }, + { + "file": "src/main/browser/doc-preview-file-reader.test.ts", + "assertions": [ + "a host-reported truncated read is refused instead of served as partial bytes", + "an over-cap binary and an empty binary body are reported as too-large and unsupported rather than a bare read failure", + "a document outside the paired worktree is refused without issuing an RPC", + "an SSH path that canonicalizes outside the grant root is refused before any read", + "a 0-byte file the host still typed is served as 0 bytes rather than reported as a refused format" + ] + }, + { + "file": "src/main/browser/doc-preview-grant-registry.test.ts", + "assertions": [ + "a path escaping the grant root resolves to nothing, including sibling-prefix roots", + "a revoked grant stops resolving" + ] + }, + { + "file": "src/main/browser/doc-preview-protocol.test.ts", + "assertions": [ + "an unknown or revoked grant is answered exactly like a missing file, with no read attempted", + "a failed read pushes its reason for the requested path while a served document pushes nothing", + "every served document carries the self-only content security policy", + "the preview session cancels any request that is not in-document", + "the scheme is handled on the preview session and never on the default one", + "the scheme claims exactly its five privileges, so a widened one fails" + ] + }, + { + "file": "src/main/browser/doc-preview-failure-notice.test.ts", + "assertions": ["413 and 415 map to distinct reasons and everything else stays generic"] + }, + { + "file": "src/renderer/src/lib/file-preview-action-visibility.test.tsx", + "assertions": [ + "the Explorer row predicate and the preview plan return one answer for the same file" + ] + }, + { + "file": "src/renderer/src/hooks/ipc-events/doc-preview-external-link-bridge.test.ts", + "assertions": [ + "an external preview link becomes a browser tab, and a tab that is refused or that fails outright surfaces instead of being dropped" + ] + }, + { + "file": "src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.failure-message.test.tsx", + "assertions": [ + "each entry-document failure reason surfaces its own sentence instead of one generic unavailable panel", + "a refused subresource names itself in a notice, deduplicated per path, while the document stays rendered", + "an entry-document failure replaces the asset notice with the panel", + "a failure for another preview's grant leaves the rendered document alone" + ] + }, + { + "file": "src/main/browser/doc-preview-guest-policy.test.ts", + "assertions": [ + "the guest may only navigate within its own live grant, and not at all until a document has bound it to one", + "every navigation and popup sink refuses without routing, whenever genuine input happened", + "a subframe cannot navigate outside the grant, and only the main frame binds the guest to one", + "a reported click routes exactly once, and only from a focused preview guest with a live bound grant", + "a reported click on a non-web URL, or from a destroyed guest, routes nothing", + "the guest is denied non-proxied WebRTC UDP when its policy is installed" + ] + }, + { + "file": "src/preload/doc-preview-link-interception.test.ts", + "assertions": [ + "a click the document dispatched itself is not trusted and routes nothing", + "a trusted press on an external anchor reports it once and stops the navigation, including through what the anchor wraps and an SVG animated href", + "a fragment link scrolls in the document, raw or percent-encoded, and a bare hash goes to the top", + "a sibling preview document is left to the guest's own navigation, and a middle click opens nothing whether or not it is trusted", + "an SVG link is resolved against the document, so its relative, rooted and fragment forms behave exactly like the identical HTML anchor", + "the preload reports on exactly the channel main listens on" + ] + }, + { + "file": "src/main/window/main-window-webview-security.test.ts", + "assertions": [ + "an orca-preview src is admitted only on the preview partition with a live grant", + "preview guests receive their own policy instead of the browser guest policies", + "a preview attach is pinned to the preview preload over any renderer-supplied value, and a browsing attach never receives it", + "grants a renderer can no longer release are cleared at window creation and on reload, and survive an in-document navigation" + ] + }, + { + "file": "src/shared/doc-preview-scheme.test.ts", + "assertions": ["preview URLs round-trip per segment and reject a malformed grant id"] + }, + { + "file": "src/renderer/src/lib/doc-preview-grants.test.ts", + "assertions": [ + "grants are deduped per preview tab so a StrictMode double-mount cannot revoke the surviving webview", + "a mint that rejects after its tab was released and reopened leaves the reopened tab's grant revocable" + ] + }, + { + "file": "src/main/ipc/doc-preview-grant-ipc.test.ts", + "assertions": [ + "a sender that is not the trusted renderer mints no grant and revokes none", + "a reported link click reaches the guest policy with its own sender, and a non-string report reaches nothing" + ] + }, + { + "file": "src/renderer/src/store/slices/browser-workspace-doc-location.test.ts", + "assertions": [ + "closing a document browser tab revokes its grant, and closing a URL tab beside it revokes nothing", + "a document page's url is blank in the store, in the written session and after restore", + "a navigation committing a url onto a document page leaves the url blank, the document title standing and nothing loading, while an ordinary page beside it takes the url it is given" + ] + }, + { + "file": "src/renderer/src/store/slices/tabs-hydration.test.ts", + "assertions": [ + "chrome for a preview tab written before previews were browser tabs is dropped, and the ordinary editor tab for the same document is kept" + ] + }, + { + "file": "src/renderer/src/runtime/sync-runtime-graph-editor-diff-tabs.test.ts", + "assertions": [ + "a browser tab located by a workspace document is not published to mobile clients, while an ordinary browser tab beside it is", + "with tab groups in play the document is kept out of the published groups too: no group's order, recency or active tab names a tab the snapshot does not carry, the held-back group leaves the published layout tree with it, and the grouped URL tab beside it still publishes" + ] + }, + { + "file": "src/renderer/src/components/browser-pane/workspace-doc/workspace-doc-page-pane.test.tsx", + "assertions": [ + "the pane offers its guest focus when the reader is in this very preview", + "it refuses while the reader is in a terminal in front of it, in a different browser tab, or while its own page is not the active one" + ] + }, + { + "file": "src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.toolbar.test.tsx", + "assertions": [ + "a preview that is the reader's surface hands its guest focus, and one that is not leaves focus alone", + "the window taking focus back re-offers it to the guest, and re-offers nothing for a preview that was refusing" ] }, { @@ -2999,22 +3182,301 @@ ], "evidenceRuns": [ { - "date": "2026-08-12", + "date": "2026-08-27", "runner": "local", "platform": "macos", - "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/runtime/web-runtime-browser-capability-cleanup.test.ts src/renderer/src/lib/file-preview-capability-cleanup.test.ts", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/store/slices/browser-workspace-doc-location.test.ts src/renderer/src/runtime/sync-runtime-graph-editor-diff-tabs.test.ts src/renderer/src/components/browser-pane/workspace-doc/workspace-doc-page-pane.test.tsx src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.toolbar.test.tsx", "result": "failed", - "durationSeconds": 0.72, - "summary": "The unchanged STA-4063 oracle failed 5/5 on scan e4c278eb52 and latest main bc2e30000b: direct create never closed the new split, and side-preview rejection produced neither cleanup nor a toast. Pre-fix PR #14100 passed the two caller cases but still failed all three direct transaction cases." + "durationSeconds": 7, + "summary": "The adversarial pass answered the request for its own group-projection probe after this round landed, and it had measured a third observable this test was missing: the held-back group also has to leave the published layout tree, or the phone renders a split whose second pane can never hold anything. Adopted as a fourth assertion and re-run against the same sole delta, which now fails on the dangling-reference check and, with that check blinded, on the layout alone — group-left and group-right against the group-right the clean tree publishes. The two probes agreed on everything else, including that the tab list is unchanged under this mutant, which is why an assertion reading only the tab list cannot reach the site at all." }, { - "date": "2026-08-12", + "date": "2026-08-27", "runner": "local", "platform": "macos", - "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/runtime/web-runtime-browser-capability-cleanup.test.ts src/renderer/src/lib/file-preview-capability-cleanup.test.ts", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/store/slices/browser-workspace-doc-location.test.ts src/renderer/src/runtime/sync-runtime-graph-editor-diff-tabs.test.ts src/renderer/src/components/browser-pane/workspace-doc/workspace-doc-page-pane.test.tsx src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.toolbar.test.tsx", + "result": "passed", + "durationSeconds": 3, + "summary": "The adversarial pass's four coverage findings, landed and pinned: 58/58. The third url door now keeps a document page blank and names it by its document, the group projection holds a document back from published groups as well as from the tab list, the pane's focus handoff is pinned in the widening direction as well as the narrowing one, and the handoff is re-offered when the window takes focus back." + }, + { + "date": "2026-08-27", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/store/slices/browser-workspace-doc-location.test.ts src/renderer/src/runtime/sync-runtime-graph-editor-diff-tabs.test.ts src/renderer/src/components/browser-pane/workspace-doc/workspace-doc-page-pane.test.tsx src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.toolbar.test.tsx", + "result": "failed", + "durationSeconds": 3, + "summary": "Three sole-delta mutants on the third url door, each failing only the new store test: committing the reported url onto a document page, dropping the document from the title normalizer there (which renames the tab New Tab, since a blank url is what that fallback reads), and marking a blank document page loading. The ordinary-page half of the same test passes under all three, so the door is pinned as a document-only refusal rather than as a door that writes nothing." + }, + { + "date": "2026-08-27", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/store/slices/browser-workspace-doc-location.test.ts src/renderer/src/runtime/sync-runtime-graph-editor-diff-tabs.test.ts src/renderer/src/components/browser-pane/workspace-doc/workspace-doc-page-pane.test.tsx src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.toolbar.test.tsx", + "result": "failed", + "durationSeconds": 3, + "summary": "Two sole-delta mutants on the mobile publish boundary. Dropping the document from the group projection's browser ids fails only the new grouped test — the tab list stays correct because the per-tab guard still refuses the tab, and what breaks is a published group whose order and active tab name a tab the phone is never sent. This is the survivor the adversarial pass reported: it survives the whole suite without this test, because no other test in it has tab groups. Dropping the per-tab guard instead fails only the ungrouped test, so the two halves of the boundary are pinned separately." + }, + { + "date": "2026-08-27", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/store/slices/browser-workspace-doc-location.test.ts src/renderer/src/runtime/sync-runtime-graph-editor-diff-tabs.test.ts src/renderer/src/components/browser-pane/workspace-doc/workspace-doc-page-pane.test.tsx src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.toolbar.test.tsx", + "result": "failed", + "durationSeconds": 3, + "summary": "Three sole-delta mutants widening the focus handoff, the direction the adversarial pass found uncovered: dropping the page-activity half, dropping the active-tab-type half, and dropping the active-browser-tab half each fail exactly one of the pane's refusals — the inactive page, the terminal in front, and the other browser tab respectively. The presence half of that test passes under all three, so a pane that had stopped offering focus at all would still fail rather than pass the refusals vacuously." + }, + { + "date": "2026-08-27", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/store/slices/browser-workspace-doc-location.test.ts src/renderer/src/runtime/sync-runtime-graph-editor-diff-tabs.test.ts src/renderer/src/components/browser-pane/workspace-doc/workspace-doc-page-pane.test.tsx src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.toolbar.test.tsx", + "result": "failed", + "durationSeconds": 3, + "summary": "One sole-delta mutant dropping the window-focus re-offer: the guest is focused once at mount and never again after the window comes back, which is the desktop gap behind it — another app taking the front leaves the preview's only route out shut until the pane remounts. It fails only the new re-offer test, and the refusing-preview case beside it stays green, so the re-offer is pinned as gated rather than unconditional." + }, + { + "date": "2026-08-27", + "runner": "local", + "platform": "macos", + "command": "SKIP_BUILD=1 ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-remote-html-preview-local-render.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1 --reporter=line", + "result": "passed", + "durationSeconds": 186, + "summary": "The rewritten journey passed 2/2. The first test proves the inverted claim: the client gains exactly one browser workspace and it is the document one — page and tab url blank, the document path mirrored onto both, the tab named by the document's own title, the chip still naming the file, no editor row of the retired preview species — while the host's page registry and tab snapshot both stay at their pre-action content. Both host oracles carry a presence precondition in the same run: an ordinary URL browser tab this client opens does arrive in browser.tabList and in session.tabs.list, and leaves both again when it is closed, so the preview's absence is a decision rather than an oracle that cannot see browser pages. The document still reaches nothing on its own (zero ICE candidates, unattended and post-press egress both swallowed), and the one route out still works: a real press on a target=_blank anchor routes exactly one call and opens a tab while the preview stays open. The second test quits the client with a document tab open and relaunches it on the same profile: the same workspace row comes back blank and named by the document, renders again, and does so over a grant URL that differs from the one that was quit." + }, + { + "date": "2026-08-27", + "runner": "local", + "platform": "macos", + "command": "SKIP_BUILD=1 ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-remote-html-preview-local-render.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1 --reporter=line", + "result": "failed", + "durationSeconds": 108, + "summary": "Candidate with the sole delta that the preview action also opens an ordinary browser tab through the runtime — what it did before STA-5557 — failed at the client-workspace count, 2 against the 1 the claim allows. It dies there rather than at the host oracle, so it proves the exactly-one-workspace half; the host half has no single line to mutate, since a document page never calls the runtime at all, and is protected instead by the presence precondition recorded above." + }, + { + "date": "2026-08-27", + "runner": "local", + "platform": "macos", + "command": "SKIP_BUILD=1 ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-remote-html-preview-local-render.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1 --reporter=line", + "result": "failed", + "durationSeconds": 190, + "summary": "Candidate with the sole delta that the workspace row stops mirroring the document its active page shows failed in both tests on workspaceDocFilePath, null against the document path — the tab entry would go on naming nothing while the page it renders is a document. Two earlier mutants survived and are reported as ill-posed rather than as blind spots: flipping the mobile publish filter changes what this desktop publishes to its own phone clients, not what a paired client puts on its host, and flipping the document mirror in the workspace constructor is repaired on the next state write by the active-page mirror, which is the line the mutant above hits." + }, + { + "date": "2026-08-27", + "runner": "local", + "platform": "macos", + "command": "SKIP_BUILD=1 ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-remote-html-preview-local-render.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1 --reporter=line", + "result": "failed", + "durationSeconds": 195, + "summary": "Candidate with the sole delta that the preview pane does not hand focus to its guest — the tree as it stood before this change — failed at the target=_blank press: six real presses reached the anchor, the embedder had the webview as its active element, and main's own log answered each report with registered true, grant live, focused false. This is the gap the rewrite found: since previews became browser pages they had no chrome to pass focus on, so the one route out of a preview was shut." + }, + { + "date": "2026-08-27", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/browser/doc-preview-grant-registry.test.ts src/main/browser/doc-preview-file-reader.test.ts src/main/browser/doc-preview-protocol.test.ts src/main/browser/doc-preview-guest-policy.test.ts src/main/browser/doc-preview-failure-notice.test.ts src/shared/doc-preview-scheme.test.ts src/renderer/src/lib/doc-preview-grants.test.ts src/renderer/src/lib/file-preview.test.ts src/renderer/src/lib/file-preview-action-visibility.test.tsx src/main/window/main-window-webview-security.test.ts src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.failure-message.test.tsx src/renderer/src/hooks/ipc-events/doc-preview-external-link-bridge.test.ts src/renderer/src/components/terminal-pane/terminal-file-link-actions.test.ts", + "result": "passed", + "durationSeconds": 1.37, + "summary": "174/174 over the grant, reader, protocol, guest-policy, scheme-admission, plan-routing, action-visibility, failure-message, external-link and terminal-row contracts, re-issued at the relocated preview paths. This replaces the 2026-08-26 record of the same thirteen files at 152/152, whose command named the pre-relocation paths: it established the stale-mint rejection that must not evict a reopened preview tab, the CSP header and session request filter, the SSH symlink refusal, subframe containment, truncated and over-cap reads, the unservable-asset and empty-file split, the out-of-worktree paired refusal with SSH left unrestricted, reload re-minting a stale grant, and the guest deny-only navigation sinks. The count moved with the cases added since; nothing in those files changed but their directory." + }, + { + "date": "2026-08-27", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/browser/doc-preview-download-block-notice.test.ts src/main/browser/browser-session-partition-policies.test.ts src/main/browser/doc-preview-guest-policy.test.ts src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.failure-message.test.tsx", + "result": "passed", + "durationSeconds": 1.78, + "summary": "64/64 on the visible download refusal, re-issued at the relocated path. This replaces the 2026-08-27 record of the same four files at 62/62, whose ten mutants — removing the notice from the deny listener, adding it to the routing listener, dropping the guest-registry lookup that names which preview to tell, dropping the shell branch and the row, letting the row survive the failure panel, removing the per-preview throttle, sharing one throttle across previews, never expiring the window, and publishing for a contents no preview is bound to — all failed as sole deltas. The relocation moved no line of any of them." + }, + { + "date": "2026-08-27", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/store/slices/browser-workspace-doc-location.test.ts src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.toolbar.test.tsx src/shared/doc-preview-scheme.test.ts", + "result": "passed", + "durationSeconds": 2.05, + "summary": "39/39 on the title-parity trio at the relocated path, replacing the 2026-08-27 record of the same three files, which killed five mutants as sole deltas: disabling the document branch restores New Tab for a named document, dropping the grant-URL refusal puts the scheme into the title, the tab mirror and the written session, returning New Tab instead of the file name breaks the fallback, removing the guest listener leaves the document unable to name its tab, and making the scheme test parse rather than prefix-match lets a title that merely looks like a preview URL through." + }, + { + "date": "2026-08-27", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.failure-message.test.tsx", + "result": "passed", + "durationSeconds": 1.54, + "summary": "14/14 on the reader-facing failure suite at its relocated path. This and the red run below replace the 2026-08-26 pair recorded against the pre-relocation path (10/10 green, 3/10 red), re-measured today so the entry/subresource split keeps a red half rather than only a green one." + }, + { + "date": "2026-08-27", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.failure-message.test.tsx", + "result": "failed", + "durationSeconds": 1.27, + "summary": "Candidate with the sole delta that every failure sets the blocking reason, asset or not, failed 4/14 on the asset-notice assertions — the same mutant the retired 2026-08-26 record measured at 3/10, re-run at the relocated path. A refused subresource must name itself over a document that stays mounted; only the entry document may take the page away." + }, + { + "date": "2026-08-27", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/browser-preview-tool-authorization.test.ts src/main/browser/browser-manager-annotation-bridge.test.ts src/main/browser/browser-manager-guest-lifecycle.test.ts src/main/browser/offscreen-browser-backend-lifecycle.test.ts src/main/browser/doc-preview-guest-policy.test.ts src/shared/doc-preview-scheme.test.ts src/renderer/src/components/browser-pane/workspace-doc/doc-preview-document-actions.test.ts src/renderer/src/components/browser-pane/workspace-doc/use-doc-preview-guest-tools.test.ts src/renderer/src/components/editor/EditorPanelShell.header.test.tsx src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.toolbar.test.tsx", + "result": "passed", + "durationSeconds": 1.52, + "summary": "174/174 at the relocated paths, replacing the 2026-08-27 records of the same command at 162/162 and 158/158. Those established, as sole deltas: removing the unregister-channel refusal goes red because the grab disposal beside it drops the intent an in-flight preview grab compares by identity, while an ordinary browser page driven through the identical sequence still has its grab state disposed; removing registration’s already-gone-guest refusal goes red for both a missing and a destroyed guest; removing the map deletion on the refusal path goes red at shutdown; the bridge’s own unregisterGuest branch, restored, tears down a live page over a misaddressed request; and the download fall-through for an unresolved owner hands back a same-named local file under a remote document’s name." + }, + { + "date": "2026-08-27", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/browser-preview-tool-authorization.test.ts src/main/ipc/browser-tab-registration-wait.test.ts src/main/ipc/doc-preview-grant-ipc.test.ts src/main/browser/browser-manager-guest-lifecycle.test.ts src/main/browser/browser-manager-guest-policy-profile.test.ts src/main/browser/browser-manager-annotation-bridge.test.ts src/main/browser/offscreen-browser-backend-lifecycle.test.ts src/main/browser/doc-preview-guest-policy.test.ts src/shared/doc-preview-scheme.test.ts src/renderer/src/components/browser-pane/workspace-doc/use-doc-preview-guest-tools.test.ts src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.toolbar.test.tsx", + "result": "passed", + "durationSeconds": 1.5, + "summary": "175/175 at the relocated paths on the unified tool authority, replacing the 2026-08-27 record of the same command at 174/174. That run killed twelve of fourteen mutants as sole deltas — dropping the document branch from the one authority door, removing either browsing-registration refusal or the mint-side refusal that is its converse, deleting the registration notification or widening it to the worktree-wide and any-tab waits, disposing revoked state by grant instead of by page, dropping the hosting-renderer or destroyed-contents check, rebuilding the renderer tool target from the grant, and deleting the teardown identity check under a re-mint — and reported two as ill-posed rather than as kills. The relocation changed no file it names except their directory." + }, +{ + "date": "2026-08-27", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/store/slices/browser-workspace-doc-location.test.ts src/renderer/src/lib/file-preview.test.ts", + "result": "passed", + "durationSeconds": 22.09, + "summary": "29/29 on the candidate that makes the preview a browser tab. The preview action now creates a page located by the document; reopening the same document activates the tab it is already in rather than minting a second grant on one file; and closing that tab revokes its grant, which nothing else does now that the editor tab's close hook is gone. Red-green with each mutant as the sole delta: dropping the reuse lookup opens a second tab for a document already on screen, and dropping the release on close leaves the document readable through a grant nothing revokes until the process ends. Both are paired with presence preconditions in the same runs — a second, different document still gets its own tab, and a URL tab closed beside the document tab revokes nothing, so a release fired for every close would fail rather than pass." + }, + { + "date": "2026-08-27", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/doc-preview-grant-ipc.test.ts src/renderer/src/components/terminal-pane/TerminalLinkActionPopover.test.tsx src/renderer/src/runtime/sync-runtime-graph-editor-diff-tabs.test.ts src/renderer/src/store/slices/tabs-hydration.test.ts", + "result": "passed", + "durationSeconds": 4.77, + "summary": "40/40 on the candidate that removes the editor preview species and moves its two boundary duties onto the browser tab. The publish filter moved with the tab: it used to exclude an editor file by mode, and now excludes a browser workspace by whether it is located by a document — asserted at both the group projection and the tab loop, because a mutant that filters only one of them still publishes. Migration is the other half: sessions written by builds that made previews editor tabs carry chrome whose entity id encodes the document, and whose document was never persisted, so it has always come back naming a surface no restore produces; that chrome is now dropped. Each mutant as the sole delta: filtering neither place publishes the document tab to mobile, filtering only the projection still publishes it, and removing the migration leaves the stale strip entry. Presence preconditions in the same runs: an ordinary browser tab beside the document tab does publish, and the ordinary editor tab for the very same document survives hydration." + }, + { + "date": "2026-08-27", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/store/slices/browser-workspace-doc-location.test.ts", + "result": "passed", + "durationSeconds": 1.54, + "summary": "10/10 on the candidate that gives a browser page a workspace-document location. The trap this is built around: page.url is read by persistence, the mobile publisher, history and the address bar, so a grant URL parked there would outlive the grant that named it and travel to readers that cannot resolve it. Creation overrides the url rather than trusting its caller, and restore re-asserts the same thing, so a session written by another build cannot carry one in. Red-green with each mutant as the sole delta: dropping the creation override writes the grant URL into the store and into the session payload; dropping the restore override brings a foreign session's grant URL back; leaving docLocation out of either browser schema strips it on load and restores the document as a blank New Tab under a strip entry that still names it (both schemas are plain z.object, and this is the trap that already cost one round on the editor schema); dropping it from the workspace build or the mirror leaves the tab entry naming a document its page has left, which the publish boundary reads; dropping the salvage-path copy loses the document when only the tab row survived; and dropping the address-bar exclusion focuses an address bar the surface does not have. The blank-url assertions are paired with a presence precondition in the same run: an ordinary tab created through the same call keeps its URL and does ask for the address bar." + }, + { + "date": "2026-08-27", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/browser/browser-manager-guest-policy-profile.test.ts src/main/window/main-window-webview-security.test.ts src/main/browser/doc-preview-guest-policy.test.ts", + "result": "passed", + "durationSeconds": 0.32, + "summary": "55/55 on the candidate that gives every guest one attach door. A preview used to reach its policies through an installer of its own, which meant the manager's attachment bookkeeping — the set registration consults and the cleanup teardown runs — never knew a preview existed. It is now a profile on `attachGuestPolicies`, and the doc policy is what that profile installs. Red-green with each mutant as the sole delta: deleting the profile branch hands a previewed document the browsing machinery (clicked-link routing, frame routing, popup child windows, anti-detection over the debugger) and lets it navigate off-grant; dropping the manager's destroyed wiring leaves the guest answerable through the grant-keyed authority after it dies and its id marked attached forever; and making the window fork ask for the default profile is caught at the fork itself. The absence assertions are paired with a presence precondition in the same run: a browsing guest attached through the same method observably takes every one of those things, so a profile that fenced nothing, or an attach path that had stopped installing anything at all, fails rather than passing empty." + }, + { + "date": "2026-08-27", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/browser/browser-session-partition-policies.test.ts src/main/browser/doc-preview-protocol.test.ts", + "result": "passed", + "durationSeconds": 0.17, + "summary": "22/22 on the candidate that stops a previewed document writing into this desktop's Downloads folder. The hole was read out of the shipping path, not guessed: the preview partition took the shared browser partition policies whole, including will-download, and a preview guest joins no browser-page registry, so handleGuestWillDownload resolved no owner context, routeBrowserClientDownload answered local for a non-client-hosted contents, and the item got a reserved Downloads name with no prompt and no tab to attribute it to. Downloads are now the one policy the preview partition does not inherit. Red-green with each mutant as the sole delta: making the installer ignore the deny option routes the preview partition's download to the router again, and making the preview protocol installer stop asking for the deny leaves the option unset. The absence half is paired with a presence precondition in the same run — an ordinary browsing partition installed beside the preview one in one test still reaches the download router, so a deny that fenced everything, or a download path that had been removed entirely, would fail rather than pass quietly." + }, +{ + "date": "2026-08-27", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/browser-preview-tool-authorization.test.ts src/main/browser/doc-preview-guest-policy.test.ts src/main/ipc/browser.test.ts", "result": "passed", "durationSeconds": 0.7, - "summary": "Rebased candidate 14b5877a75 passed 5/5: absent, unknown, mixed-version, preflight-known, and post-preflight capability failures all reported failure and retained no new empty split." + "summary": "99/99 on the candidate that binds a preview guest to its grant when the policy installs, not only on a later navigation. Live on a dev instance against a real SSH workspace, the previous shape refused every tool with not-ready for as long as the reader stayed on the page they opened: the embedder hands the guest over after it has already started loading its src, so the did-start-navigation latch never saw the only navigation most previews make. Following an in-document link bound it and the tools began answering, which is what identified the miss. Two cases pin it: a guest already reporting its URL at install, and one whose first navigation only reaches the commit." + }, + { + "date": "2026-08-26", + "runner": "local", + "platform": "macos", + "command": "SKIP_BUILD=1 ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-remote-html-preview-local-render.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1 --reporter=line", + "result": "passed", + "durationSeconds": 23.1, + "summary": "The journey passed 1/1 on the candidate that requires the guest to be bound to a grant before any navigation is allowed, rather than admitting an unbound one. The document still rendered and the target=_blank press still routed out, which is the half that would break if the renderer-set src reached will-navigate: it does not, because that load is browser-initiated, so the permissive branch it was written for is unreachable." + }, + { + "date": "2026-08-26", + "runner": "ci", + "platform": "linux", + "command": "npx playwright test --config tests/playwright.config.ts --project=electron-headless tests/e2e/paired-remote-html-preview-local-render.spec.ts --workers=1", + "result": "failed", + "durationSeconds": 72, + "summary": "The journey failed on Ubuntu at the post-press presence precondition, with #post-input-egress still reading 'idle' after 30 seconds. The trace shows the press was dispatched at 769.5,143.9, a point the guest's own hit test had just confirmed was on its heading, and the focus report before it was already isFocused true, so neither focus nor coordinates were the break: the press never entered the guest at all. A standalone Electron 43 probe under Xvfb with this suite's Linux flags reproduced it in roughly two runs in five when pressing immediately after guest attach — the embedder received the pointerdown with the webview element as its target, meaning the browser resolved the press against the embedder because the freshly attached guest had not yet registered its hit-test region. The same probe never dropped a press on macOS, which is why local runs of either project could not see this. The spec now presses until the document answers, mirroring the target=_blank phase." + }, + { + "date": "2026-08-26", + "runner": "local", + "platform": "macos", + "command": "SKIP_BUILD=1 ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-remote-html-preview-local-render.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1 --reporter=line --repeat-each=3", + "result": "passed", + "durationSeconds": 72, + "summary": "The journey passed 3/3 on the trusted-click candidate, which removed the recent-input gate, made both navigation sinks deny-only, dropped the webview's allowpopups attribute and gave the preview guest its own pinned preload. The fixture now also attempts a window.open and location.href from inside a pointerdown handler, so the egress rides a press the reader really made: it reported 'attempted' every run while both browser counts stayed at that phase's baseline and nothing routed, and the later real press on the target=_blank link still opened exactly one Orca browser tab with the preview still open." + }, + { + "date": "2026-08-26", + "runner": "local", + "platform": "macos", + "command": "SKIP_BUILD=1 ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-remote-html-preview-local-render.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1 --reporter=line", + "result": "failed", + "durationSeconds": 26.9, + "summary": "Candidate with the sole delta that the navigation sinks route again behind a two-second recent-input gate failed on clientBrowserWorkspaceCountAllWorktrees 1 versus baseline 0: the document's location.href, run from inside the handler for the reader's press on the heading, opened a real browser tab. That is the case the earlier design could not distinguish from the press's own effect, and it is why the sinks route nothing at all now. The same run still refused the unattended on-load egress, so this measures the new half specifically." + }, + { + "date": "2026-08-26", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/browser-preview-tool-authorization.test.ts src/main/browser/doc-preview-guest-policy.test.ts src/main/ipc/browser.test.ts", + "result": "passed", + "durationSeconds": 0.97, + "summary": "97/97 passed over the preview tool-authorization seam. The channel census asserts the split is total, so a new browser channel cannot be added without classifying it: all six tool channels resolved the preview guest and refused both a renderer that does not host the preview and a grant no preview rendered, while all twenty-three page/session channels never reached the preview authority at all. Five mutations were killed \u2014 dropping the preview branch (12 failures), turning the two authorities into a fallback chain (13), ignoring the hosting renderer id (6), ignoring grant revocation (1), and removing one channel from the census (1)." + }, + { + "date": "2026-08-26", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/preload/doc-preview-link-interception.test.ts src/main/browser/doc-preview-guest-policy.test.ts src/main/window/main-window-webview-security.test.ts src/main/ipc/doc-preview-grant-ipc.test.ts", + "result": "passed", + "durationSeconds": 0.36, + "summary": "The four surfaces the trusted-click route is built from passed 63/63: the preload's click policy over a real document, including an untrusted dispatched click, an untrusted middle click, an SVG animated href resolved against the document so a relative, rooted or fragment SVG link behaves exactly like the identical HTML anchor, fragment and percent-encoded fragment targets and a trusted middle click; the guest policy's deny-only sinks, its refusal of a navigation that arrives before a document has bound the guest to a grant, and its report gate for an unfocused guest, an unregistered sender, a missing or revoked grant and non-web URLs; will-attach-webview pinning the preview preload in both directions; and the click channel handing main its own sender without a trusted-renderer check." + }, +{ + "date": "2026-08-26", + "runner": "local", + "platform": "macos", + "command": "SKIP_BUILD=1 ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-remote-html-preview-local-render.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1 --reporter=line", + "result": "passed", + "durationSeconds": 22.8, + "summary": "The journey passed 1/1 with the fixture document now attempting its own egress on every load: an unattended window.open and location.href to an off-machine URL, and an ICE gathering probe. Both browser counts held at baseline and the probe reported candidates=0, while the document still rendered and the later real target=_blank press still routed out as an Orca browser tab." + }, + { + "date": "2026-08-26", + "runner": "local", + "platform": "macos", + "command": "SKIP_BUILD=1 ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-remote-html-preview-local-render.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1 --reporter=line", + "result": "failed", + "durationSeconds": 23.6, + "summary": "Candidate with the sole delta that the preview guest no longer receives the disable_non_proxied_udp IP-handling policy failed on candidates=6 versus 0, proving the document can gather ICE — including a STUN reflexive candidate against an off-machine server in the standalone probe — whenever that one main-process call is missing." + }, + { + "date": "2026-08-26", + "runner": "local", + "platform": "macos", + "command": "SKIP_BUILD=1 ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-remote-html-preview-local-render.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1 --reporter=line --repeat-each=3", + "result": "passed", + "durationSeconds": 66, + "summary": "The journey passed 3/3 again under --repeat-each=3 on the candidate that clears grants on every fresh shell document and gates the mint channel on the trusted renderer, so neither change disturbs a live preview: the document still rendered, both browser counts held at baseline, and the target=_blank press still routed out as an Orca browser tab." + }, +{ + "date": "2026-08-26", + "runner": "local", + "platform": "macos", + "command": "SKIP_BUILD=1 ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-remote-html-preview-local-render.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1 --reporter=line --repeat-each=3", + "result": "passed", + "durationSeconds": 59.7, + "summary": "The renamed journey passed 3/3 under --repeat-each=3 on the hardened candidate: the host document rendered in the orca-preview guest, both browser counts held at baseline through the split, focus, reactivation and close phases, and the final phase's real target=_blank press routed out as an Orca browser tab with the routing call recorded as opened while the preview tab stayed open." }, { "date": "2026-08-12", @@ -3070,78 +3532,6 @@ "durationSeconds": 3.48, "summary": "Seven focused host and renderer contract files passed 183/183 tests, covering owning-runtime routing, focused and unfocused creation, placement, reconciliation, failure cleanup, and teardown." }, - { - "date": "2026-08-12", - "runner": "local", - "platform": "macos", - "command": "ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm exec playwright test tests/e2e/paired-remote-html-browser-focus.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1 --repeat-each=3", - "result": "passed", - "durationSeconds": 16.1, - "summary": "The candidate passed exact host/client cardinality, split stability, rendered DOM, focus authority, close convergence, and surviving editor and terminal." - }, - { - "date": "2026-08-12", - "runner": "local", - "platform": "macos", - "command": "SKIP_BUILD=1 ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-remote-html-browser-focus.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", - "result": "failed", - "durationSeconds": 11.6, - "summary": "Latest-main a90a18d43da333e1cc327779a3f9912a78308848 failed the byte-identical 5806b23f oracle because the HTML preview collapsed into the source editor group instead of retaining its requested split." - }, - { - "date": "2026-08-12", - "runner": "local", - "platform": "macos", - "command": "ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-remote-html-browser-focus.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", - "result": "passed", - "durationSeconds": 6.5, - "summary": "Rebased candidate b7de9c0961a4edf389a29079b2b4d5250184b57e passed the byte-identical 5806b23f oracle: one host page, one client workspace, requested split, rendered DOM, stable focus, and browser-only close." - }, - { - "date": "2026-08-12", - "runner": "local", - "platform": "macos", - "command": "ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-remote-html-browser-focus.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", - "result": "failed", - "durationSeconds": 10.9, - "summary": "Rebased candidate b7de9c0961a4edf389a29079b2b4d5250184b57e with only the side-preview owning-runtime routing seam disabled failed the byte-identical 5806b23f oracle with two client workspaces." - }, - { - "date": "2026-08-12", - "runner": "local", - "platform": "macos", - "command": "ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-remote-html-browser-focus.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", - "result": "passed", - "durationSeconds": 7.6, - "summary": "Final candidate d5767481b982b736e7cc5b4653b2a32a61280638 on merge-base 36d45af062ec4022265aabac8fc6a9d36eecedf1 passed the byte-identical 5806b23f oracle after final review and rebase: one host page, one client workspace, requested split, rendered DOM, stable focus, and browser-only close." - }, - { - "date": "2026-08-12", - "runner": "local", - "platform": "macos", - "command": "ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-remote-html-browser-focus.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", - "result": "failed", - "durationSeconds": 11.8, - "summary": "Latest-main 346e59c879 failed the byte-identical 2ec1dcf1 oracle with two client HTML browser workspaces instead of one." - }, - { - "date": "2026-08-12", - "runner": "local", - "platform": "macos", - "command": "ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-remote-html-browser-focus.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", - "result": "passed", - "durationSeconds": 8.4, - "summary": "Candidate d05217c40d passed the byte-identical 2ec1dcf1 oracle: one host page, one client workspace, stable split, rendered DOM, editor focus after creation, explicit-click browser focus, and browser-only close." - }, - { - "date": "2026-08-12", - "runner": "local", - "platform": "macos", - "command": "ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-remote-html-browser-focus.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", - "result": "failed", - "durationSeconds": 12.3, - "summary": "Candidate d05217c40d with only the side-preview focusOnCreate:false call disabled failed the byte-identical 2ec1dcf1 oracle because preview creation activated the browser instead of preserving editor focus." - }, { "date": "2026-08-13", "runner": "local", @@ -3171,7 +3561,7 @@ }, "redGreenEvidence": { "status": "complete", - "evidence": "The unchanged STA-4063 five-assertion oracle is red 5/5 on scan e4c278eb52 and latest main bc2e30000b, partially green 2/5 on pre-fix PR #14100, green 5/5 on candidate 14b5877a75, and red 3/5 when only the new transaction cleanup boundary is disabled on final candidate a9e6fb7716. The earlier STA-4025 transaction oracle remains red on the post-#13909 base and green on this candidate. The byte-identical STA-4231 headed oracle is red on latest-main b19d99bd00: both 15-second creates return runtime_timeout without a page ID while the host retains two distinct pages after retry. The relevant behavior is unchanged through 0a0ae974d4. The candidate is green with one returned canonical ID, one host/client page, and no retry; disabling only the navigation-independent acknowledgement is red with the same two orphaned pages. The byte-identical 2ec1dcf1 Electron oracle remains red on latest main 346e59c879, green on candidate d05217c40d, and red with only side-preview focus disabled; historical 5806b23f evidence separately proves owning-runtime routing." + "evidence": "The unchanged STA-4063 five-assertion oracle is red 5/5 on scan e4c278eb52 and latest main bc2e30000b, partially green 2/5 on pre-fix PR #14100, green 5/5 on candidate 14b5877a75, and red 3/5 when only the new transaction cleanup boundary is disabled on final candidate a9e6fb7716. The earlier STA-4025 transaction oracle remains red on the post-#13909 base and green on this candidate. The byte-identical STA-4231 headed oracle is red on latest-main b19d99bd00: both 15-second creates return runtime_timeout without a page ID while the host retains two distinct pages after retry. The relevant behavior is unchanged through 0a0ae974d4. The candidate is green with one returned canonical ID, one host/client page, and no retry; disabling only the navigation-independent acknowledgement is red with the same two orphaned pages. The byte-identical 2ec1dcf1 Electron oracle remains red on latest main 346e59c879, green on candidate d05217c40d, and red with only side-preview focus disabled; historical 5806b23f evidence separately proves owning-runtime routing. For the STA-5557 preview behavior the rewritten journey is green 1/1 on the candidate and red when the sole delta makes the same preview action also create a browser tab, failing on clientBrowserWorkspaceCount 1 versus baseline 0. That absence assertion is gated on first reading the rendered document out of the guest, so it cannot pass by the preview never happening. Against pre-STA-5557 builds the journey is red by construction rather than by measurement: those builds have no html-preview tab mode, so the run would fail earlier, at materialization, and it has not been executed against an old build. The reader-facing half is red-green at the unit layer. The 6/6 green and 2/6 red pair recorded earlier on 2026-08-26 measured the superseded shape, where a subresource failure was filtered out entirely; the shipping shape is green 10/10 and red 3/10 when the sole delta makes every failure, asset or entry, set the blocking reason. The containment lines the second review found uncovered are red-green too: installing the scheme handler on the default session instead of the preview session, and widening the scheme privileges with allowServiceWorkers and bypassCSP, each fail the protocol suite as the sole delta, and deleting the html-preview case from the closed-tab cleanup fails the hook suite. The external-link phase added on 2026-08-26 is red-green by measurement: green 3/3 on the trusted-click design, in which the guest's own preload reports a trusted press on an anchor and main answers only a focused preview guest holding a live grant. Its earlier shape depended on the webview carrying allowpopups and went red with zero recorded routing calls when only that attribute was dropped; the preload now intercepts the press before Chromium considers a popup, so the attribute is gone and that measurement retired with it. One further run failed in the paired-client harness before reaching any preview step, when the client window never exposed its store within the launch timeout; that is a harness flake, not a preview signal. The two egress fences added on 2026-08-26 are red-green by measurement in the same journey, whose fixture attempts its own escape on every load and again from inside the handler for the reader's press on the heading: with deny-only navigation sinks the document opens no browser tab and the counts hold at baseline, and with the sole delta that those sinks route again behind a two-second recent-input gate the post-press location.href opens a real tab, clientBrowserWorkspaceCountAllWorktrees 1 against a baseline of 0. An earlier measurement of that same gate, taken when it was the shipping design, showed the unattended on-load egress opening two tabs once the gate was removed entirely; it was retired with the gate, because a gate that cannot tell a script's navigation from the click it followed was never the fence the claim needed. With the guest's IP-handling policy the document reports candidates=0, and with the sole delta that the policy call is removed it reports candidates=6. Both mutants were applied to the shipping module, rebuilt, and run against the live paired journey rather than reasoned about." }, "performanceBudget": { "required": true, @@ -3184,9 +3574,14 @@ "knownGaps": [ "Live paired-server validation currently covers macOS only.", "Multiple simultaneous viewers are not exercised by the Electron journey.", - "Direct SSH remote file URLs remain intentionally unsupported and local file previews retain their existing path." + "Local workspaces intentionally keep the embedded browser tab at a file URL and are not covered by the preview journey.", + "The SSH preview owner has unit coverage only; no live SSH host renders a preview in this gate.", + "The preview journey exercises one self-contained document. Relative subresources, reload re-reading the workspace disk, and the truncated and out-of-worktree refusals are covered by unit contracts only.", + "Grant revocation on tab close is asserted at the unit layer; the journey observes the tab and open file disappearing, not the main-process registry.", + "The external-link route trusts a real press on an anchor in a focused preview guest, so it proves the reader pressed that anchor, not that the anchor's visible text matches its target. The tab it opens is visible, which is the remaining containment.", + "WebRTC is fenced by the IP-handling policy rather than by removing the API; the guest can still construct a peer connection, and the journey measures that it gathers nothing." ], - "demotionRule": "Demote if one paired HTML action can create duplicate client or host browser identities, lose the requested split, steal editor focus before an explicit browser click, lose browser focus after that click, or remove unrelated editor or terminal state on close." + "demotionRule": "Demote if a paired HTML preview creates a browser workspace on either side, renders from anything other than the orca-preview scheme, serves a read the host reported as truncated or over-cap, serves a document without the self-only content security policy, lets the preview session carry an off-machine request, reads an SSH path that canonicalizes outside its grant root, steals focus from the source editor when it opens, removes unrelated editor or terminal state on close, hides a document that rendered because one subresource was refused, leaves a grant readable after the tab or renderer that owns it is gone, mints one for a sender that is not the trusted renderer, opens a browser tab for a navigation the document started with no one at the keyboard, or lets the guest gather an ICE candidate; or if a paired browser creation this gate still owns produces duplicate client or host identities or loses its requested split." }, { "id": "browser-network-tunnel.bounded-fail-closed-route", diff --git a/electron.vite.config.ts b/electron.vite.config.ts index f81dbb93041..4faa844ee4c 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -209,6 +209,7 @@ export const electronViteConfig: UserConfig = { index: resolve('src/main/index.ts'), // Why: sandboxed webview preloads cannot load Rollup helper chunks. 'browser-window-close-preload': resolve('src/preload/browser-window-close.ts'), + 'doc-preview-link-preload': resolve('src/preload/doc-preview-link.ts'), 'daemon-entry': resolve('src/main/daemon/daemon-entry.ts'), 'plugin-host-entry': resolve('src/main/plugins/plugin-host-entry.ts'), 'computer-sidecar': resolve('src/main/computer/sidecar-entry.ts'), diff --git a/src/main/browser/browser-manager-annotation-bridge.test.ts b/src/main/browser/browser-manager-annotation-bridge.test.ts new file mode 100644 index 00000000000..2d9aa5e50fe --- /dev/null +++ b/src/main/browser/browser-manager-annotation-bridge.test.ts @@ -0,0 +1,210 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const browserMocks = vi.hoisted(() => ({ + appGetPathMock: vi.fn(() => '/downloads'), + shellOpenExternalMock: vi.fn(), + browserWindowFromWebContentsMock: vi.fn(), + menuBuildFromTemplateMock: vi.fn(), + guestOffMock: vi.fn(), + guestOnMock: vi.fn(), + guestSetBackgroundThrottlingMock: vi.fn(), + guestSetWindowOpenHandlerMock: vi.fn(), + guestOpenDevToolsMock: vi.fn(), + webContentsFromIdMock: vi.fn(), + screenGetCursorScreenPointMock: vi.fn(() => ({ x: 0, y: 0 })), + openPopupWithOriginBarMock: vi.fn() +})) + +vi.mock('electron', () => ({ + app: { getPath: browserMocks.appGetPathMock }, + BrowserWindow: { fromWebContents: browserMocks.browserWindowFromWebContentsMock }, + clipboard: { writeText: vi.fn() }, + shell: { openExternal: browserMocks.shellOpenExternalMock }, + Menu: { buildFromTemplate: browserMocks.menuBuildFromTemplateMock }, + screen: { getCursorScreenPoint: browserMocks.screenGetCursorScreenPointMock }, + webContents: { fromId: browserMocks.webContentsFromIdMock } +})) + +vi.mock('./popup-origin-bar-window', () => ({ + openPopupWithOriginBar: browserMocks.openPopupWithOriginBarMock +})) + +import { browserManager } from './browser-manager' +import type { BrowserAnnotationViewportBridgeOptions } from '../../shared/browser-annotation-viewport-bridge' +import { + rendererWebContentsId, + resetBrowserManagerMocks, + resetBrowserManagerState +} from './browser-manager-test-harness' +import { + createViewportGuestFactory, + flushViewportOps +} from './browser-manager-viewport-test-fixtures' + +const { webContentsFromIdMock } = browserMocks +const makeGuest = createViewportGuestFactory(browserMocks) + +const BRIDGE_OPTIONS: BrowserAnnotationViewportBridgeOptions = { + emitViewport: false, + enabled: true, + markers: [], + token: 'annotationviewporttoken' +} + +function registerPage(pageId: string, guest: Record): void { + webContentsFromIdMock.mockReturnValue(guest) + browserManager.attachGuestPolicies(guest as never) + browserManager.registerGuest({ + browserPageId: pageId, + webContentsId: guest.id as number, + rendererWebContentsId + }) +} + +/** The production resolver's shape: read the registry now, not when the request was made. */ +function resolveFromRegistry(pageId: string): () => Electron.WebContents | null { + return () => browserManager.getAuthorizedGuest(pageId, rendererWebContentsId) +} + +describe('browserManager.setAnnotationViewportBridge', () => { + beforeEach(() => { + resetBrowserManagerMocks(browserMocks) + resetBrowserManagerState() + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('installs the bridge in an isolated world', async () => { + const { guest } = makeGuest(4646) + registerPage('tab-annotations', guest) + + const ok = await browserManager.setAnnotationViewportBridge( + 'tab-annotations', + BRIDGE_OPTIONS, + resolveFromRegistry('tab-annotations') + ) + + expect(ok).toBe(true) + expect(guest.executeJavaScriptInIsolatedWorld).toHaveBeenCalledWith( + expect.any(Number), + [ + expect.objectContaining({ + code: expect.stringContaining('__orcaBrowserAnnotationViewportBridge') + }) + ], + false + ) + }) + + // Why this is the case that matters: a cross-process navigation re-registers the same page id + // with a new WebContents and leaves the retired one alive, so a bridge op that resolved its + // guest when the request arrived would inject into a page nobody is looking at — and the + // annotation badges would stop tracking the document on screen. + it('injects into the guest the page has when a queued op finally runs, not the one it was asked with', async () => { + const { guest: firstGuest } = makeGuest(5101) + let releaseFirstInjection = (): void => {} + // One shared gate for every call, so a wrongly-routed second injection still settles and the + // test fails on where it landed rather than on a timeout. + const firstInjectionGate = new Promise((resolve) => { + releaseFirstInjection = () => resolve() + }) + firstGuest.executeJavaScriptInIsolatedWorld = vi.fn(() => firstInjectionGate) + registerPage('tab-swap', firstGuest) + + const firstDone = browserManager.setAnnotationViewportBridge( + 'tab-swap', + BRIDGE_OPTIONS, + resolveFromRegistry('tab-swap') + ) + await flushViewportOps() + + // A second request arrives while the first still holds the chain — at this moment the page is + // still on the first guest, which is the guest a request-time resolution would capture. + const secondDone = browserManager.setAnnotationViewportBridge( + 'tab-swap', + BRIDGE_OPTIONS, + resolveFromRegistry('tab-swap') + ) + await flushViewportOps() + + // Only now does the page swap renderer processes, while the second op is still queued. + const { guest: secondGuest } = makeGuest(5102) + registerPage('tab-swap', secondGuest) + + releaseFirstInjection() + await expect(firstDone).resolves.toBe(true) + await expect(secondDone).resolves.toBe(true) + + expect(secondGuest.executeJavaScriptInIsolatedWorld).toHaveBeenCalledTimes(1) + expect(firstGuest.executeJavaScriptInIsolatedWorld).toHaveBeenCalledTimes(1) + }) + + // Why this is the resolver's cleanup and not the bridge's: the authority that reads the registry + // is the one that can see the guest is gone. The bridge only reports the refusal. + it('refuses when its guest died while the op was queued, and the resolver drops the registration', async () => { + const { guest } = makeGuest(5103) + registerPage('tab-dies', guest) + expect(browserManager.getGuestWebContentsId('tab-dies')).toBe(5103) + + webContentsFromIdMock.mockReturnValue(null) + await expect( + browserManager.setAnnotationViewportBridge( + 'tab-dies', + BRIDGE_OPTIONS, + resolveFromRegistry('tab-dies') + ) + ).resolves.toBe(false) + + // Why assert the registry and not just the answer: a stale guest has to clear every per-tab + // entry, or the page keeps a dead WebContents id that later ops resolve against. + expect(browserManager.getGuestWebContentsId('tab-dies')).toBeNull() + }) + + // Why this one exists: an unresolved guest is not the same as a dead one. A request addressed by + // the wrong renderer names a page that is alive and on screen, and answering it with teardown + // would cancel that page's in-flight downloads and grabs over a misaddressed message. + it('refuses a request from the wrong renderer without tearing down the healthy page it named', async () => { + const { guest } = makeGuest(5105) + registerPage('tab-mismatch', guest) + const unregisterGuest = vi.spyOn(browserManager, 'unregisterGuest') + + await expect( + browserManager.setAnnotationViewportBridge('tab-mismatch', BRIDGE_OPTIONS, () => + browserManager.getAuthorizedGuest('tab-mismatch', rendererWebContentsId + 1) + ) + ).resolves.toBe(false) + + expect(unregisterGuest).not.toHaveBeenCalled() + expect(browserManager.getGuestWebContentsId('tab-mismatch')).toBe(5105) + expect(guest.executeJavaScriptInIsolatedWorld).not.toHaveBeenCalled() + }) + + it('refuses a destroyed guest without injecting into it', async () => { + const { guest } = makeGuest(5104) + registerPage('tab-destroyed', guest) + ;(guest.isDestroyed as ReturnType).mockReturnValue(true) + + await expect( + browserManager.setAnnotationViewportBridge( + 'tab-destroyed', + BRIDGE_OPTIONS, + () => guest as never + ) + ).resolves.toBe(false) + expect(guest.executeJavaScriptInIsolatedWorld).not.toHaveBeenCalled() + }) + + // Why: a document page owns no browsing state, so treating its refusal as a stale page would run + // teardown against an id the browsing registry never held. + it('does not run page teardown when a document page resolves to nothing', async () => { + const unregisterGuest = vi.spyOn(browserManager, 'unregisterGuest') + + await expect( + browserManager.setAnnotationViewportBridge('doc-page-1', BRIDGE_OPTIONS, () => null) + ).resolves.toBe(false) + + expect(unregisterGuest).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/browser/browser-manager-guest-lifecycle.test.ts b/src/main/browser/browser-manager-guest-lifecycle.test.ts index d1a6a16c085..6d71db94585 100644 --- a/src/main/browser/browser-manager-guest-lifecycle.test.ts +++ b/src/main/browser/browser-manager-guest-lifecycle.test.ts @@ -45,6 +45,32 @@ import { resetBrowserManagerMocks, resetBrowserManagerState } from './browser-manager-test-harness' +import { installDocPreviewGuestPolicy } from './doc-preview-guest-policy' +import { mintDocPreviewGrant, revokeAllDocPreviewGrants } from './doc-preview-grant-registry' +import { buildDocPreviewUrl } from '../../shared/doc-preview-scheme' + +/** + * A page the document half of the registry really holds. Built rather than named: membership is + * what both doors refuse on now, so an id that merely looks like a preview's would be admitted. + */ +function registerWorkspaceDocPage(browserPageId: string): void { + const grant = mintDocPreviewGrant({ + owner: { kind: 'ssh', connectionId: 'ssh-1' }, + root: '/home/alice/docs', + entryRelativePath: 'index.html', + browserPageId + }) + const guest = { + isFocused: () => false, + isDestroyed: () => false, + getURL: () => buildDocPreviewUrl(grant.id, grant.entryRelativePath), + on: vi.fn(), + once: vi.fn(), + setWindowOpenHandler: vi.fn(), + setWebRTCIPHandlingPolicy: vi.fn() + } + installDocPreviewGuestPolicy(guest as never, { id: rendererWebContentsId, send: vi.fn() }) +} const { guestOffMock, @@ -60,6 +86,7 @@ describe('browserManager', () => { beforeEach(() => { resetBrowserManagerMocks(browserMocks) resetBrowserManagerState() + revokeAllDocPreviewGrants() }) afterEach(() => { @@ -92,6 +119,77 @@ describe('browserManager', () => { expect(browserManager.getSessionProfileIdForTab('browser-1')).toBe('work') }) + // Why both doors: one id in both halves of the registry would make the tool door answer with a + // document guest for a page the reader is browsing in. + it.each(['registerGuest', 'registerOffscreenGuest'] as const)( + 'refuses %s for a page the document registry already holds', + (entryPoint) => { + const guest = { + id: 129, + isDestroyed: vi.fn(() => false), + getType: vi.fn(() => 'webview'), + setBackgroundThrottling: guestSetBackgroundThrottlingMock, + setWindowOpenHandler: guestSetWindowOpenHandlerMock, + on: guestOnMock, + off: guestOffMock, + openDevTools: guestOpenDevToolsMock + } + webContentsFromIdMock.mockReturnValue(guest) + browserManager.attachGuestPolicies(guest as never) + const browserPageId = 'doc-page-1' + registerWorkspaceDocPage(browserPageId) + + if (entryPoint === 'registerGuest') { + expect( + browserManager.registerGuest({ + browserPageId, + webContentsId: guest.id, + rendererWebContentsId + }) + ).toBe(false) + } else { + expect( + browserManager.registerOffscreenGuest({ browserPageId, webContentsId: guest.id }) + ).toBe(false) + } + + expect(browserManager.getGuestWebContentsId(browserPageId)).toBeNull() + } + ) + + // Why this answer is load-bearing: the headless backend destroys its window on false, so a true + // for a guest that is already gone would leave a page id registered onto nothing. + it.each(['missing', 'destroyed'] as const)( + 'refuses registerOffscreenGuest when the named guest is %s', + (guestState) => { + webContentsFromIdMock.mockReturnValue( + guestState === 'missing' ? null : { id: 137, isDestroyed: vi.fn(() => true) } + ) + + expect( + browserManager.registerOffscreenGuest({ browserPageId: 'offscreen-1', webContentsId: 137 }) + ).toBe(false) + + expect(browserManager.getGuestWebContentsId('offscreen-1')).toBeNull() + } + ) + + // Why the exit door needs the same check: a document page withdraws by revoking its grant, so its + // id here is misaddressed — and unregistering opens by evicting whatever grab that id names. + it('refuses unregisterGuest for a page the document registry holds', () => { + registerWorkspaceDocPage('doc-page-2') + const cancelGrabOp = vi.spyOn(browserManager, 'cancelGrabOp') + + browserManager.unregisterGuest('doc-page-2') + + expect(cancelGrabOp).not.toHaveBeenCalled() + + // The presence half: the same door does evict a browsing page's grab. + browserManager.unregisterGuest('browser-page-1') + expect(cancelGrabOp).toHaveBeenCalledWith('browser-page-1', 'evicted') + cancelGrabOp.mockRestore() + }) + it('blocks non-web guest navigations after attach', () => { const guest = { isDestroyed: vi.fn(() => false), diff --git a/src/main/browser/browser-manager-guest-policy-profile.test.ts b/src/main/browser/browser-manager-guest-policy-profile.test.ts new file mode 100644 index 00000000000..47871f64ea9 --- /dev/null +++ b/src/main/browser/browser-manager-guest-policy-profile.test.ts @@ -0,0 +1,250 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const browserMocks = vi.hoisted(() => ({ + appGetPathMock: vi.fn(() => '/downloads'), + shellOpenExternalMock: vi.fn(), + browserWindowFromWebContentsMock: vi.fn(), + menuBuildFromTemplateMock: vi.fn(), + guestOffMock: vi.fn(), + guestOnMock: vi.fn(), + guestSetBackgroundThrottlingMock: vi.fn(), + guestSetWindowOpenHandlerMock: vi.fn(), + guestOpenDevToolsMock: vi.fn(), + webContentsFromIdMock: vi.fn(), + screenGetCursorScreenPointMock: vi.fn(() => ({ x: 0, y: 0 })), + openPopupWithOriginBarMock: vi.fn() +})) + +vi.mock('electron', () => ({ + app: { getPath: browserMocks.appGetPathMock }, + BrowserWindow: { fromWebContents: browserMocks.browserWindowFromWebContentsMock }, + clipboard: { writeText: vi.fn() }, + shell: { openExternal: browserMocks.shellOpenExternalMock }, + Menu: { buildFromTemplate: browserMocks.menuBuildFromTemplateMock }, + screen: { getCursorScreenPoint: browserMocks.screenGetCursorScreenPointMock }, + webContents: { fromId: browserMocks.webContentsFromIdMock } +})) +vi.mock('./popup-origin-bar-window', () => ({ + openPopupWithOriginBar: browserMocks.openPopupWithOriginBarMock +})) + +import { browserManager } from './browser-manager' +import { resetBrowserManagerMocks, resetBrowserManagerState } from './browser-manager-test-harness' +import { getWorkspaceDocPageGuest } from './doc-preview-guest-policy' +import { mintDocPreviewGrant, revokeAllDocPreviewGrants } from './doc-preview-grant-registry' +import { buildDocPreviewUrl } from '../../shared/doc-preview-scheme' + +type GuestFake = { + id: number + url: string + listeners: Map void)[]> + windowOpenHandler: ((details: { url: string; frameName: string }) => unknown) | null + webRtcPolicy: string | null + isDestroyed: () => boolean + isFocused: () => boolean + getURL: () => string + getType: () => string + setBackgroundThrottling: (value: boolean) => void + setWebRTCIPHandlingPolicy: (policy: string) => void + setWindowOpenHandler: (handler: (details: { url: string; frameName: string }) => unknown) => void + executeJavaScriptInIsolatedWorld: ReturnType + debugger: { + isAttached: () => boolean + attach: ReturnType + sendCommand: ReturnType + } + on: (event: string, listener: (...args: never[]) => void) => void + once: (event: string, listener: (...args: never[]) => void) => void + off: (event: string, listener: (...args: never[]) => void) => void +} + +function createGuest(id: number, url: string): GuestFake { + const listeners = new Map void)[]>() + const guest: GuestFake = { + id, + url, + listeners, + windowOpenHandler: null, + webRtcPolicy: null, + isDestroyed: () => false, + isFocused: () => true, + getURL: () => guest.url, + getType: () => 'webview', + setBackgroundThrottling: vi.fn(), + setWebRTCIPHandlingPolicy: (policy: string) => { + guest.webRtcPolicy = policy + }, + setWindowOpenHandler: (handler) => { + guest.windowOpenHandler = handler + }, + executeJavaScriptInIsolatedWorld: vi.fn(async () => undefined), + debugger: { + isAttached: () => true, + attach: vi.fn(), + sendCommand: vi.fn(async () => undefined) + }, + on: (event, listener) => { + listeners.set(event, [...(listeners.get(event) ?? []), listener]) + }, + once: (event, listener) => { + listeners.set(event, [...(listeners.get(event) ?? []), listener]) + }, + off: (event, listener) => { + listeners.set( + event, + (listeners.get(event) ?? []).filter((entry) => entry !== listener) + ) + } + } + return guest +} + +function listenerCount(guest: GuestFake, event: string): number { + return guest.listeners.get(event)?.length ?? 0 +} + +/** Drives the guest's own will-navigate listeners and reports whether they refused. */ +function navigateTo(guest: GuestFake, url: string): boolean { + let prevented = false + const event = { preventDefault: () => (prevented = true) } as never + for (const listener of guest.listeners.get('will-navigate') ?? []) { + ;(listener as (event: unknown, url: string) => void)(event, url) + } + return prevented +} + +const host = { id: 5001, send: vi.fn() } as unknown as Electron.WebContents + +beforeEach(() => { + resetBrowserManagerMocks(browserMocks) + resetBrowserManagerState() + revokeAllDocPreviewGrants() + browserMocks.guestSetBackgroundThrottlingMock.mockReturnValue(undefined) +}) + +describe('guest policy profiles', () => { + function attachPreviewGuest(id = 301): { + guest: GuestFake + grantId: string + browserPageId: string + } { + const browserPageId = `doc-page-${id}` + const grant = mintDocPreviewGrant({ + owner: { kind: 'ssh', connectionId: 'ssh-1' }, + root: '/home/alice/docs', + entryRelativePath: 'index.html', + browserPageId + }) + const guest = createGuest(id, buildDocPreviewUrl(grant.id, 'index.html')) + browserManager.attachGuestPolicies(guest as never, null, { profile: 'workspace-doc', host }) + return { guest, grantId: grant.id, browserPageId } + } + + // The presence half of every absence below: a browsing guest observably takes all of it through + // the same method, so a profile that fenced nothing — or an attach path that stopped installing + // anything at all — cannot pass these by being uniformly empty. + it('gives a browsing guest link routing, popups and anti-detection', () => { + const guest = createGuest(300, 'https://example.com/') + + browserManager.attachGuestPolicies(guest as never) + + expect(listenerCount(guest, 'dom-ready')).toBe(1) + expect(listenerCount(guest, 'frame-created')).toBe(1) + expect(listenerCount(guest, 'did-create-window')).toBe(1) + expect(guest.debugger.sendCommand).toHaveBeenCalled() + expect(navigateTo(guest, 'https://elsewhere.example/')).toBe(false) + }) + + it('gives a workspace-document guest none of it', () => { + const { guest } = attachPreviewGuest() + + expect(listenerCount(guest, 'dom-ready')).toBe(0) + expect(listenerCount(guest, 'frame-created')).toBe(0) + expect(listenerCount(guest, 'did-create-window')).toBe(0) + expect(guest.debugger.sendCommand).not.toHaveBeenCalled() + expect(guest.executeJavaScriptInIsolatedWorld).not.toHaveBeenCalled() + }) + + it('holds a workspace-document guest inside the grant it is showing', () => { + const { guest, grantId } = attachPreviewGuest() + + expect(navigateTo(guest, buildDocPreviewUrl(grantId, 'other.html'))).toBe(false) + expect(navigateTo(guest, 'https://example.com/')).toBe(true) + expect(guest.webRtcPolicy).toBe('disable_non_proxied_udp') + }) + + it('denies every window a workspace-document guest asks for', () => { + const { guest } = attachPreviewGuest() + + expect(guest.windowOpenHandler?.({ url: 'https://example.com/', frameName: '' })).toEqual({ + action: 'deny' + }) + }) + + // Why teardown and not just install: registration and teardown both key on the guest having been + // policy-attached, so a profile that installs outside that bookkeeping leaves the id marked + // attached forever and stays answerable to tools after the surface is gone. + it('tears a workspace-document guest down through the same policy cleanup', () => { + const { guest, browserPageId } = attachPreviewGuest(302) + expect(getWorkspaceDocPageGuest(browserPageId, host.id)).toBe(guest as never) + + for (const listener of guest.listeners.get('destroyed') ?? []) { + listener() + } + + expect(getWorkspaceDocPageGuest(browserPageId, host.id)).toBeNull() + // The manager's own bookkeeping was reached too: a second attach is refused while the id is + // still marked policy-attached, and accepted once its teardown has run. + browserManager.attachGuestPolicies(guest as never) + expect(listenerCount(guest, 'dom-ready')).toBe(1) + }) + + // The seam the whole split rests on: one public door answers for both halves, and each page id + // resolves in exactly one of them. Asserted against the real manager, because the IPC census + // test has to mock it. + describe('the one door across both halves of the registry', () => { + const BROWSING_PAGE_ID = 'browser-page-1' + + function registerBrowsingGuest(id = 400): GuestFake { + const guest = createGuest(id, 'https://example.com/') + browserMocks.webContentsFromIdMock.mockReturnValue(guest) + browserManager.attachGuestPolicies(guest as never) + browserManager.registerGuest({ + browserPageId: BROWSING_PAGE_ID, + workspaceId: 'workspace-1', + worktreeId: 'wt-1', + webContentsId: id, + rendererWebContentsId: host.id + }) + return guest + } + + it('answers each page with the guest of its own half', () => { + const browsing = registerBrowsingGuest() + const { guest: document, browserPageId } = attachPreviewGuest() + + expect(browserManager.getAuthorizedGuest(BROWSING_PAGE_ID, host.id)).toBe(browsing as never) + expect(browserManager.getAuthorizedGuest(browserPageId, host.id)).toBe(document as never) + }) + + // Why this is the containment claim and not a lookup detail: page management, agent commands, + // download routing and certificate attribution all read the browsing map directly, so a + // document page being absent from it is what fences them without a guard of their own. + it('keeps a document page out of the browsing map entirely', () => { + registerBrowsingGuest() + const { browserPageId } = attachPreviewGuest() + + expect(browserManager.getGuestWebContentsId(browserPageId)).toBeNull() + expect([...browserManager.getWebContentsIdByTabId().keys()]).toEqual([BROWSING_PAGE_ID]) + }) + }) + + it('attaches a guest once whatever profile it was asked for', () => { + const { guest } = attachPreviewGuest(303) + + browserManager.attachGuestPolicies(guest as never) + + expect(listenerCount(guest, 'dom-ready')).toBe(0) + expect(listenerCount(guest, 'will-navigate')).toBe(1) + }) +}) diff --git a/src/main/browser/browser-manager-viewport-override.test.ts b/src/main/browser/browser-manager-viewport-override.test.ts index c72936cf6a3..b7d3bbabe0a 100644 --- a/src/main/browser/browser-manager-viewport-override.test.ts +++ b/src/main/browser/browser-manager-viewport-override.test.ts @@ -882,34 +882,5 @@ describe('browserManager', () => { expect(debuggerAttach).toHaveBeenCalledWith('1.3') expect(debuggerSendCommand).not.toHaveBeenCalled() }) - - it('installs annotation viewport bridge in an isolated world', async () => { - const { guest } = makeGuest(4646) - webContentsFromIdMock.mockReturnValue(guest) - browserManager.attachGuestPolicies(guest as never) - browserManager.registerGuest({ - browserPageId: 'tab-annotations', - webContentsId: guest.id as number, - rendererWebContentsId - }) - - const ok = await browserManager.setAnnotationViewportBridge('tab-annotations', { - emitViewport: false, - enabled: true, - markers: [], - token: 'annotationviewporttoken' - }) - - expect(ok).toBe(true) - expect(guest.executeJavaScriptInIsolatedWorld).toHaveBeenCalledWith( - expect.any(Number), - [ - expect.objectContaining({ - code: expect.stringContaining('__orcaBrowserAnnotationViewportBridge') - }) - ], - false - ) - }) }) }) diff --git a/src/main/browser/browser-manager.ts b/src/main/browser/browser-manager.ts index 7ac64b485b9..2b43e17a371 100644 --- a/src/main/browser/browser-manager.ts +++ b/src/main/browser/browser-manager.ts @@ -63,6 +63,11 @@ import { BROWSER_ANNOTATION_VIEWPORT_BRIDGE_WORLD_ID, buildBrowserAnnotationViewportBridgeScript } from '../../shared/browser-annotation-viewport-bridge' +import { + getWorkspaceDocPageGuest, + installDocPreviewGuestPolicy, + isWorkspaceDocPageId +} from './doc-preview-guest-policy' import type { KeybindingOverrides } from '../../shared/keybindings' import { BrowserCertificateTrustController, @@ -157,6 +162,15 @@ type PopupOwnerContext = { browserTabId: string rootGuestWebContentsId: number } +/** + * What a guest is allowed to be. A browsing guest is the web — popups, clicked-link routing and + * anti-detection all apply. A workspace-document guest renders one granted document and gets none + * of that; `host` is the renderer that minted its grant, and the only sink for what it reports. + */ +export type BrowserGuestPolicy = + | { profile: 'browsing' } + | { profile: 'workspace-doc'; host: Electron.WebContents } +const BROWSING_GUEST_POLICY: BrowserGuestPolicy = { profile: 'browsing' } type PendingMainFrameNavigation = { currentUrl: string supersededUrls: string[] @@ -671,12 +685,20 @@ export class BrowserManager { attachGuestPolicies( guest: Electron.WebContents, - inheritedOwnerContext: PopupOwnerContext | null = null + inheritedOwnerContext: PopupOwnerContext | null = null, + policy: BrowserGuestPolicy = BROWSING_GUEST_POLICY ): void { if (this.policyAttachedGuestIds.has(guest.id)) { return } this.policyAttachedGuestIds.add(guest.id) + // Why one door with a profile rather than a second installer beside it: whether a guest was + // policy-attached at all is what registration and teardown both key on, so a guest that took + // another path into the app is invisible to both. + if (policy.profile === 'workspace-doc') { + this.attachWorkspaceDocGuestPolicies(guest, policy.host) + return + } if (inheritedOwnerContext) { this.popupOwnerContextByGuestId.set(guest.id, inheritedOwnerContext) } @@ -1016,6 +1038,30 @@ export class BrowserManager { }) } + /** + * A workspace document is not the web: no popups, no link routing, no anti-detection, and no + * navigation bookkeeping for chrome it does not have. What it does share with a browsing guest is + * this method's teardown, so a retired preview drops its listeners on the same path. + */ + private attachWorkspaceDocGuestPolicies( + guest: Electron.WebContents, + host: Electron.WebContents + ): void { + const disposeDocPolicy = installDocPreviewGuestPolicy(guest, host) + const handleDestroyed = (): void => { + this.cleanupGuestPolicyAttachment(guest.id) + } + guest.on('destroyed', handleDestroyed) + this.policyCleanupByGuestId.set(guest.id, () => { + disposeDocPolicy() + try { + guest.off('destroyed', handleDestroyed) + } catch { + // guest may already be destroyed + } + }) + } + // Why: navigator.userAgent (read by Google's auth JS) reflects the WebContents UA, // not the request header, so the header-level Firefox switch in setupClientHintsOverride // must be matched here per navigation or the two layers disagree — itself a bot tell. @@ -1318,7 +1364,9 @@ export class BrowserManager { rendererWebContentsId }: BrowserGuestRegistration): boolean { const browserTabId = browserPageId ?? legacyBrowserTabId - if (!browserTabId) { + // Why refuse rather than overwrite: the two halves of the registry must stay disjoint, or one + // id resolves in both and the tool door silently prefers the document guest over the page. + if (!browserTabId || isWorkspaceDocPageId(browserTabId)) { return false } // Why: on guest-surface swap, cancel any grab bound to the old guest's listeners so it doesn't strand on a stale webContents. @@ -1377,6 +1425,12 @@ export class BrowserManager { } unregisterGuest(browserTabId: string): void { + // Why the check on the exit door too: a document page withdraws by revoking its grant, never + // through here, so its id arriving is misaddressed — and the cancel below would evict that + // preview's live grab on the strength of it. + if (isWorkspaceDocPageId(browserTabId)) { + return + } // Why: teardown mid-grab must cancel it so the renderer gets a signal, not a dangling Promise. this.cancelGrabOp(browserTabId, 'evicted') @@ -1444,10 +1498,15 @@ export class BrowserManager { sessionProfileId?: string | null userAgentMode?: BrowserSessionUserAgentMode webContentsId: number - }): void { + }): boolean { + // Why the same check on both registration doors: one id resolving in both halves is the exact + // confusion the split registries exist to prevent. + if (isWorkspaceDocPageId(browserPageId)) { + return false + } const guest = webContents.fromId(webContentsId) if (!guest || guest.isDestroyed()) { - return + return false } // Why: offscreen pages have no renderer webview listeners, so main owns their load-failure lifecycle. this.offscreenGuestIds.add(webContentsId) @@ -1468,6 +1527,7 @@ export class BrowserManager { this.worktreeIdByTabId.set(browserPageId, worktreeId) } this.certificateTrustController?.onGuestRegistered(webContentsId, browserPageId) + return true } unregisterAll(): void { @@ -1843,12 +1903,13 @@ export class BrowserManager { async setAnnotationViewportBridge( browserTabId: string, - options: BrowserAnnotationViewportBridgeOptions + options: BrowserAnnotationViewportBridgeOptions, + resolveGuest: () => Electron.WebContents | null ): Promise { const prev = this.annotationViewportBridgeOpsByTabId.get(browserTabId) ?? Promise.resolve() const next = prev .catch(() => {}) - .then(() => this.doSetAnnotationViewportBridgeImpl(browserTabId, options)) + .then(() => this.doSetAnnotationViewportBridgeImpl(options, resolveGuest)) this.annotationViewportBridgeOpsByTabId.set(browserTabId, next) try { return await next @@ -1859,18 +1920,22 @@ export class BrowserManager { } } + // Why the caller resolves the guest: the same bridge serves browsing pages and workspace + // documents, which live in different halves of the page registry. + // Why a resolver and not the guest itself: this op may have waited behind another one, and a + // cross-process navigation meanwhile swaps the tab's contents without destroying the old one — + // injecting into the guest the request named would bridge a page nobody is looking at. + // Why no tab id: with teardown gone this reaches only the guest the resolver hands back, and + // taking an id it cannot act on would invite the next reader to act on it. private async doSetAnnotationViewportBridgeImpl( - browserTabId: string, - options: BrowserAnnotationViewportBridgeOptions + options: BrowserAnnotationViewportBridgeOptions, + resolveGuest: () => Electron.WebContents | null ): Promise { - const webContentsId = this.webContentsIdByTabId.get(browserTabId) - if (!webContentsId) { - return false - } - const guest = webContents.fromId(webContentsId) + // Why no teardown here: the resolver already unregisters a page whose guest died, and the only + // case it uniquely leaves is an ownership mismatch on a healthy page — where tearing down would + // cancel that page's in-flight downloads and grabs over a request that was merely misaddressed. + const guest = resolveGuest() if (!guest || guest.isDestroyed()) { - // Why: a stale guest must clear every per-tab registry entry, not just the WebContents maps. - this.unregisterGuest(browserTabId) return false } @@ -1977,10 +2042,21 @@ export class BrowserManager { // --- Browser Context Grab — main-owned operations --- /** Validate that the sender owns browserTabId; returns the guest WebContents or null. */ + /** + * The guest a request from `senderWebContentsId` may act on, across both halves of the page + * registry. This is the only door taught about workspace-document guests: they are kept out of + * the browsing maps entirely, so page management, agent commands, download routing and + * certificate attribution all miss them without a guard of their own — and a reader who opens a + * tool on the document in front of them still gets an answer. + */ getAuthorizedGuest( browserTabId: string, senderWebContentsId: number ): Electron.WebContents | null { + const docGuest = getWorkspaceDocPageGuest(browserTabId, senderWebContentsId) + if (docGuest) { + return docGuest + } const registeredRenderer = this.rendererWebContentsIdByTabId.get(browserTabId) if (registeredRenderer == null || registeredRenderer !== senderWebContentsId) { return null diff --git a/src/main/browser/browser-session-partition-policies.test.ts b/src/main/browser/browser-session-partition-policies.test.ts new file mode 100644 index 00000000000..a3667154c9b --- /dev/null +++ b/src/main/browser/browser-session-partition-policies.test.ts @@ -0,0 +1,185 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { BrowserSessionProfile } from '../../shared/browser-workspace-types' + +const mocks = vi.hoisted(() => ({ + handleGuestWillDownload: vi.fn(), + noticeDocPreviewDownloadBlocked: vi.fn() +})) + +type WillDownloadListener = ( + event: { preventDefault: () => void }, + item: { id: string }, + webContents: { id: number } +) => void + +type FakeSession = { + listeners: WillDownloadListener[] + on: ReturnType + removeListener: ReturnType + getUserAgent: () => string + setUserAgent: ReturnType + setPermissionRequestHandler: ReturnType + setPermissionCheckHandler: ReturnType + setDisplayMediaRequestHandler: ReturnType +} + +const sessionsByPartition = new Map() + +function fakeSession(): FakeSession { + const listeners: WillDownloadListener[] = [] + return { + listeners, + on: vi.fn((event: string, listener: WillDownloadListener) => { + if (event === 'will-download') { + listeners.push(listener) + } + }), + removeListener: vi.fn((event: string, listener: WillDownloadListener) => { + if (event !== 'will-download') { + return + } + const index = listeners.indexOf(listener) + if (index !== -1) { + listeners.splice(index, 1) + } + }), + getUserAgent: () => 'Mozilla/5.0 Orca', + setUserAgent: vi.fn(), + setPermissionRequestHandler: vi.fn(), + setPermissionCheckHandler: vi.fn(), + setDisplayMediaRequestHandler: vi.fn() + } +} + +vi.mock('electron', () => ({ + session: { + fromPartition: (partition: string) => { + const existing = sessionsByPartition.get(partition) + if (existing) { + return existing + } + const created = fakeSession() + sessionsByPartition.set(partition, created) + return created + } + } +})) +vi.mock('./browser-manager', () => ({ + browserManager: { + handleGuestWillDownload: mocks.handleGuestWillDownload, + installCertificateRequestGuard: vi.fn(), + removeCertificateRequestGuard: vi.fn(), + notifyPermissionDenied: vi.fn() + } +})) +vi.mock('./doc-preview-download-block-notice', () => ({ + noticeDocPreviewDownloadBlocked: mocks.noticeDocPreviewDownloadBlocked +})) +vi.mock('./browser-media-access', () => ({ + hasSystemMediaAccess: () => false, + requestSystemMediaAccess: async () => false +})) +vi.mock('./browser-session-ua', () => ({ + cleanElectronUserAgent: (userAgent: string) => userAgent, + setupClientHintsOverride: vi.fn() +})) +vi.mock('./browser-session-user-agent-mode', () => ({ + setBrowserSessionUserAgentMode: vi.fn() +})) +vi.mock('./browser-webauthn-access', () => ({ + allowsBrowserWebAuthnPermission: () => false, + clearBrowserWebAuthnAccessHandlers: vi.fn(), + installBrowserWebAuthnAccessHandlers: vi.fn() +})) + +type PartitionPolicyInstaller = ( + profile: BrowserSessionProfile, + options?: { downloads?: 'route' | 'deny' } +) => void + +// Why imported per test rather than at the top: the installer remembers which partitions it has +// already configured in module state, so a shared import would make the second test's install a +// no-op and leave it reading the first test's listener. +async function loadInstaller(): Promise { + const module = await import('./browser-session-partition-policies') + return module.installBrowserSessionPartitionPolicies +} + +function profileFor(partition: string): BrowserSessionProfile { + return { + id: partition, + scope: 'isolated', + partition, + label: partition, + source: null, + userAgentMode: 'clean' + } +} + +/** Fires the partition's real `will-download` listener and reports what it decided. */ +function fireWillDownload(partition: string): { cancelled: boolean } { + const sess = sessionsByPartition.get(partition) + if (!sess || sess.listeners.length !== 1) { + throw new Error(`expected exactly one will-download listener on ${partition}`) + } + let cancelled = false + sess.listeners[0]({ preventDefault: () => (cancelled = true) }, { id: 'item-1' }, { id: 42 }) + return { cancelled } +} + +beforeEach(() => { + vi.clearAllMocks() + sessionsByPartition.clear() + vi.resetModules() +}) + +describe('partition download policy', () => { + // The presence half: without it, a deny assertion passes for a partition that installed no + // listener at all, and would keep passing if the whole download path were removed. + it('routes a download on a partition that did not ask for the deny', async () => { + const install = await loadInstaller() + install(profileFor('persist:browsing-1')) + + expect(fireWillDownload('persist:browsing-1').cancelled).toBe(false) + expect(mocks.handleGuestWillDownload).toHaveBeenCalledWith( + expect.objectContaining({ guestWebContentsId: 42 }) + ) + }) + + it('cancels a download on a partition that asked for the deny, routing nothing', async () => { + const install = await loadInstaller() + install(profileFor('orca-doc-preview'), { downloads: 'deny' }) + + expect(fireWillDownload('orca-doc-preview').cancelled).toBe(true) + expect(mocks.handleGuestWillDownload).not.toHaveBeenCalled() + }) + + // Why in the same run as the routing test above: a refusal the reader cannot see is a pressed + // button that does nothing, and a notice on the routing partition would announce a download that + // is about to arrive normally. + it('tells the reader about the refusal, and only on the partition that refused', async () => { + const install = await loadInstaller() + install(profileFor('orca-doc-preview'), { downloads: 'deny' }) + install(profileFor('persist:browsing-1')) + + fireWillDownload('persist:browsing-1') + expect(mocks.noticeDocPreviewDownloadBlocked).not.toHaveBeenCalled() + + fireWillDownload('orca-doc-preview') + expect(mocks.noticeDocPreviewDownloadBlocked).toHaveBeenCalledWith( + expect.objectContaining({ id: 42 }) + ) + }) + + // Why both partitions in one run: the listener is module state shared across sessions, so a deny + // installed for one partition must not follow the next partition that installs after it. + it('keeps each partition on its own decision', async () => { + const install = await loadInstaller() + install(profileFor('orca-doc-preview'), { downloads: 'deny' }) + install(profileFor('persist:browsing-1')) + + expect(fireWillDownload('orca-doc-preview').cancelled).toBe(true) + expect(fireWillDownload('persist:browsing-1').cancelled).toBe(false) + expect(mocks.handleGuestWillDownload).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/main/browser/browser-session-partition-policies.ts b/src/main/browser/browser-session-partition-policies.ts index 2118c62ed68..cf542665ae4 100644 --- a/src/main/browser/browser-session-partition-policies.ts +++ b/src/main/browser/browser-session-partition-policies.ts @@ -11,6 +11,7 @@ import { clearBrowserWebAuthnAccessHandlers, installBrowserWebAuthnAccessHandlers } from './browser-webauthn-access' +import { noticeDocPreviewDownloadBlocked } from './doc-preview-download-block-notice' // Why: one shared installer keeps every partition's deny-by-default permission/download policies from drifting apart. const configuredPartitions = new Set() @@ -22,6 +23,23 @@ const handleWillDownload = ( browserManager.handleGuestWillDownload({ guestWebContentsId: webContents.id, item }) } +/** + * Why a second listener instead of a branch inside the shared one: `will-download` is a session + * event that names no partition, so the only place the decision can be keyed by partition is which + * listener that partition's session got. A workspace-document guest has no page of its own to + * attribute a download to, so routing one lands it in this desktop's Downloads folder under a + * remote-authored name that nothing in the UI accounts for. + */ +const handleDeniedWillDownload = ( + event: Electron.Event, + _item: Electron.DownloadItem, + webContents: Electron.WebContents +): void => { + event.preventDefault() + // The page gets nothing back; the reader gets a sentence, or a pressed button just does nothing. + noticeDocPreviewDownloadBlocked(webContents) +} + function resolvePermissionNoticeUrl( webContents: Electron.WebContents, details: Electron.PermissionRequest | undefined @@ -37,7 +55,13 @@ function resolvePermissionNoticeUrl( } } -export function installBrowserSessionPartitionPolicies(profile: BrowserSessionProfile): void { +/** `route` hands the item to the owning page's download flow; `deny` cancels it before it starts. */ +export type BrowserPartitionDownloadPolicy = 'route' | 'deny' + +export function installBrowserSessionPartitionPolicies( + profile: BrowserSessionProfile, + options?: { downloads?: BrowserPartitionDownloadPolicy } +): void { const { partition } = profile const sess = session.fromPartition(partition) setBrowserSessionUserAgentMode(sess, profile.userAgentMode ?? 'clean') @@ -106,7 +130,11 @@ export function installBrowserSessionPartitionPolicies(profile: BrowserSessionPr callback({ video: undefined, audio: undefined }) }) sess.removeListener('will-download', handleWillDownload) - sess.on('will-download', handleWillDownload) + sess.removeListener('will-download', handleDeniedWillDownload) + sess.on( + 'will-download', + options?.downloads === 'deny' ? handleDeniedWillDownload : handleWillDownload + ) configuredPartitions.add(partition) } @@ -115,6 +143,7 @@ export function clearBrowserSessionPartitionPolicies(partition: string, sess: Se configuredPartitions.delete(partition) browserManager.removeCertificateRequestGuard(sess) sess.removeListener('will-download', handleWillDownload) + sess.removeListener('will-download', handleDeniedWillDownload) clearBrowserWebAuthnAccessHandlers(sess) sess.setPermissionRequestHandler(null) sess.setPermissionCheckHandler(null) diff --git a/src/main/browser/doc-preview-download-block-notice.test.ts b/src/main/browser/doc-preview-download-block-notice.test.ts new file mode 100644 index 00000000000..9fc4396b329 --- /dev/null +++ b/src/main/browser/doc-preview-download-block-notice.test.ts @@ -0,0 +1,107 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + publishDocPreviewFailure: vi.fn(), + boundGrantIdByGuest: new Map() +})) + +vi.mock('./doc-preview-failure-notice', () => ({ + publishDocPreviewFailure: mocks.publishDocPreviewFailure +})) +vi.mock('./doc-preview-guest-policy', () => ({ + readDocPreviewGuestBoundGrantId: (guest: object) => mocks.boundGrantIdByGuest.get(guest) ?? null +})) + +const GRANT_ID = 'a'.repeat(32) +const OTHER_GRANT_ID = 'b'.repeat(32) + +/** Only the identity matters: the module asks the guest registry what grant this contents holds. */ +function guestBoundTo(grantId: string | null): Electron.WebContents { + const guest = {} as Electron.WebContents + if (grantId !== null) { + mocks.boundGrantIdByGuest.set(guest, grantId) + } + return guest +} + +async function loadNotifier(): Promise<(guest: Electron.WebContents) => void> { + const module = await import('./doc-preview-download-block-notice') + return module.noticeDocPreviewDownloadBlocked +} + +beforeEach(() => { + vi.clearAllMocks() + mocks.boundGrantIdByGuest.clear() + // Why per test: the module remembers which grants it has already told the reader about. + vi.resetModules() + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-08-27T00:00:00.000Z')) +}) + +afterEach(() => { + vi.useRealTimers() +}) + +describe('noticeDocPreviewDownloadBlocked', () => { + it('tells the shell which preview had a download refused', async () => { + const notice = await loadNotifier() + + notice(guestBoundTo(GRANT_ID)) + + expect(mocks.publishDocPreviewFailure).toHaveBeenCalledWith({ + grantId: GRANT_ID, + reason: 'download-blocked' + }) + }) + + // Why: a document can ask in a loop. The reader learns nothing from the second notice, and every + // attempt would otherwise cross the IPC boundary and re-render the strip. + it('says it once however often the document asks', async () => { + const notice = await loadNotifier() + const guest = guestBoundTo(GRANT_ID) + + notice(guest) + notice(guest) + vi.advanceTimersByTime(1_500) + notice(guest) + + expect(mocks.publishDocPreviewFailure).toHaveBeenCalledTimes(1) + }) + + it('says it again for an attempt long after the last one', async () => { + const notice = await loadNotifier() + const guest = guestBoundTo(GRANT_ID) + + notice(guest) + vi.advanceTimersByTime(2_500) + notice(guest) + + expect(mocks.publishDocPreviewFailure).toHaveBeenCalledTimes(2) + }) + + // Why not one throttle for the whole app: two previews are two readers, and silencing the second + // because the first just refused something leaves that press unexplained. + it('throttles each preview on its own', async () => { + const notice = await loadNotifier() + + notice(guestBoundTo(GRANT_ID)) + notice(guestBoundTo(OTHER_GRANT_ID)) + + expect(mocks.publishDocPreviewFailure).toHaveBeenCalledTimes(2) + expect(mocks.publishDocPreviewFailure).toHaveBeenLastCalledWith({ + grantId: OTHER_GRANT_ID, + reason: 'download-blocked' + }) + }) + + // The absence half of the first test: no shell is showing this contents, so there is no preview + // to put a notice on. Without the presence tests above, this would pass on a module that never + // published anything at all. + it('says nothing for a contents no preview is bound to', async () => { + const notice = await loadNotifier() + + notice(guestBoundTo(null)) + + expect(mocks.publishDocPreviewFailure).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/browser/doc-preview-download-block-notice.ts b/src/main/browser/doc-preview-download-block-notice.ts new file mode 100644 index 00000000000..2aefb2824a7 --- /dev/null +++ b/src/main/browser/doc-preview-download-block-notice.ts @@ -0,0 +1,34 @@ +import { publishDocPreviewFailure } from './doc-preview-failure-notice' +import { readDocPreviewGuestBoundGrantId } from './doc-preview-guest-policy' + +/** + * Why the reader is told at all, when the point of the fence is that nothing happens: a press that + * produces no file and no explanation reads as Orca being broken. The notice is chrome the document + * cannot see or read back, so the refusal stays as silent to the page as it was. + * + * Why a floor between notices: a document can ask in a loop, and every attempt would otherwise + * cross the IPC boundary and re-render the strip. The reader learns nothing from the thousandth. + */ +const NOTICE_MIN_INTERVAL_MS = 2_000 +const noticedAtByGrantId = new Map() + +export function noticeDocPreviewDownloadBlocked(guest: Electron.WebContents): void { + const grantId = readDocPreviewGuestBoundGrantId(guest) + // Nothing to route a notice to: no shell is showing this contents as a preview. + if (grantId === null) { + return + } + const now = Date.now() + // Doubles as the expiry check, so what survives is exactly "noticed inside the window", and a + // grant that stops downloading stops being remembered. + for (const [noticedGrantId, noticedAt] of noticedAtByGrantId) { + if (now - noticedAt >= NOTICE_MIN_INTERVAL_MS) { + noticedAtByGrantId.delete(noticedGrantId) + } + } + if (noticedAtByGrantId.has(grantId)) { + return + } + noticedAtByGrantId.set(grantId, now) + publishDocPreviewFailure({ grantId, reason: 'download-blocked' }) +} diff --git a/src/main/browser/doc-preview-failure-notice.test.ts b/src/main/browser/doc-preview-failure-notice.test.ts new file mode 100644 index 00000000000..2272e7bf614 --- /dev/null +++ b/src/main/browser/doc-preview-failure-notice.test.ts @@ -0,0 +1,70 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { DOC_PREVIEW_LOAD_FAILURE_CHANNEL } from '../../shared/doc-preview-scheme' +import { publishDocPreviewFailure, setDocPreviewFailureSink } from './doc-preview-failure-notice' + +afterEach(() => { + setDocPreviewFailureSink(null) +}) + +describe('publishDocPreviewFailure', () => { + it('sends the grant, path, and reason on the failure channel', () => { + const send = vi.fn() + setDocPreviewFailureSink({ send }) + + publishDocPreviewFailure({ + grantId: 'a'.repeat(32), + relativePath: 'index.html', + reason: 'too-large' + }) + + expect(send).toHaveBeenCalledWith(DOC_PREVIEW_LOAD_FAILURE_CHANNEL, { + grantId: 'a'.repeat(32), + relativePath: 'index.html', + reason: 'too-large' + }) + }) + + // Why: reads can outlive the window that asked for them; a missing sink must not throw inside + // the protocol handler. + it('is a no-op with no sink registered', () => { + expect(() => + publishDocPreviewFailure({ + grantId: 'b'.repeat(32), + relativePath: 'index.html', + reason: 'unreadable' + }) + ).not.toThrow() + }) + + it('sends nothing into a destroyed window', () => { + const send = vi.fn() + setDocPreviewFailureSink({ send, isDestroyed: () => true }) + + publishDocPreviewFailure({ + grantId: 'c'.repeat(32), + relativePath: 'index.html', + reason: 'unreadable' + }) + + expect(send).not.toHaveBeenCalled() + }) + + // Why: a WebContents can be torn down between the liveness check and the send. + it('swallows a throwing sink and stops using it', () => { + const send = vi.fn(() => { + throw new Error('Object has been destroyed') + }) + setDocPreviewFailureSink({ send }) + const failure = { + grantId: 'd'.repeat(32), + relativePath: 'index.html', + reason: 'unreadable' as const + } + + expect(() => publishDocPreviewFailure(failure)).not.toThrow() + publishDocPreviewFailure(failure) + + expect(send).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/main/browser/doc-preview-failure-notice.ts b/src/main/browser/doc-preview-failure-notice.ts new file mode 100644 index 00000000000..1b7330d6f27 --- /dev/null +++ b/src/main/browser/doc-preview-failure-notice.ts @@ -0,0 +1,34 @@ +import { + DOC_PREVIEW_LOAD_FAILURE_CHANNEL, + type DocPreviewFailure +} from '../../shared/doc-preview-scheme' + +type DocPreviewFailureSink = { + send: (channel: string, payload: DocPreviewFailure) => void + isDestroyed?: () => boolean +} + +let failureSink: DocPreviewFailureSink | null = null + +export function setDocPreviewFailureSink(sink: DocPreviewFailureSink | null): void { + failureSink = sink +} + +/** + * The preview shell cannot read the guest's HTTP status, and a 4xx body renders as + * if it were the document. Pushing the reason lets the shell replace that with a + * localized notice for the failure the user actually hit. + */ +export function publishDocPreviewFailure(failure: DocPreviewFailure): void { + const sink = failureSink + // Why: a read can outlive the window that asked for it, and sending into torn-down + // WebContents throws — an unreadable asset must not take the protocol handler with it. + if (!sink || sink.isDestroyed?.()) { + return + } + try { + sink.send(DOC_PREVIEW_LOAD_FAILURE_CHANNEL, failure) + } catch { + failureSink = null + } +} diff --git a/src/main/browser/doc-preview-file-reader.test.ts b/src/main/browser/doc-preview-file-reader.test.ts new file mode 100644 index 00000000000..fc707279f8f --- /dev/null +++ b/src/main/browser/doc-preview-file-reader.test.ts @@ -0,0 +1,333 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + callRuntimeEnvironment: vi.fn(), + readFile: vi.fn(), + realpath: vi.fn(), + requireSshFilesystemProvider: vi.fn() +})) + +vi.mock('../ipc/runtime-environment-transport-routing', () => ({ + callRuntimeEnvironment: mocks.callRuntimeEnvironment +})) +vi.mock('../persistence', () => ({ getCanonicalUserDataPath: () => '/user-data' })) +vi.mock('../providers/ssh-filesystem-dispatch', () => ({ + requireSshFilesystemProvider: mocks.requireSshFilesystemProvider +})) + +import { FileReadCapExceededError } from '../ssh/ssh-filesystem-stream-reader' +import { docPreviewContentType, readDocPreviewFile } from './doc-preview-file-reader' +import { mintDocPreviewGrant, revokeAllDocPreviewGrants } from './doc-preview-grant-registry' + +function sshGrant(): ReturnType { + return mintDocPreviewGrant({ + owner: { kind: 'ssh', connectionId: 'ssh-1' }, + root: '/home/alice/docs', + entryRelativePath: 'index.html', + browserPageId: 'page-1' + }) +} + +function runtimeGrant(root = '/srv/repo/docs'): ReturnType { + return mintDocPreviewGrant({ + owner: { + kind: 'runtime', + environmentId: 'env-1', + worktreeSelector: 'id:wt-1', + worktreeRoot: '/srv/repo' + }, + root, + entryRelativePath: 'index.html', + browserPageId: 'page-1' + }) +} + +beforeEach(() => { + vi.clearAllMocks() + revokeAllDocPreviewGrants() + // Why: an unsymlinked host canonicalizes to the path it was given. + mocks.realpath.mockImplementation((path: string) => Promise.resolve(path)) + mocks.requireSshFilesystemProvider.mockReturnValue({ + readFile: mocks.readFile, + realpath: mocks.realpath + }) +}) + +describe('docPreviewContentType', () => { + it('maps document and asset extensions, defaulting to octet-stream', () => { + expect(docPreviewContentType('index.html')).toBe('text/html; charset=utf-8') + expect(docPreviewContentType('assets/app.CSS')).toBe('text/css; charset=utf-8') + expect(docPreviewContentType('assets/logo.png')).toBe('image/png') + expect(docPreviewContentType('data.bin')).toBe('application/octet-stream') + }) +}) + +describe('readDocPreviewFile — ssh owner', () => { + it('reads text through the SSH filesystem provider', async () => { + mocks.readFile.mockResolvedValue({ content: '

hi

', isBinary: false }) + + const outcome = await readDocPreviewFile(sshGrant(), 'index.html') + + expect(mocks.requireSshFilesystemProvider).toHaveBeenCalledWith('ssh-1') + expect(mocks.readFile).toHaveBeenCalledWith('/home/alice/docs/index.html') + expect(outcome).toEqual({ + ok: true, + bytes: Buffer.from('

hi

', 'utf8'), + contentType: 'text/html; charset=utf-8' + }) + }) + + it('decodes a base64 binary asset', async () => { + const png = Buffer.from([0x89, 0x50, 0x4e, 0x47]) + mocks.readFile.mockResolvedValue({ content: png.toString('base64'), isBinary: true }) + + const outcome = await readDocPreviewFile(sshGrant(), 'assets/logo.png') + + expect(outcome).toEqual({ ok: true, bytes: png, contentType: 'image/png' }) + }) + + // Why: the SSH reader rejects an over-cap file rather than clamping it, so a completed read is + // always whole and needs no truncation flag. + it('serves a whole SSH read that carries no truncation flag', async () => { + mocks.readFile.mockResolvedValue({ content: '

whole

', isBinary: false }) + + expect(await readDocPreviewFile(sshGrant(), 'index.html')).toMatchObject({ ok: true }) + }) + + // Why: the SSH read path only serves images and PDFs as bytes, so a font is refused there by + // design — the failure must name the file type, not a stale server. + it('reports a file type the host will not send as unsupported-asset', async () => { + mocks.readFile.mockResolvedValue({ content: '', isBinary: true }) + + const outcome = await readDocPreviewFile(sshGrant(), 'assets/font.woff2') + + expect(outcome).toMatchObject({ ok: false, status: 415, reason: 'unsupported-asset' }) + }) + + // Why: a host that still named the type read a 0-byte file, so 0 bytes is the honest answer — + // reporting it as a refused format would be a failure the workspace never reported. + it('serves an empty file the host still typed instead of calling it unsupported', async () => { + mocks.readFile.mockResolvedValue({ content: '', isBinary: true, mimeType: 'image/png' }) + + const outcome = await readDocPreviewFile(sshGrant(), 'assets/logo.png') + + expect(outcome).toEqual({ ok: true, bytes: Buffer.alloc(0), contentType: 'image/png' }) + }) + + // Why: containment above is lexical, and the SSH read RPC enforces no root of its own, so a + // symlink inside the grant would otherwise read anything the account can reach. + it('404s a path that canonicalizes outside the grant root', async () => { + mocks.realpath.mockImplementation((path: string) => + Promise.resolve(path === '/home/alice/docs/escape.html' ? '/etc/shadow' : path) + ) + + const outcome = await readDocPreviewFile(sshGrant(), 'escape.html') + + expect(outcome).toMatchObject({ ok: false, status: 404 }) + expect(mocks.readFile).not.toHaveBeenCalled() + }) + + it('reads the canonical path once containment holds', async () => { + mocks.realpath.mockImplementation((path: string) => + Promise.resolve(path === '/home/alice/docs/link.html' ? '/home/alice/docs/real.html' : path) + ) + mocks.readFile.mockResolvedValue({ content: '

real

', isBinary: false }) + + expect(await readDocPreviewFile(sshGrant(), 'link.html')).toMatchObject({ ok: true }) + expect(mocks.readFile).toHaveBeenCalledWith('/home/alice/docs/real.html') + }) + + // Why: a symlinked root is legitimate; containment must be judged on what both sides resolve to. + it('keeps serving a grant whose own root is a symlink', async () => { + mocks.realpath.mockImplementation((path: string) => + Promise.resolve(path.replace('/home/alice/docs', '/mnt/data/docs')) + ) + mocks.readFile.mockResolvedValue({ content: '

hi

', isBinary: false }) + + expect(await readDocPreviewFile(sshGrant(), 'index.html')).toMatchObject({ ok: true }) + expect(mocks.readFile).toHaveBeenCalledWith('/mnt/data/docs/index.html') + }) + + it('404s when the host cannot canonicalize the path at all', async () => { + mocks.realpath.mockRejectedValue(new Error('no such file')) + + expect(await readDocPreviewFile(sshGrant(), 'index.html')).toMatchObject({ + ok: false, + status: 404 + }) + expect(mocks.readFile).not.toHaveBeenCalled() + }) + + it('404s a path outside the grant root without touching the provider', async () => { + const outcome = await readDocPreviewFile(sshGrant(), '../../etc/passwd') + + expect(outcome).toMatchObject({ ok: false, status: 404 }) + expect(mocks.requireSshFilesystemProvider).not.toHaveBeenCalled() + }) + + it('reports an over-cap SSH file as too large rather than unreadable', async () => { + mocks.readFile.mockRejectedValue(new FileReadCapExceededError('exceeds client cap')) + + expect(await readDocPreviewFile(sshGrant(), 'huge.html')).toMatchObject({ + ok: false, + status: 413 + }) + }) + + it('404s when the provider read fails', async () => { + mocks.readFile.mockRejectedValue(new Error('no such file')) + + expect(await readDocPreviewFile(sshGrant(), 'missing.html')).toMatchObject({ + ok: false, + status: 404 + }) + }) +}) + +describe('readDocPreviewFile — paired runtime owner', () => { + it('reads text over worktree-relative files.read', async () => { + mocks.callRuntimeEnvironment.mockResolvedValue({ + ok: true, + result: { content: '

remote

', truncated: false, byteLength: 15 } + }) + + const outcome = await readDocPreviewFile(runtimeGrant(), 'index.html') + + expect(mocks.callRuntimeEnvironment).toHaveBeenCalledWith( + '/user-data', + 'env-1', + 'files.read', + { worktree: 'id:wt-1', relativePath: 'docs/index.html' }, + 15_000 + ) + expect(outcome).toEqual({ + ok: true, + bytes: Buffer.from('

remote

', 'utf8'), + contentType: 'text/html; charset=utf-8' + }) + }) + + it('falls back to the base64 preview RPC for a binary asset', async () => { + const png = Buffer.from([0x89, 0x50, 0x4e, 0x47]) + mocks.callRuntimeEnvironment + .mockResolvedValueOnce({ + ok: false, + error: { code: 'runtime_error', message: 'binary_file' } + }) + .mockResolvedValueOnce({ + ok: true, + result: { + content: png.toString('base64'), + isBinary: true, + isImage: true, + mimeType: 'image/png' + } + }) + + const outcome = await readDocPreviewFile(runtimeGrant(), 'assets/logo.png') + + expect(mocks.callRuntimeEnvironment).toHaveBeenNthCalledWith( + 2, + '/user-data', + 'env-1', + 'files.readPreview', + { worktree: 'id:wt-1', relativePath: 'docs/assets/logo.png' }, + 15_000 + ) + expect(outcome).toEqual({ ok: true, bytes: png, contentType: 'image/png' }) + }) + + it('degrades on an old server whose empty binary preview carries no metadata', async () => { + mocks.callRuntimeEnvironment + .mockResolvedValueOnce({ + ok: false, + error: { code: 'runtime_error', message: 'binary_file' } + }) + .mockResolvedValueOnce({ ok: true, result: { content: '', isBinary: true } }) + + expect(await readDocPreviewFile(runtimeGrant(), 'assets/logo.png')).toMatchObject({ + ok: false, + status: 415, + reason: 'unsupported-asset' + }) + }) + + it('serves an empty paired asset the host typed rather than reporting a refusal', async () => { + mocks.callRuntimeEnvironment + .mockResolvedValueOnce({ + ok: false, + error: { code: 'runtime_error', message: 'binary_file' } + }) + .mockResolvedValueOnce({ + ok: true, + result: { content: '', isBinary: true, isImage: true, mimeType: 'image/png' } + }) + + expect(await readDocPreviewFile(runtimeGrant(), 'assets/logo.png')).toEqual({ + ok: true, + bytes: Buffer.alloc(0), + contentType: 'image/png' + }) + }) + + // Why: files.read clamps text at the host cap and only says so in `truncated`; serving the + // clamped bytes renders a document that silently stops halfway. + it('refuses a truncated text read instead of serving the clamped bytes', async () => { + mocks.callRuntimeEnvironment.mockResolvedValue({ + ok: true, + result: { content: '

half of', truncated: true, byteLength: 40_000_000 } + }) + + const outcome = await readDocPreviewFile(runtimeGrant(), 'index.html') + + expect(outcome).toMatchObject({ ok: false, status: 413 }) + expect(outcome).not.toMatchObject({ ok: true }) + }) + + it('serves a read the host reports as complete', async () => { + mocks.callRuntimeEnvironment.mockResolvedValue({ + ok: true, + result: { content: '

all

', truncated: false, byteLength: 12 } + }) + + expect(await readDocPreviewFile(runtimeGrant(), 'index.html')).toMatchObject({ ok: true }) + }) + + // Why: the binary RPC has no `truncated` field — it rejects an over-cap asset with this error. + it('reports the host rejecting an over-cap binary as too large', async () => { + mocks.callRuntimeEnvironment + .mockResolvedValueOnce({ + ok: false, + error: { code: 'runtime_error', message: 'binary_file' } + }) + .mockResolvedValueOnce({ + ok: false, + error: { code: 'runtime_error', message: 'file_too_large' } + }) + + expect(await readDocPreviewFile(runtimeGrant(), 'assets/huge.png')).toMatchObject({ + ok: false, + status: 413 + }) + }) + + it('does not treat an unrelated RPC failure as a binary fallback', async () => { + mocks.callRuntimeEnvironment.mockResolvedValue({ + ok: false, + error: { code: 'runtime_error', message: 'permission_denied' } + }) + + expect(await readDocPreviewFile(runtimeGrant(), 'index.html')).toMatchObject({ + ok: false, + status: 404 + }) + expect(mocks.callRuntimeEnvironment).toHaveBeenCalledOnce() + }) + + it('404s a document outside the worktree, which files.read cannot address', async () => { + const outcome = await readDocPreviewFile(runtimeGrant('/tmp/agent-docs'), 'index.html') + + expect(outcome).toMatchObject({ ok: false, status: 404 }) + expect(mocks.callRuntimeEnvironment).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/browser/doc-preview-file-reader.ts b/src/main/browser/doc-preview-file-reader.ts new file mode 100644 index 00000000000..967461a91f9 --- /dev/null +++ b/src/main/browser/doc-preview-file-reader.ts @@ -0,0 +1,198 @@ +import { extname } from 'node:path' +import type { + RuntimeFilePreviewResult, + RuntimeFileReadResult +} from '../../shared/runtime-file-contracts' +import type { DocPreviewFileFailureReason } from '../../shared/doc-preview-scheme' +import { callRuntimeEnvironment } from '../ipc/runtime-environment-transport-routing' +import { FileReadCapExceededError } from '../ssh/ssh-filesystem-stream-reader' +import { getCanonicalUserDataPath } from '../persistence' +import { requireSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' +import { + resolveCanonicalDocPreviewPath, + resolveDocPreviewTargetPath, + toRuntimeWorktreeRelativePath, + type DocPreviewGrant +} from './doc-preview-grant-registry' + +const DOC_PREVIEW_READ_TIMEOUT_MS = 15_000 + +/** Why not "needs a newer server": the SSH read path only ever serves images and PDFs as bytes, so + * a font is refused there by design, not by version. Name the file type, not the host's age. */ +const UNSERVABLE_ASSET_PREVIEW_MESSAGE = 'This workspace cannot send this file type to a preview.' + +/** `files.read` clamps text at the host's cap and reports it; serving the clamped bytes would + * render a silently half-finished document. */ +const TRUNCATED_PREVIEW_MESSAGE = 'This document is too large for the server to send in full.' + +/** The paired host rejects an over-cap asset outright instead of clamping it. */ +const RUNTIME_TOO_LARGE_ERROR = 'file_too_large' + +/** Both owners refuse an over-cap file; only their error shapes differ. */ +function isTooLargeReadError(error: unknown): boolean { + return ( + error instanceof FileReadCapExceededError || + (error instanceof Error && error.message === RUNTIME_TOO_LARGE_ERROR) + ) +} + +export type DocPreviewReadOutcome = + | { ok: true; bytes: Buffer; contentType: string } + | { ok: false; status: number; reason: DocPreviewFileFailureReason; message: string } + +const DOC_PREVIEW_CONTENT_TYPES: Record = { + '.html': 'text/html; charset=utf-8', + '.htm': 'text/html; charset=utf-8', + '.css': 'text/css; charset=utf-8', + '.js': 'text/javascript; charset=utf-8', + '.mjs': 'text/javascript; charset=utf-8', + '.json': 'application/json; charset=utf-8', + '.txt': 'text/plain; charset=utf-8', + '.md': 'text/plain; charset=utf-8', + '.csv': 'text/plain; charset=utf-8', + '.svg': 'image/svg+xml', + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.gif': 'image/gif', + '.webp': 'image/webp', + '.bmp': 'image/bmp', + '.ico': 'image/x-icon', + '.pdf': 'application/pdf', + '.woff': 'font/woff', + '.woff2': 'font/woff2', + '.ttf': 'font/ttf', + '.otf': 'font/otf' +} + +export function docPreviewContentType(relativePath: string): string { + return ( + DOC_PREVIEW_CONTENT_TYPES[extname(relativePath).toLowerCase()] ?? 'application/octet-stream' + ) +} + +type PreviewFileBytes = { + content: string + isBinary: boolean + truncated?: boolean + /** Set by every owner that agreed to serve the bytes, so it also survives a 0-byte file. */ + mimeType?: string +} + +function toOutcome(source: PreviewFileBytes, contentType: string): DocPreviewReadOutcome { + if (source.truncated) { + return { ok: false, status: 413, reason: 'too-large', message: TRUNCATED_PREVIEW_MESSAGE } + } + if (!source.isBinary) { + return { ok: true, bytes: Buffer.from(source.content, 'utf8'), contentType } + } + if (source.content) { + return { ok: true, bytes: Buffer.from(source.content, 'base64'), contentType } + } + // Why: an empty binary body is two different answers. A host that still named the file's type + // read a 0-byte file, and 0 bytes is what it should serve; a host that named no type declined + // the format outright and has nothing to send. + return source.mimeType + ? { ok: true, bytes: Buffer.alloc(0), contentType } + : { + ok: false, + status: 415, + reason: 'unsupported-asset', + message: UNSERVABLE_ASSET_PREVIEW_MESSAGE + } +} + +async function readRuntimeDocPreviewFile( + environmentId: string, + worktreeSelector: string, + relativePath: string +): Promise { + const userDataPath = getCanonicalUserDataPath() + const response = await callRuntimeEnvironment( + userDataPath, + environmentId, + 'files.read', + { worktree: worktreeSelector, relativePath }, + DOC_PREVIEW_READ_TIMEOUT_MS + ) + if (response.ok) { + const result = response.result as RuntimeFileReadResult + return { content: result.content, isBinary: false, truncated: result.truncated === true } + } + // Why: files.read rejects binaries with a typed error; the base64 preview RPC serves + // images and fonts the same way it does for markdown previews. Match the exact + // message so an unrelated failure can't spoof the fallback. + if (response.error.message !== 'binary_file') { + throw new Error(response.error.message) + } + const previewResponse = await callRuntimeEnvironment( + userDataPath, + environmentId, + 'files.readPreview', + { worktree: worktreeSelector, relativePath }, + DOC_PREVIEW_READ_TIMEOUT_MS + ) + if (!previewResponse.ok) { + throw new Error(previewResponse.error.message) + } + const preview = previewResponse.result as RuntimeFilePreviewResult + // Why: readPreview never clamps — it rejects an over-cap asset — so its body is whole or absent. + return { + content: preview.content, + isBinary: preview.isBinary, + ...(preview.mimeType ? { mimeType: preview.mimeType } : {}) + } +} + +function notFoundOutcome(message = 'Not found'): DocPreviewReadOutcome { + return { ok: false, status: 404, reason: 'unreadable', message } +} + +/** Reads one in-grant path over the same channel the editor uses for that owner. */ +export async function readDocPreviewFile( + grant: DocPreviewGrant, + relativePath: string +): Promise { + const absolutePath = resolveDocPreviewTargetPath(grant, relativePath) + if (!absolutePath) { + return notFoundOutcome() + } + const contentType = docPreviewContentType(relativePath) + try { + if (grant.owner.kind === 'ssh') { + const provider = requireSshFilesystemProvider(grant.owner.connectionId) + // Why: the SSH read RPC enforces no root of its own, so containment has to survive a symlink + // before the read — the lexical check above only proves the requested path looked contained. + const canonicalPath = await resolveCanonicalDocPreviewPath(grant, absolutePath, (path) => + provider.realpath(path) + ) + if (!canonicalPath) { + return notFoundOutcome() + } + // Why: the SSH reader rejects an over-cap file outright, so its result is never partial. + return toOutcome(await provider.readFile(canonicalPath), contentType) + } + const worktreeRelativePath = toRuntimeWorktreeRelativePath( + grant.owner.worktreeRoot, + absolutePath + ) + if (!worktreeRelativePath) { + // Why: files.read is worktree-scoped, so a doc outside the worktree has no client-side channel. + return notFoundOutcome() + } + // Why no realpath pass here: the host resolves this path through resolveAuthorizedPath, which + // canonicalizes and re-checks the worktree root server-side before reading. + return toOutcome( + await readRuntimeDocPreviewFile( + grant.owner.environmentId, + grant.owner.worktreeSelector, + worktreeRelativePath + ), + contentType + ) + } catch (error) { + return isTooLargeReadError(error) + ? { ok: false, status: 413, reason: 'too-large', message: TRUNCATED_PREVIEW_MESSAGE } + : notFoundOutcome(error instanceof Error ? error.message : undefined) + } +} diff --git a/src/main/browser/doc-preview-grant-registry.test.ts b/src/main/browser/doc-preview-grant-registry.test.ts new file mode 100644 index 00000000000..8aadf9ed695 --- /dev/null +++ b/src/main/browser/doc-preview-grant-registry.test.ts @@ -0,0 +1,164 @@ +import { beforeEach, describe, expect, it } from 'vitest' +import { + getDocPreviewGrant, + mintDocPreviewGrant, + resolveCanonicalDocPreviewPath, + resolveDocPreviewTargetPath, + revokeAllDocPreviewGrants, + revokeDocPreviewGrant, + toRuntimeWorktreeRelativePath, + type DocPreviewGrant +} from './doc-preview-grant-registry' + +const sshOwner = { kind: 'ssh', connectionId: 'ssh-1' } as const + +function mintPosixGrant(root = '/srv/repo/docs'): DocPreviewGrant { + return mintDocPreviewGrant({ + owner: sshOwner, + root, + entryRelativePath: 'index.html', + browserPageId: 'page-1' + }) +} + +beforeEach(() => { + revokeAllDocPreviewGrants() +}) + +describe('doc preview grants', () => { + it('mints unguessable ids and looks them up', () => { + const first = mintPosixGrant() + const second = mintPosixGrant() + + expect(first.id).toMatch(/^[0-9a-f]{32}$/) + expect(first.id).not.toBe(second.id) + expect(getDocPreviewGrant(first.id)).toBe(first) + }) + + it('returns nothing for an unknown or revoked grant', () => { + const grant = mintPosixGrant() + + expect(getDocPreviewGrant('0'.repeat(32))).toBeNull() + expect(revokeDocPreviewGrant(grant.id)).toBe(true) + expect(getDocPreviewGrant(grant.id)).toBeNull() + expect(revokeDocPreviewGrant(grant.id)).toBe(false) + }) +}) + +describe('resolveDocPreviewTargetPath', () => { + it('resolves paths inside the grant root', () => { + const grant = mintPosixGrant() + + expect(resolveDocPreviewTargetPath(grant, 'index.html')).toBe('/srv/repo/docs/index.html') + expect(resolveDocPreviewTargetPath(grant, 'assets/logo.png')).toBe( + '/srv/repo/docs/assets/logo.png' + ) + }) + + it('refuses parent traversal, absolute escapes and empty paths', () => { + const grant = mintPosixGrant() + + expect(resolveDocPreviewTargetPath(grant, '../secret.env')).toBeNull() + expect(resolveDocPreviewTargetPath(grant, 'assets/../../secret.env')).toBeNull() + expect(resolveDocPreviewTargetPath(grant, '..')).toBeNull() + expect(resolveDocPreviewTargetPath(grant, '')).toBeNull() + expect(resolveDocPreviewTargetPath(grant, 'a//b')).toBeNull() + }) + + it('refuses backslash and NUL segments that could re-split on the owning host', () => { + const grant = mintPosixGrant() + + expect(resolveDocPreviewTargetPath(grant, '..\\secret.env')).toBeNull() + expect(resolveDocPreviewTargetPath(grant, 'index.html\0.png')).toBeNull() + }) + + // Why this is a traversal test and not a containment test: every request path that names a + // sibling directory has to climb out of the root first, so the `..` segment guard answers it + // before the prefix check runs. Sibling containment is exercised where it is reachable — + // against a canonicalized path, below. + it('refuses a sibling directory by refusing the traversal that reaches it', () => { + const grant = mintPosixGrant() + + expect(resolveDocPreviewTargetPath(grant, '../docs-private/secret.html')).toBeNull() + }) + + it('keeps a Windows drive root addressable instead of turning it drive-relative', () => { + const grant = mintDocPreviewGrant({ + owner: sshOwner, + root: 'C:\\', + entryRelativePath: 'index.html', + browserPageId: 'page-1' + }) + + expect(resolveDocPreviewTargetPath(grant, 'index.html')).toBe('C:\\index.html') + }) + + it('follows the owning host path flavor rather than this process platform', () => { + const windowsGrant = mintDocPreviewGrant({ + owner: sshOwner, + root: 'C:\\srv\\repo\\docs', + entryRelativePath: 'index.html', + browserPageId: 'page-1' + }) + + expect(resolveDocPreviewTargetPath(windowsGrant, 'assets/logo.png')).toBe( + 'C:\\srv\\repo\\docs\\assets\\logo.png' + ) + expect(resolveDocPreviewTargetPath(windowsGrant, '../secret.env')).toBeNull() + }) + + it('normalizes a trailing separator on the root', () => { + const grant = mintDocPreviewGrant({ + owner: sshOwner, + root: '/srv/repo/docs/', + entryRelativePath: 'index.html', + browserPageId: 'page-1' + }) + + expect(resolveDocPreviewTargetPath(grant, 'index.html')).toBe('/srv/repo/docs/index.html') + expect(resolveDocPreviewTargetPath(grant, '../secret.env')).toBeNull() + }) +}) + +describe('resolveCanonicalDocPreviewPath', () => { + // Why here and not above: a canonical path is the one input that can name a sibling directory + // without traversing — the host resolved a symlink to it — so this is where the prefix check + // is the only thing standing between the grant and `/srv/repo/docs-private`. + it('refuses a canonical path in a sibling directory that shares the root prefix', async () => { + const grant = mintPosixGrant() + + await expect( + resolveCanonicalDocPreviewPath(grant, '/srv/repo/docs/report.html', async (path) => + path === grant.root ? path : '/srv/repo/docs-private/secret.html' + ) + ).resolves.toBeNull() + }) + + it('answers the canonical path when it stays inside the canonical root', async () => { + const grant = mintPosixGrant() + + await expect( + resolveCanonicalDocPreviewPath(grant, '/srv/repo/docs/report.html', async (path) => path) + ).resolves.toBe('/srv/repo/docs/report.html') + }) +}) + +describe('toRuntimeWorktreeRelativePath', () => { + it('produces a worktree-relative path for files inside the worktree', () => { + expect(toRuntimeWorktreeRelativePath('/srv/repo', '/srv/repo/docs/index.html')).toBe( + 'docs/index.html' + ) + }) + + it('rejects paths outside the worktree, which files.read cannot address', () => { + expect(toRuntimeWorktreeRelativePath('/srv/repo', '/tmp/agent/report.html')).toBeNull() + expect(toRuntimeWorktreeRelativePath('/srv/repo', '/srv/repo')).toBeNull() + }) + + it('uses Windows semantics for a Windows worktree root', () => { + expect(toRuntimeWorktreeRelativePath('C:\\srv\\repo', 'C:\\srv\\repo\\docs\\index.html')).toBe( + 'docs/index.html' + ) + expect(toRuntimeWorktreeRelativePath('C:\\srv\\repo', 'D:\\other\\index.html')).toBeNull() + }) +}) diff --git a/src/main/browser/doc-preview-grant-registry.ts b/src/main/browser/doc-preview-grant-registry.ts new file mode 100644 index 00000000000..51237a685f1 --- /dev/null +++ b/src/main/browser/doc-preview-grant-registry.ts @@ -0,0 +1,208 @@ +import { randomBytes } from 'node:crypto' +import { posix, win32 } from 'node:path' +import { isWindowsAbsolutePathLike } from '../../shared/cross-platform-path' + +/** + * A preview grant is the only authority that turns an `orca-preview://` request + * into bytes: it names the host that owns the file and the single directory + * subtree requests may resolve inside. No grant, no bytes. + */ +export type DocPreviewOwner = + | { kind: 'ssh'; connectionId: string } + | { + kind: 'runtime' + environmentId: string + /** Selector the runtime resolves `files.read` against. */ + worktreeSelector: string + /** Worktree root on the runtime host; `files.read` only accepts paths inside it. */ + worktreeRoot: string + } + +export type DocPreviewGrant = { + id: string + owner: DocPreviewOwner + /** Containing directory of the opened document, on the owning host. */ + root: string + /** Path of the opened document relative to `root`. */ + entryRelativePath: string + /** + * Browser page the reader opened this document in. Main registers the guest under it once the + * guest commits to the grant, so the surface a tool names is the page the reader is looking at + * and not the grant, which a re-mint replaces underneath the same page. + */ + browserPageId: string +} + +const grantsById = new Map() + +function pathFlavorFor(root: string): typeof posix | typeof win32 { + return isWindowsAbsolutePathLike(root) ? win32 : posix +} + +function normalizeRootPath(root: string): string { + const flavor = pathFlavorFor(root) + const normalized = + flavor === win32 ? flavor.normalize(root.replace(/\//g, '\\')) : flavor.normalize(root) + // Why: `C:\` is the whole root, and trimming its separator would make win32.join answer the + // drive-relative `C:x`, which resolves against the host's cwd instead of inside the grant. + if (flavor === win32 && /^[a-zA-Z]:\\$/.test(normalized)) { + return normalized + } + // Why: a trailing separator would make the containment prefix check accept a sibling directory. + return normalized.length > 1 && normalized.endsWith(flavor.sep) + ? normalized.slice(0, -1) + : normalized +} + +export function mintDocPreviewGrant(params: { + owner: DocPreviewOwner + root: string + entryRelativePath: string + browserPageId: string +}): DocPreviewGrant { + const grant: DocPreviewGrant = { + id: randomBytes(16).toString('hex'), + owner: params.owner, + root: normalizeRootPath(params.root), + entryRelativePath: params.entryRelativePath.replace(/\\/g, '/'), + browserPageId: params.browserPageId + } + grantsById.set(grant.id, grant) + return grant +} + +export function getDocPreviewGrant(grantId: string): DocPreviewGrant | null { + return grantsById.get(grantId) ?? null +} + +/** + * Why anything listens at all: a grant is the only thing that names a preview's lifetime. State + * elsewhere in main keyed by a preview's tool target — grab intent, a queued grab chain — has no + * other signal telling it the surface is gone, and would otherwise accrete one entry per grant + * for the life of the process. + */ +const revocationListeners = new Set<(grant: DocPreviewGrant) => void>() + +/** Why the whole grant and not its id: it is already gone from the registry when listeners run. */ +export function onDocPreviewGrantRevoked(listener: (grant: DocPreviewGrant) => void): () => void { + revocationListeners.add(listener) + return () => revocationListeners.delete(listener) +} + +function notifyRevoked(grant: DocPreviewGrant): void { + for (const listener of revocationListeners) { + listener(grant) + } +} + +export function revokeDocPreviewGrant(grantId: string): boolean { + canonicalRootByGrantId.delete(grantId) + const grant = grantsById.get(grantId) + if (!grant) { + return false + } + grantsById.delete(grantId) + notifyRevoked(grant) + return true +} + +export function revokeAllDocPreviewGrants(): void { + canonicalRootByGrantId.clear() + const revoked = [...grantsById.values()] + grantsById.clear() + for (const grant of revoked) { + notifyRevoked(grant) + } +} + +function hasUnsafeSegment(segments: string[]): boolean { + return segments.some( + (segment) => + segment.length === 0 || + segment === '.' || + segment === '..' || + segment.includes('\0') || + segment.includes('\\') + ) +} + +/** + * Resolves a request path to an absolute path on the owning host, or null when + * it would escape the grant's root. Path flavor follows the root (the owning + * host may be Windows while this client is not), never `process.platform`. + */ +export function resolveDocPreviewTargetPath( + grant: DocPreviewGrant, + relativePath: string +): string | null { + const segments = relativePath.split('/').filter((segment, index, all) => { + // Why: keep empty segments visible to the safety check except a single trailing one from `dir/`. + return !(segment === '' && index === all.length - 1) + }) + if (segments.length === 0 || hasUnsafeSegment(segments)) { + return null + } + const flavor = pathFlavorFor(grant.root) + const resolved = flavor.normalize(flavor.join(grant.root, ...segments)) + return isInsideRoot(grant.root, resolved, flavor) ? resolved : null +} + +function isInsideRoot( + root: string, + candidate: string, + flavor: typeof posix | typeof win32 +): boolean { + const rootPrefix = root.endsWith(flavor.sep) ? root : `${root}${flavor.sep}` + return candidate.startsWith(rootPrefix) +} + +/** Why: realpath is a host round-trip, and a grant's root is fixed for its lifetime. */ +const canonicalRootByGrantId = new Map>() + +/** + * Second containment pass for hosts where the lexical one is not enough: a symlink + * inside the root can point anywhere, and the SSH read RPC applies no root of its + * own. Both sides are canonicalized on the owning host before the prefix re-check; + * a host that cannot canonicalize a path answers nothing. + */ +export async function resolveCanonicalDocPreviewPath( + grant: DocPreviewGrant, + absolutePath: string, + realpath: (path: string) => Promise +): Promise { + try { + let canonicalRoot = canonicalRootByGrantId.get(grant.id) + if (!canonicalRoot) { + canonicalRoot = realpath(grant.root).then(normalizeRootPath) + canonicalRootByGrantId.set(grant.id, canonicalRoot) + } + const [root, canonicalPath] = await Promise.all([canonicalRoot, realpath(absolutePath)]) + const flavor = pathFlavorFor(root) + return isInsideRoot(root, canonicalPath, flavor) ? canonicalPath : null + } catch { + // Why: a root that no longer canonicalizes must not fall back to the lexical answer. + canonicalRootByGrantId.delete(grant.id) + return null + } +} + +/** + * Path a runtime `files.read` can address, i.e. relative to the worktree root. + * Returns null when the grant root sits outside the worktree — the runtime file + * RPCs are worktree-scoped, so those documents are unreadable client-side. + */ +export function toRuntimeWorktreeRelativePath( + worktreeRoot: string, + absolutePath: string +): string | null { + const flavor = pathFlavorFor(worktreeRoot) + const normalizedRoot = normalizeRootPath(worktreeRoot) + const relative = flavor.relative(normalizedRoot, absolutePath) + if (!relative || relative === '..' || relative.startsWith(`..${flavor.sep}`)) { + return null + } + if (flavor === win32 && /^[a-zA-Z]:/.test(relative)) { + return null + } + return relative.replace(/\\/g, '/') +} diff --git a/src/main/browser/doc-preview-guest-policy.test.ts b/src/main/browser/doc-preview-guest-policy.test.ts new file mode 100644 index 00000000000..26b117c0fc0 --- /dev/null +++ b/src/main/browser/doc-preview-guest-policy.test.ts @@ -0,0 +1,550 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + getWorkspaceDocPageGuest, + installDocPreviewGuestPolicy, + readDocPreviewGuestBoundGrantId, + reportDocPreviewLinkClick +} from './doc-preview-guest-policy' +import { buildDocPreviewUrl } from '../../shared/doc-preview-scheme' +import { + mintDocPreviewGrant, + revokeAllDocPreviewGrants, + revokeDocPreviewGrant +} from './doc-preview-grant-registry' + +type GuestHandlers = Record void> + +const HOST_RENDERER_ID = 42 + +function installOnFakeGuest( + hostId: number = HOST_RENDERER_ID, + /** What the guest is already showing when the embedder hands it over, as a real one usually is. */ + initialUrl = '' +): { + contents: object + handlers: GuestHandlers + hostId: number + isFocused: ReturnType + send: ReturnType + setWebRTCIPHandlingPolicy: ReturnType + windowOpenHandler: (details: { url: string }) => { action: string } + /** Why without the `destroyed` event: Chromium tears the contents down before main runs it. */ + markContentsDestroyed: () => void +} { + const handlers: GuestHandlers = {} + let contentsDestroyed = false + const send = vi.fn() + const isFocused = vi.fn(() => true) + const setWebRTCIPHandlingPolicy = vi.fn() + let windowOpenHandler: (details: { url: string }) => { action: string } = () => ({ + action: 'deny' + }) + const register = (event: string, handler: (...args: never[]) => void): void => { + handlers[event] = handler + } + const guest = { + isFocused, + isDestroyed: () => contentsDestroyed, + getURL: () => initialUrl, + on: vi.fn(register), + once: vi.fn(register), + setWindowOpenHandler: vi.fn((handler: (details: { url: string }) => { action: string }) => { + windowOpenHandler = handler + }), + setWebRTCIPHandlingPolicy + } + installDocPreviewGuestPolicy(guest as never, { id: hostId, send }) + return { + contents: guest, + handlers, + hostId, + isFocused, + send, + setWebRTCIPHandlingPolicy, + windowOpenHandler: (details) => windowOpenHandler(details), + markContentsDestroyed: () => { + contentsDestroyed = true + } + } +} + +type FakeGuest = ReturnType + +function startMainFrameNavigation(guest: FakeGuest, url: string): void { + guest.handlers['did-start-navigation']?.({ url, isMainFrame: true } as never) +} + +/** A guest already showing a document, which is the only state a link can be pressed in. */ +function boundGuest(): { grant: ReturnType; guest: FakeGuest } { + const grant = mintGrant() + const guest = installOnFakeGuest() + startMainFrameNavigation(guest, buildDocPreviewUrl(grant.id, 'index.html')) + return { grant, guest } +} + +function reportClick(guest: FakeGuest, url: string): void { + reportDocPreviewLinkClick(guest.contents as never, url) +} + +// Why a fresh page per grant: the registry is keyed by the page now, so two grants sharing one +// would have the second silently replace the first rather than stand beside it. +let nextDocPageOrdinal = 0 + +function mintGrant(): ReturnType { + nextDocPageOrdinal += 1 + return mintDocPreviewGrant({ + owner: { kind: 'ssh', connectionId: 'ssh-1' }, + root: '/home/alice/docs', + entryRelativePath: 'index.html', + browserPageId: `doc-page-${nextDocPageOrdinal}` + }) +} + +beforeEach(() => { + vi.clearAllMocks() + revokeAllDocPreviewGrants() +}) + +describe('doc preview guest policy', () => { + it('allows relative navigation within the bound grant', () => { + const { grant, guest } = boundGuest() + const preventDefault = vi.fn() + + guest.handlers['will-navigate']?.( + { preventDefault } as never, + buildDocPreviewUrl(grant.id, 'guide.html') as never + ) + + expect(preventDefault).not.toHaveBeenCalled() + expect(guest.send).not.toHaveBeenCalled() + }) + + // Why an unlatched guest is refused rather than trusted: the renderer-set src is + // browser-initiated and never reaches will-navigate, so a navigation arriving before the latch is + // one the guest started for itself. + it('blocks a navigation the guest starts before a document has bound it', () => { + const grant = mintGrant() + const guest = installOnFakeGuest() + const preventDefault = vi.fn() + + guest.handlers['will-navigate']?.( + { preventDefault } as never, + buildDocPreviewUrl(grant.id, 'index.html') as never + ) + + expect(preventDefault).toHaveBeenCalledOnce() + expect(guest.send).not.toHaveBeenCalled() + }) + + it('blocks navigation into a different live grant once bound', () => { + const { guest } = boundGuest() + const otherGrant = mintGrant() + const preventDefault = vi.fn() + + guest.handlers['will-navigate']?.( + { preventDefault } as never, + buildDocPreviewUrl(otherGrant.id, 'index.html') as never + ) + + expect(preventDefault).toHaveBeenCalledOnce() + }) + + it('blocks a revoked grant even when it matches the bound id', () => { + const { grant, guest } = boundGuest() + revokeAllDocPreviewGrants() + const preventDefault = vi.fn() + + guest.handlers['will-navigate']?.( + { preventDefault } as never, + buildDocPreviewUrl(grant.id, 'guide.html') as never + ) + + expect(preventDefault).toHaveBeenCalledOnce() + }) + + it('blocks an external navigation without offering it to the renderer', () => { + const { guest } = boundGuest() + const preventDefault = vi.fn() + + guest.handlers['will-navigate']?.({ preventDefault } as never, 'https://example.com/' as never) + + expect(preventDefault).toHaveBeenCalledOnce() + expect(guest.send).not.toHaveBeenCalled() + }) + + it('blocks a file: navigation', () => { + const { guest } = boundGuest() + const preventDefault = vi.fn() + + guest.handlers['will-navigate']?.({ preventDefault } as never, 'file:///etc/passwd' as never) + + expect(preventDefault).toHaveBeenCalledOnce() + expect(guest.send).not.toHaveBeenCalled() + }) + + it('applies the same rule to redirects', () => { + const { guest } = boundGuest() + const preventDefault = vi.fn() + + guest.handlers['will-redirect']?.({ preventDefault } as never, 'https://evil.test/' as never) + + expect(preventDefault).toHaveBeenCalledOnce() + expect(guest.send).not.toHaveBeenCalled() + }) + + it('denies every popup and routes none of them', () => { + const { guest } = boundGuest() + + expect(guest.windowOpenHandler({ url: 'https://example.com/docs' })).toEqual({ + action: 'deny' + }) + expect(guest.windowOpenHandler({ url: 'file:///etc/passwd' })).toEqual({ action: 'deny' }) + expect(guest.send).not.toHaveBeenCalled() + }) + + // Why: will-navigate never fires for a subframe, so an