mirror of
https://github.com/stablyai/orca.git
synced 2026-10-24 00:04:01 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
@@ -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']
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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' })
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
}
|
||||
})
|
||||
]
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
+26
@@ -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>
|
||||
)
|
||||
}
|
||||
+13
@@ -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()
|
||||
|
||||
+43
-1
@@ -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()
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user