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/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/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/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/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/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/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 d0dbd9ebf9b..2d2a1785c0d 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, @@ -48,6 +51,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 @@ -2773,8 +2777,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, { @@ -3301,7 +3306,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) { @@ -3328,7 +3334,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/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/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/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 8e892e69147..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' @@ -275,11 +336,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 +367,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 +399,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..4e58f947dee 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', @@ -1081,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) { @@ -1177,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 @@ -1492,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) { @@ -1505,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') @@ -1529,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/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/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/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/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/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/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 f1275403001..cc79e2bb054 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', @@ -84,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') @@ -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..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', @@ -90,6 +91,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/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, 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-request.ts b/src/relay/agent-hook-request.ts index f257ddf41cb..3b8366574e2 100644 --- a/src/relay/agent-hook-request.ts +++ b/src/relay/agent-hook-request.ts @@ -1,5 +1,6 @@ import type { IncomingMessage, ServerResponse } from 'node:http' import { normalizeHookPayload } from '../shared/agent-hook-listener' +import { mergeAgentHookRequestHeaders } from '../shared/agent-hook-listener/hook-envelope' import { HOOK_REQUEST_SLOWLORIS_MS } from '../shared/agent-hook-listener/listener-limits' import { readRequestBody } from '../shared/agent-hook-listener/request-body' import { resolveHookSource } from '../shared/agent-hook-listener/source-routing' @@ -59,22 +60,23 @@ export async function handleRelayHookRequest( req.destroy() }) try { - const body = await readRequestBody(req) const source = resolveHookSource(new URL(req.url ?? '/', 'http://127.0.0.1').pathname) if (!source) { res.writeHead(404) res.end() return } - const event = normalizeHookPayload(options.state, source, body, options.env, { + const body = await readRequestBody(req) + const hookBody = mergeAgentHookRequestHeaders(body, req.headers) + const event = normalizeHookPayload(options.state, source, hookBody, options.env, { deferCompactOwnershipToClient: true }) if (event) { - const env = options.bodyEnv(body), - version = options.bodyVersion(body) + const env = options.bodyEnv(hookBody), + version = options.bodyVersion(hookBody) options.applyEvent(event, source, env, version) - options.scheduleAssistantMessageRetry(source, body, event, env, version) - options.scheduleCodexSubagentPoll(source, body, event, env, version) + options.scheduleAssistantMessageRetry(source, hookBody, event, env, version) + options.scheduleCodexSubagentPoll(source, hookBody, event, env, version) } res.writeHead(204) res.end() diff --git a/src/relay/agent-hook-server-cache-hydration.test.ts b/src/relay/agent-hook-server-cache-hydration.test.ts new file mode 100644 index 00000000000..0a949973dea --- /dev/null +++ b/src/relay/agent-hook-server-cache-hydration.test.ts @@ -0,0 +1,99 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import type { AgentHookRelayEnvelope } from '../shared/agent-hook-relay' +import { makePaneKey } from '../shared/stable-pane-id' +import { RelayAgentHookServer } from './agent-hook-server' + +const PANE_KEY = makePaneKey('tab-1', '11111111-1111-4111-8111-111111111111') +const MALFORMED_LEAF_IDS = [ + '22222222-2222-4222-8222-222222222222', + '33333333-3333-4333-8333-333333333333', + '44444444-4444-4444-8444-444444444444' +] as const + +describe('relay agent-hook server cache hydration', () => { + let dir: string + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'relay-hook-server-cache-')) + }) + + afterEach(() => { + rmSync(dir, { recursive: true, force: true }) + }) + + it('skips malformed durable entries while hydrating valid status and diagnostics', async () => { + writeFileSync( + join(dir, 'hook-status-cache.json'), + JSON.stringify({ + version: 1, + entries: [ + { + event: { paneKey: makePaneKey('tab-1', MALFORMED_LEAF_IDS[0]) }, + meta: { source: 'claude' } + }, + { + event: { + paneKey: makePaneKey('tab-1', MALFORMED_LEAF_IDS[1]), + payload: { state: 'working', prompt: 'missing agent type' } + }, + meta: { source: 'claude' } + }, + { + event: { + paneKey: makePaneKey('tab-1', MALFORMED_LEAF_IDS[2]), + payload: null + }, + meta: { source: 'claude' } + }, + { + event: { + paneKey: PANE_KEY, + payload: { + state: 'working', + prompt: 'keep me after restart', + agentType: 'codex' + }, + reconcileDiagnostic: { + kind: 'unverifiable', + reason: 'transcript-unreadable', + observedAt: 123 + } + }, + meta: { source: 'codex', env: 'remote', version: '1' } + } + ] + }), + 'utf8' + ) + + const forward = vi.fn<(envelope: AgentHookRelayEnvelope) => void>() + const server = new RelayAgentHookServer({ endpointDir: dir, forward }) + await expect(server.start()).resolves.toBeUndefined() + try { + expect(server.replayCachedPayloadsForPanes()).toBe(1) + expect(forward).toHaveBeenCalledWith( + expect.objectContaining({ + source: 'codex', + env: 'remote', + version: '1', + payload: expect.objectContaining({ + prompt: 'keep me after restart', + agentType: 'codex' + }), + reconcileDiagnostic: { + kind: 'unverifiable', + reason: 'transcript-unreadable', + observedAt: 123 + }, + isReplay: true + }) + ) + } finally { + server.stop() + } + }) +}) diff --git a/src/relay/agent-hook-server.test.ts b/src/relay/agent-hook-server.test.ts index 9a3c7564ebb..c3a99b8a20c 100644 --- a/src/relay/agent-hook-server.test.ts +++ b/src/relay/agent-hook-server.test.ts @@ -8,14 +8,10 @@ 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) -const MALFORMED_LEAF_IDS = [ - '22222222-2222-4222-8222-222222222222', - '33333333-3333-4333-8333-333333333333', - '44444444-4444-4444-8444-444444444444' -] as const type RelayServerInternals = { state: { lastStatusByPaneKey: Map } @@ -47,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() @@ -57,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) @@ -335,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 }) @@ -371,78 +385,6 @@ describe('RelayAgentHookServer', () => { } }) - it('skips malformed durable entries while hydrating valid status and diagnostics', async () => { - writeFileSync( - join(dir, 'hook-status-cache.json'), - JSON.stringify({ - version: 1, - entries: [ - { - event: { paneKey: makePaneKey('tab-1', MALFORMED_LEAF_IDS[0]) }, - meta: { source: 'claude' } - }, - { - event: { - paneKey: makePaneKey('tab-1', MALFORMED_LEAF_IDS[1]), - payload: { state: 'working', prompt: 'missing agent type' } - }, - meta: { source: 'claude' } - }, - { - event: { - paneKey: makePaneKey('tab-1', MALFORMED_LEAF_IDS[2]), - payload: null - }, - meta: { source: 'claude' } - }, - { - event: { - paneKey: PANE_KEY, - payload: { - state: 'working', - prompt: 'keep me after restart', - agentType: 'codex' - }, - reconcileDiagnostic: { - kind: 'unverifiable', - reason: 'transcript-unreadable', - observedAt: 123 - } - }, - meta: { source: 'codex', env: 'remote', version: '1' } - } - ] - }), - 'utf8' - ) - - const forward = vi.fn<(envelope: AgentHookRelayEnvelope) => void>() - const server = new RelayAgentHookServer({ endpointDir: dir, forward }) - await expect(server.start()).resolves.toBeUndefined() - try { - expect(server.replayCachedPayloadsForPanes()).toBe(1) - expect(forward).toHaveBeenCalledWith( - expect.objectContaining({ - source: 'codex', - env: 'remote', - version: '1', - payload: expect.objectContaining({ - prompt: 'keep me after restart', - agentType: 'codex' - }), - reconcileDiagnostic: { - kind: 'unverifiable', - reason: 'transcript-unreadable', - observedAt: 123 - }, - isReplay: true - }) - ) - } finally { - server.stop() - } - }) - it('forwards and replays Pi session identity as metadata-only', async () => { const forward = vi.fn<(envelope: AgentHookRelayEnvelope) => void>() const server = new RelayAgentHookServer({ endpointDir: dir, forward }) @@ -569,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 8f6c96d573b..c596af3f442 100644 --- a/src/relay/agent-hook-server.ts +++ b/src/relay/agent-hook-server.ts @@ -1,7 +1,10 @@ import type { Server } from 'node:http' 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, @@ -190,7 +193,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 } 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) + }) +}) 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} - + 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 +} 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/agent-hook-listener-relay-dependency.test.ts b/src/shared/agent-hook-listener-relay-dependency.test.ts index 46a5d184336..5f827b032f9 100644 --- a/src/shared/agent-hook-listener-relay-dependency.test.ts +++ b/src/shared/agent-hook-listener-relay-dependency.test.ts @@ -123,6 +123,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 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 } } 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() + }) +})