From cc384c5a3d12508684156dd7ce5d3eaff121380f Mon Sep 17 00:00:00 2001 From: Mark Xian Date: Fri, 28 Aug 2026 03:32:29 +0800 Subject: [PATCH 1/7] 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 2/7] 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 3/7] 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 4/7] 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 5/7] 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 6/7] 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 7/7] 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() + }) +})