Merge remote-tracking branch 'origin/main' into brennanb2025/pr-16336-rebase-review-r1

# Conflicts:
#	src/relay/agent-hook-server.ts
This commit is contained in:
Brennan Benson
2026-08-27 14:24:37 -07:00
81 changed files with 3362 additions and 304 deletions
+1
View File
@@ -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
+1
View File
@@ -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",
+58
View File
@@ -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
`<userData>/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 <domain> <key>` 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.
+28
View File
@@ -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']
+19
View File
@@ -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<string, CommandHandler> = {
'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(),
@@ -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 {
@@ -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
@@ -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 {
@@ -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 {
@@ -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
@@ -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 {
+37
View File
@@ -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'
]
}
@@ -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')
@@ -284,7 +284,7 @@ describe('remote hook service installers', () => {
hooks: Record<string, { hooks: { command: string }[] }[]>
}
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`
@@ -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()
}
@@ -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' })
+12 -5
View File
@@ -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
}
+24 -7
View File
@@ -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<AgentHookInstallStatus | null>
}): Promise<void> {
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) {
@@ -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<string, unknown>, connectionId: string) => void
installHooks: typeof installRemoteManagedAgentHooks
installCodex: (runtimeHomePath: string, distro: string) => Promise<AgentHookInstallStatus | null>
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() }),
@@ -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
})
@@ -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<AgentHookServer['ingestRemote']>[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'
@@ -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()
})
+27 -8
View File
@@ -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<string, DistroState>()
/** Distros a hooks-off teardown stopped, so re-enabling can put them back. */
private stoppedByHooksOff = new Set<string>()
private stoppedByHooksOff = new Map<string, string | undefined>()
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<void> {
private async ensureInternal(
requestedDistro: string | null,
requestedCodexHomePath?: string
): Promise<void> {
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)
+6 -4
View File
@@ -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.
+6 -13
View File
@@ -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')
@@ -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)
})
})
@@ -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<void>((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')
}
)
+47 -18
View File
@@ -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<string, number>()
private readonly wslInstallsInFlight = new Map<string, Promise<AgentHookInstallStatus | null>>()
private supersedeWslReconciliation(runtimeHomePath: string | null | undefined): number {
if (!runtimeHomePath) {
@@ -1177,6 +1172,30 @@ export class CodexHookService {
}
}
installForRuntimeHomeSerialized(
runtimeHomePath: string | null | undefined,
target?: CodexWslRuntimeHookTarget
): Promise<AgentHookInstallStatus | null> {
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<string>(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
@@ -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()
})
})
@@ -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<string, Map<string, ManagedWslCodexHome>>()
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
}
@@ -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<AgentHookInstallStatus | null> | null
}): Promise<AgentHookInstallStatus | null> {
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
})
}
+4
View File
@@ -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'),
@@ -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' })
+1 -1
View File
@@ -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
@@ -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'
@@ -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<typeof runProcessSync> =>
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')
})
}
)
@@ -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<PressAndHoldHost> & { 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>): () => 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(
'<plist><dict>\n<key>CFBundleName</key>\n<string>Orca</string>\n' +
'<key>CFBundleIdentifier</key>\n\t<string>com.stablyai.orca</string>\n</dict></plist>'
)
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('<plist><dict/></plist>'))).toBe(
null
)
expect(
readBundleIdentifierFromExecutablePath(
bundleWithPlist('<key>CFBundleIdentifier</key><string></string>')
)
).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)
})
})
+257
View File
@@ -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 <domain> <key>` 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<PressAndHoldDecision>(['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}.`)
}
/** `<bundle>/Contents/MacOS/<exe>` → `<bundle>/Contents/Info.plist`. */
export function readBundleIdentifierFromExecutablePath(execPath: string): string | null {
try {
const plist = readFileSync(join(dirname(dirname(execPath)), 'Info.plist'), 'utf8')
const match = /<key>CFBundleIdentifier<\/key>\s*<string>([^<]*)<\/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<PressAndHoldRecord>
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<Record<PressAndHoldDecision, string>> = {
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
}
@@ -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<string, unknown> {
function isRecord<T>(value: unknown): value is Record<string, T> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function arrayOrEmpty<T>(value: unknown): T[] {
return Array.isArray(value) ? (value as T[]) : []
return Array.isArray(value) ? value : []
}
function recordOrEmpty<T>(value: unknown): Record<string, T> {
return isRecord(value) ? (value as Record<string, T>) : {}
return isRecord<T>(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<PersistedState>
const parsed: Partial<PersistedState> = JSON.parse(readFileSync(dataFile, 'utf-8'))
return rebuildRepoBackedProjectState({
...defaults,
...parsed,
repos: arrayOrEmpty<Repo>(parsed.repos),
projects: arrayOrEmpty<Project>(parsed.projects),
projectHostSetups: arrayOrEmpty<ProjectHostSetup>(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<Project>(parsed.projects)),
projectHostSetups: normalizeProjectHostSetupRows(
arrayOrEmpty<ProjectHostSetup>(parsed.projectHostSetups)
),
projectGroups: arrayOrEmpty(parsed.projectGroups),
folderWorkspaces: arrayOrEmpty(parsed.folderWorkspaces),
sparsePresetsByRepo: recordOrEmpty<SparsePreset[]>(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<ExecutionHostId, WorkspaceSessionState>
>)
workspaceSessionsByHostId: isRecord<WorkspaceSessionState>(parsed.workspaceSessionsByHostId)
? parsed.workspaceSessionsByHostId
: {},
sshTargets: arrayOrEmpty(parsed.sshTargets),
sshRemotePtyLeases: arrayOrEmpty(parsed.sshRemotePtyLeases),
@@ -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
@@ -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<T extends object>(row: T, overrides: Record<string, unknown>): T {
for (const [key, value] of Object.entries(overrides)) {
Reflect.set(row, key, value)
}
return row
}
function makeParsed(
setups: ProjectHostSetup[],
projects: Project[] = []
): Partial<Pick<PersistedState, 'projects' | 'projectHostSetups'>> {
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([])
})
})
@@ -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<Pick<PersistedState, 'projects' | 'projectHostSetups'>>,
markNeedsSave: () => void
): Pick<PersistedState, 'projects' | 'projectHostSetups'> {
const projects = normalizeProjectRows(parsed.projects ?? [])
const projectHostSetups = normalizeProjectHostSetupRows(parsed.projectHostSetups ?? [])
if (projects !== parsed.projects || projectHostSetups !== parsed.projectHostSetups) {
markNeedsSave()
}
return { projects, projectHostSetups }
}
@@ -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)
})
@@ -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'
+11 -4
View File
@@ -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
+3 -1
View File
@@ -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')
+2
View File
@@ -77,6 +77,7 @@ export function addOrcaWslInteropEnv(env: Record<string, string>): 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<string, string>): 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',
+1
View File
@@ -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
@@ -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()
})
})
@@ -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')
})
}
})
]
+2
View File
@@ -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,
+6 -2
View File
@@ -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
+8 -6
View File
@@ -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()
@@ -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()
}
})
})
+30 -87
View File
@@ -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<string, unknown> }
@@ -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()
+6 -2
View File
@@ -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
}
@@ -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)
})
})
@@ -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}
>
<div className="flex h-full min-w-0 flex-1">
<FloatingWorkspaceTabDragContext enabled={open}>
<TabBar
tabs={terminalItems}
activeTabId={activeTerminalId}
@@ -1874,7 +1870,7 @@ export function FloatingTerminalPanel({
tabBarOrder={tabBarOrder}
tabStripChrome="floating-panel"
/>
</div>
</FloatingWorkspaceTabDragContext>
<FloatingTerminalWindowControls
maximized={maximized}
onToggleMaximized={toggleMaximized}
@@ -0,0 +1,49 @@
import { cloneElement } from 'react'
import { DndContext, DragOverlay } from '@dnd-kit/core'
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants'
import TabDragPreview from '../tab-bar/TabDragPreview'
import { TabDragProvider } from '../tab-group/tab-drag-context'
import { useTabDragSplit, type HoveredTabInsertion } from '../tab-group/useTabDragSplit'
/** dnd-kit host for the floating workspace tab strip, so its tabs reorder with
* the same gesture and insertion bar the main workspace tab bar uses. The panel
* renders one group and no split layout, so a drop only ever resolves to a
* same-group reorder — the pane-column split branches find no panel geometry. */
export function FloatingWorkspaceTabDragContext({
enabled,
children
}: {
/** False while the panel is hidden: a closed panel must not register sensors that compete with the workspace DndContext. */
enabled: boolean
children: React.ReactElement<{ hoveredTabInsertion?: HoveredTabInsertion | null }>
}): React.JSX.Element {
const dragSplit = useTabDragSplit({ worktreeId: FLOATING_TERMINAL_WORKTREE_ID, enabled })
return (
<TabDragProvider
isTabDragActive={dragSplit.activeDrag !== null}
isTabDragActiveRef={dragSplit.isTabDragActiveRef}
>
<DndContext
sensors={dragSplit.sensors}
collisionDetection={dragSplit.collisionDetection}
onDragStart={dragSplit.onDragStart}
onDragMove={dragSplit.onDragMove}
onDragOver={dragSplit.onDragOver}
onDragEnd={dragSplit.onDragEnd}
onDragCancel={dragSplit.onDragCancel}
// Why: same feedback loop the workspace strip hits — autoscroll shifts tabs under a still cursor and `over` re-resolves.
autoScroll={false}
>
<div ref={dragSplit.setDragRootNode} className="flex h-full min-w-0 flex-1">
{/* 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 })}
</div>
{/* Why: the source tab stays anchored in the overflow-hidden strip; this ghost is what follows the cursor. */}
<DragOverlay dropAnimation={null}>
{dragSplit.activeDrag ? <TabDragPreview drag={dragSplit.activeDrag} /> : null}
</DragOverlay>
</DndContext>
</TabDragProvider>
)
}
@@ -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
}
@@ -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<typeof useTabDragSplit> {
let result: ReturnType<typeof useTabDragSplit> | 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<typeof drag.onDragStart>[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)
})
})
@@ -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<HTMLButtonElement>(
'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()
@@ -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 (
<span
role={canBrowseTasks ? 'button' : undefined}
tabIndex={-1}
onClick={(e) => {
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}
<button
type="button"
onClick={onOpen}
className="rounded p-0.5 text-muted-foreground/70 transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-worktree-sidebar-ring"
aria-label={label}
>
{children}
</span>
</button>
)
}
@@ -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 (
<ContextMenu>
<ContextMenuTrigger asChild>
<button
type="button"
onClick={() => {
if (!canBrowseTasks) {
return
}
openTaskPage()
}}
onPointerEnter={handlePrefetch}
onFocus={handlePrefetch}
aria-disabled={!canBrowseTasks}
aria-current={tasksActive ? 'page' : undefined}
data-contextual-tour-target="sidebar-tasks"
className={cn(
'group flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-[13px] font-medium tracking-tight transition-colors',
tasksActive
? 'bg-worktree-sidebar-accent text-worktree-sidebar-accent-foreground'
: 'text-worktree-sidebar-foreground/60 hover:bg-worktree-sidebar-foreground/8',
!canBrowseTasks && 'cursor-not-allowed opacity-50 hover:bg-transparent'
)}
>
<List
className={cn('size-4 shrink-0', !tasksActive && 'text-worktree-sidebar-foreground/30')}
strokeWidth={tasksActive ? 2.25 : 1.75}
/>
<span className="flex-1">
{translate('auto.components.sidebar.SidebarNav.fee535205b', 'Tasks')}
</span>
<span className="hidden items-center gap-1 group-hover:flex group-focus-within:flex">
{/* Why: shortcuts sit beside the Tasks button, not inside it, so each stays keyboard-reachable. */}
<div className="group relative">
<button
type="button"
onClick={() => openTaskPage()}
onPointerEnter={handlePrefetch}
onFocus={handlePrefetch}
aria-current={tasksActive ? 'page' : undefined}
data-contextual-tour-target="sidebar-tasks"
className={cn(
'flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-[13px] font-medium tracking-tight transition-colors',
tasksActive
? 'bg-worktree-sidebar-accent text-worktree-sidebar-accent-foreground'
: 'text-worktree-sidebar-foreground/60 group-hover:bg-worktree-sidebar-foreground/8'
)}
>
<List
className={cn(
'size-4 shrink-0',
!tasksActive && 'text-worktree-sidebar-foreground/30'
)}
strokeWidth={tasksActive ? 2.25 : 1.75}
/>
<span className="flex-1">
{translate('auto.components.sidebar.SidebarNav.fee535205b', 'Tasks')}
</span>
</button>
<span className="absolute right-2 top-1/2 flex -translate-y-1/2 items-center gap-1 can-hover:pointer-events-none can-hover:opacity-0 can-hover:group-hover:pointer-events-auto can-hover:group-hover:opacity-100 can-hover:group-focus-within:pointer-events-auto can-hover:group-focus-within:opacity-100">
{visibleTaskProviders.includes('github') ? (
<TaskProviderShortcut
canBrowseTasks={canBrowseTasks}
label={translate(
'auto.components.sidebar.SidebarNav.0ccba862b8',
'Open GitHub tasks'
@@ -214,7 +197,6 @@ export function SidebarTaskNavButton(): React.JSX.Element | null {
) : null}
{visibleTaskProviders.includes('gitlab') ? (
<TaskProviderShortcut
canBrowseTasks={canBrowseTasks}
label={translate(
'auto.components.sidebar.SidebarNav.196c1b5362',
'Open GitLab tasks'
@@ -226,7 +208,6 @@ export function SidebarTaskNavButton(): React.JSX.Element | null {
) : null}
{visibleTaskProviders.includes('linear') ? (
<TaskProviderShortcut
canBrowseTasks={canBrowseTasks}
label={translate(
'auto.components.sidebar.SidebarNav.c39ab10000',
'Open Linear tasks'
@@ -238,7 +219,6 @@ export function SidebarTaskNavButton(): React.JSX.Element | null {
) : null}
{visibleTaskProviders.includes('jira') ? (
<TaskProviderShortcut
canBrowseTasks={canBrowseTasks}
label={translate(
'auto.components.sidebar.SidebarNav.e7ad3c540d',
'Open Jira tasks'
@@ -249,7 +229,7 @@ export function SidebarTaskNavButton(): React.JSX.Element | null {
</TaskProviderShortcut>
) : null}
</span>
</button>
</div>
</ContextMenuTrigger>
<HideTaskSidebarMenu onHide={hideTasksButton} />
</ContextMenu>
@@ -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()
@@ -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<TCommand> = {
commandListRef: RefObject<HTMLDivElement | null>
@@ -33,6 +34,10 @@ export function useTabBarQuickCommandSearchInput<TCommand>({
if (imeEnter.ownsKeyDown(event)) {
return
}
if (isSelectAllShortcut(event)) {
event.stopPropagation()
return
}
if (event.key === 'Enter' && selectedCommand) {
event.preventDefault()
event.stopPropagation()
@@ -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<HTMLInputElement>('input'),
document.body.querySelector<HTMLTextAreaElement>('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 () => {
@@ -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({
<div
className="scrollbar-sleek flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto px-6 py-5"
onKeyDown={(event) => {
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()
+14
View File
@@ -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<KeyboardEvent, 'altKey' | 'ctrlKey' | 'key' | 'metaKey' | 'shiftKey'>
): 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
}
@@ -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<string, Project> {
const projectById = new Map(projectList.map((project) => [project.id, project]))
const byRepoId = new Map<string, Project>()
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])
})
})
@@ -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<void> {
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 {
@@ -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)
@@ -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<StateCreator<AppState>>[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: {}
@@ -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',
@@ -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
@@ -21,6 +21,7 @@ export type EndpointFileFields = {
token: string
env: string
version: string
transport?: string
}
/** Atomically write the endpoint file at `endpointDir/<getEndpointFileName()>`.
@@ -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(
@@ -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<string, unknown>, key: string): string | undefined {
const value = record[key]
if (typeof value !== 'string') {
+3
View File
@@ -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
@@ -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<T extends object>(row: T, overrides: Record<string, unknown>): T {
for (const [key, value] of Object.entries(overrides)) {
Reflect.set(row, key, value)
}
return row
}
function makeSetup(overrides: Record<string, unknown> = {}): 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<string, unknown> = {}): 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([])
})
})
@@ -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<string, unknown> {
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<string, unknown> = 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<string, unknown> = 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<T>(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)
}
+8 -2
View File
@@ -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
}
}
@@ -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<StartupState> {
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
? /<key>CFBundleIdentifier<\/key>\s*<string>([^<]*)<\/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()
})
})