Merge remote-tracking branch 'origin/main' into brennanb2025/chat-ui-supported-agents

This commit is contained in:
Brennan Benson
2026-08-27 14:54:16 -07:00
210 changed files with 16282 additions and 1984 deletions
+1
View File
@@ -103,6 +103,7 @@ docs/**
!docs/reference/git-compatibility.md
!docs/reference/headless-linux-server.md
!docs/reference/linux-glibc-compatibility.md
!docs/reference/macos-press-and-hold.md
!docs/reference/orcad-operations.md
!docs/reference/relay-grace-time-reconfiguration.md
!docs/reference/windows-process-enumeration.md
File diff suppressed because one or more lines are too long
+1
View File
@@ -5,6 +5,7 @@
"../src/shared/**/*",
"../src/main/agent-state-file-reader.ts",
"../src/main/agent-hooks/hook-stdin-contract.ts",
"../src/main/agent-hooks/hook-post-command.ts",
"../src/main/agent-hooks/hook-config-write-path.ts",
"../src/main/agent-hooks/hooks-json-read.ts",
"../src/main/agent-hooks/installer-utils.ts",
+4 -4
View File
@@ -1,5 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="106" height="20" role="img" aria-label="downloads: 30m">
<title>downloads: 30m</title>
<svg xmlns="http://www.w3.org/2000/svg" width="106" height="20" role="img" aria-label="downloads: 31m">
<title>downloads: 31m</title>
<linearGradient id="s" x2="0" y2="100%">
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
<stop offset="1" stop-opacity=".1"/>
@@ -15,7 +15,7 @@
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11">
<text x="37" y="15" fill="#010101" fill-opacity=".3">downloads</text>
<text x="37" y="14">downloads</text>
<text x="90" y="15" fill="#010101" fill-opacity=".3">30m</text>
<text x="90" y="14">30m</text>
<text x="90" y="15" fill="#010101" fill-opacity=".3">31m</text>
<text x="90" y="14">31m</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 935 B

After

Width:  |  Height:  |  Size: 935 B

+58
View File
@@ -0,0 +1,58 @@
# macOS press-and-hold and key repeat
macOS opens the accent picker when a key is held unless an application opts out in its preferences
domain. That prevents held keys from repeating in terminal applications such as vim. On the first
eligible launch, Orca writes:
```sh
defaults write com.stablyai.orca ApplePressAndHoldEnabled -bool false
```
The write is scoped to Orca's packaged bundle domain. Bare Electron development bundles and
non-macOS platforms are left untouched. A fresh write is conservatively treated as taking effect
on the next launch.
## Precedence and decision record
Orca checks for an explicit domain value before writing. Either `true` or `false` is treated as a
user choice and preserved. Only an unset key receives the `false` default.
The decision is stored once in
`<userData>/macos-press-and-hold-default.json`. An `applied` or
`kept-user-preference` decision prevents future launches from touching the domain again.
Probe and write failures remain retryable so a transient failure does not permanently disable the
fix.
`defaults read <domain> <key>` is used instead of
`systemPreferences.getUserDefault`: the Electron API cannot distinguish an unset key from an
explicit `false`. Only the missing-key exit status is interpreted as unset; spawn failures,
timeouts, and other exit statuses leave the preference alone.
## Restoring the accent picker
Set the preference explicitly, then restart Orca:
```sh
defaults write com.stablyai.orca ApplePressAndHoldEnabled -bool true
```
After Orca has recorded its one-time decision, deleting the key also restores the macOS default
without Orca recreating it:
```sh
defaults delete com.stablyai.orca ApplePressAndHoldEnabled
```
Development and prerelease channels may use a channel-suffixed Orca bundle identifier; use that
domain instead when applicable.
## Reverting
Deleting the startup code is not enough. AppKit reads the persisted preference, so a code revert
must also arrange to delete the key for users who ran an affected build.
## Test coverage
Unit tests cover platform guards, explicit-value preservation, retry behavior, domain ownership,
record persistence, and subprocess exit interpretation on CI. A macOS-only test additionally pins
the real `defaults(1)` behavior, but current PR CI does not execute tests on macOS.
+1
View File
@@ -209,6 +209,7 @@ export const electronViteConfig: UserConfig = {
index: resolve('src/main/index.ts'),
// Why: sandboxed webview preloads cannot load Rollup helper chunks.
'browser-window-close-preload': resolve('src/preload/browser-window-close.ts'),
'doc-preview-link-preload': resolve('src/preload/doc-preview-link.ts'),
'daemon-entry': resolve('src/main/daemon/daemon-entry.ts'),
'plugin-host-entry': resolve('src/main/plugins/plugin-host-entry.ts'),
'computer-sidecar': resolve('src/main/computer/sidecar-entry.ts'),
+28
View File
@@ -92,6 +92,7 @@ describe('agent hooks CLI handler', () => {
})
afterEach(() => {
vi.unstubAllEnvs()
vi.restoreAllMocks()
rmSync(userDataPath, { recursive: true, force: true })
})
@@ -139,6 +140,33 @@ describe('agent hooks CLI handler', () => {
})
})
it('forwards WSL pane routing to the runtime exactly once without using the host installer', async () => {
const home = '/home/jin/.local/share/orca/codex-runtime-home/home'
vi.stubEnv('CODEX_HOME', home)
vi.stubEnv('ORCA_CODEX_HOME', home)
vi.stubEnv('WSL_DISTRO_NAME', 'Ubuntu-24.04')
callMock.mockResolvedValue({ result: { state: 'installed' } })
await main(['agent', 'hooks', 'prepare-codex'], userDataPath)
expect(callMock).toHaveBeenCalledExactlyOnceWith(
'agentHooks.prepareCodexForWslPane',
{ codexHome: home, orcaCodexHome: home, wslDistro: 'Ubuntu-24.04' },
{ timeoutMs: 50_000 }
)
expect(prepareManagedCodexHomeBeforeShellLaunchMock).not.toHaveBeenCalled()
})
it('fails open when WSL runtime preparation is unavailable', async () => {
vi.stubEnv('CODEX_HOME', '/home/jin/.local/share/orca/codex-runtime-home/home')
vi.stubEnv('ORCA_CODEX_HOME', '/home/jin/.local/share/orca/codex-runtime-home/home')
vi.stubEnv('WSL_DISTRO_NAME', 'Ubuntu')
callMock.mockRejectedValue(new Error('method_not_found'))
await expect(main(['agent', 'hooks', 'prepare-codex'], userDataPath)).resolves.toBeUndefined()
expect(prepareManagedCodexHomeBeforeShellLaunchMock).not.toHaveBeenCalled()
})
it('honors Codex-specific disablement when the runtime is unavailable', async () => {
const state = getDefaultPersistedState(userDataPath)
state.settings.disabledTuiAgents = ['codex']
+19
View File
@@ -28,6 +28,9 @@ type AgentHookCommandResult = {
statuses: AgentHookInstallStatus[]
}
// Covers managed-home verification, WSL identity, trust grant, and bounded app-server reap.
const WSL_CODEX_PREPARE_TIMEOUT_MS = 50_000
function getDataPath(): string {
const userDataPath = getDefaultUserDataPath()
const indexPath = join(userDataPath, 'orca-profile-index.json')
@@ -207,6 +210,22 @@ async function setAgentHooksEnabled(
export const AGENT_HOOK_HANDLERS: Record<string, CommandHandler> = {
'agent hooks prepare-codex': async ({ client }) => {
if (process.env.WSL_DISTRO_NAME?.trim()) {
try {
await client.call(
'agentHooks.prepareCodexForWslPane',
{
codexHome: process.env.CODEX_HOME ?? '',
orcaCodexHome: process.env.ORCA_CODEX_HOME ?? '',
wslDistro: process.env.WSL_DISTRO_NAME
},
{ timeoutMs: WSL_CODEX_PREPARE_TIMEOUT_MS }
)
} catch {
// Best effort: old or unavailable runtimes must not block Codex launch.
}
return
}
const settings = await readHookSettings(client)
await prepareManagedCodexHomeBeforeShellLaunch({
userDataPath: getDefaultUserDataPath(),
@@ -72,7 +72,7 @@ fi
# Why || : twice — zsh alone aborts inside the substitution, but every shell's
# assignment adopts its exit status, so an absent codex trips set -e in bash too.
__orca_codex_binary="$(unalias codex 2>/dev/null || :; command -v codex 2>/dev/null || :)"
if [[ -n "${ORCA_CODEX_LAUNCH_PREFLIGHT:-}" && -n "${__orca_codex_binary:-}" && -x "${__orca_codex_binary}" ]]; then
if [[ -n "${ORCA_CODEX_LAUNCH_PREFLIGHT:-}" && -x "${ORCA_CODEX_LAUNCH_PREFLIGHT}" && -n "${__orca_codex_binary:-}" && -x "${__orca_codex_binary}" ]]; then
# Why the function reserved word: it suppresses alias expansion of the name,
# which otherwise rewrites this header at parse time and aborts the whole file.
function codex {
@@ -2,7 +2,7 @@ function __orca_shell_ready_marker --on-event fish_prompt
builtin printf "\033]777;orca-shell-ready\007"
functions -e __orca_shell_ready_marker
end
if test -n "$ORCA_CODEX_LAUNCH_PREFLIGHT"; and test (type -t codex 2>/dev/null) = file
if test -x "$ORCA_CODEX_LAUNCH_PREFLIGHT"; and test (type -t codex 2>/dev/null) = file
function codex
command "$ORCA_CODEX_LAUNCH_PREFLIGHT" agent hooks prepare-codex >/dev/null 2>&1; or true
command codex $argv
@@ -110,7 +110,7 @@ __orca_deferred_init() {
# Why || : twice — zsh alone aborts inside the substitution, but every shell's
# assignment adopts its exit status, so an absent codex trips set -e in bash too.
__orca_codex_binary="$(unalias codex 2>/dev/null || :; command -v codex 2>/dev/null || :)"
if [[ -n "${ORCA_CODEX_LAUNCH_PREFLIGHT:-}" && -n "${__orca_codex_binary:-}" && -x "${__orca_codex_binary}" ]]; then
if [[ -n "${ORCA_CODEX_LAUNCH_PREFLIGHT:-}" && -x "${ORCA_CODEX_LAUNCH_PREFLIGHT}" && -n "${__orca_codex_binary:-}" && -x "${__orca_codex_binary}" ]]; then
# Why the function reserved word: it suppresses alias expansion of the name,
# which otherwise rewrites this header at parse time and aborts the whole file.
function codex {
@@ -75,7 +75,7 @@ fi
# Why || : twice — zsh alone aborts inside the substitution, but every shell's
# assignment adopts its exit status, so an absent codex trips set -e in bash too.
__orca_codex_binary="$(unalias codex 2>/dev/null || :; command -v codex 2>/dev/null || :)"
if [[ -n "${ORCA_CODEX_LAUNCH_PREFLIGHT:-}" && -n "${__orca_codex_binary:-}" && -x "${__orca_codex_binary}" ]]; then
if [[ -n "${ORCA_CODEX_LAUNCH_PREFLIGHT:-}" && -x "${ORCA_CODEX_LAUNCH_PREFLIGHT}" && -n "${__orca_codex_binary:-}" && -x "${__orca_codex_binary}" ]]; then
# Why the function reserved word: it suppresses alias expansion of the name,
# which otherwise rewrites this header at parse time and aborts the whole file.
function codex {
@@ -2,7 +2,7 @@ function __orca_shell_ready_marker --on-event fish_prompt
builtin printf "\033]777;orca-shell-ready\007"
functions -e __orca_shell_ready_marker
end
if test -n "$ORCA_CODEX_LAUNCH_PREFLIGHT"; and test (type -t codex 2>/dev/null) = file
if test -x "$ORCA_CODEX_LAUNCH_PREFLIGHT"; and test (type -t codex 2>/dev/null) = file
function codex
command "$ORCA_CODEX_LAUNCH_PREFLIGHT" agent hooks prepare-codex >/dev/null 2>&1; or true
command codex $argv
@@ -110,7 +110,7 @@ __orca_deferred_init() {
# Why || : twice — zsh alone aborts inside the substitution, but every shell's
# assignment adopts its exit status, so an absent codex trips set -e in bash too.
__orca_codex_binary="$(unalias codex 2>/dev/null || :; command -v codex 2>/dev/null || :)"
if [[ -n "${ORCA_CODEX_LAUNCH_PREFLIGHT:-}" && -n "${__orca_codex_binary:-}" && -x "${__orca_codex_binary}" ]]; then
if [[ -n "${ORCA_CODEX_LAUNCH_PREFLIGHT:-}" && -x "${ORCA_CODEX_LAUNCH_PREFLIGHT}" && -n "${__orca_codex_binary:-}" && -x "${__orca_codex_binary}" ]]; then
# Why the function reserved word: it suppresses alias expansion of the name,
# which otherwise rewrites this header at parse time and aborts the whole file.
function codex {
+37
View File
@@ -0,0 +1,37 @@
import type { AgentHookSource } from '../../shared/agent-hook-relay'
import { ORCA_HOOK_RAW_JSON_TRANSPORT } from '../../shared/agent-hook-types'
export function buildPosixAgentHookPostCommand(
source: AgentHookSource,
options: { curlCommand?: string; indent?: string } = {}
): string[] {
const curlCommand = options.curlCommand ?? 'curl'
const indent = options.indent ?? ' '
return [
`if [ "\${ORCA_AGENT_HOOK_TRANSPORT:-}" = "${ORCA_HOOK_RAW_JSON_TRANSPORT}" ] && command -v base64 >/dev/null 2>&1 && command -v tr >/dev/null 2>&1; then`,
` orca_hook_metadata=$(printf '%s\\037%s\\037%s\\037%s\\037%s\\037%s' "$ORCA_PANE_KEY" "$ORCA_TAB_ID" "$ORCA_AGENT_LAUNCH_TOKEN" "$ORCA_WORKTREE_ID" "$ORCA_AGENT_HOOK_ENV" "$ORCA_AGENT_HOOK_VERSION" | base64 | tr -d '\\n') && \\`,
` [ -n "$orca_hook_metadata" ] && \\`,
` printf '%s' "$payload" | ${curlCommand} -sS -X POST "http://127.0.0.1:\${ORCA_AGENT_HOOK_PORT}/hook/${source}" \\`,
` ${indent}--connect-timeout "\${connect_timeout:-0.5}" --max-time "\${max_time:-1.5}" \\`,
` ${indent}--noproxy "127.0.0.1" \\`,
` ${indent}-H "Content-Type: application/json" \\`,
` ${indent}-H "X-Orca-Agent-Hook-Token: \${ORCA_AGENT_HOOK_TOKEN}" \\`,
` ${indent}-H "X-Orca-Agent-Hook-Meta-Encoding: base64" \\`,
` ${indent}-H "X-Orca-Agent-Hook-Meta: \${orca_hook_metadata}" \\`,
` ${indent}--data-binary @-`,
'else',
` printf '%s' "$payload" | ${curlCommand} -sS -X POST "http://127.0.0.1:\${ORCA_AGENT_HOOK_PORT}/hook/${source}" \\`,
` ${indent}--connect-timeout "\${connect_timeout:-0.5}" --max-time "\${max_time:-1.5}" \\`,
` ${indent}--noproxy "127.0.0.1" \\`,
` ${indent}-H "Content-Type: application/x-www-form-urlencoded" \\`,
` ${indent}-H "X-Orca-Agent-Hook-Token: \${ORCA_AGENT_HOOK_TOKEN}" \\`,
` ${indent}--data-urlencode "paneKey=\${ORCA_PANE_KEY}" \\`,
` ${indent}--data-urlencode "tabId=\${ORCA_TAB_ID}" \\`,
` ${indent}--data-urlencode "launchToken=\${ORCA_AGENT_LAUNCH_TOKEN}" \\`,
` ${indent}--data-urlencode "worktreeId=\${ORCA_WORKTREE_ID}" \\`,
` ${indent}--data-urlencode "env=\${ORCA_AGENT_HOOK_ENV}" \\`,
` ${indent}--data-urlencode "version=\${ORCA_AGENT_HOOK_VERSION}" \\`,
` ${indent}--data-urlencode "payload@-"`,
'fi'
]
}
@@ -30,6 +30,7 @@ import {
writeHooksJson,
type HooksConfig
} from './installer-utils'
import { buildPosixAgentHookPostCommand } from './hook-post-command'
import { POSIX_HOOK_STDIN_DRAIN_COMMAND } from './hook-stdin-contract'
import { wrapRuntimeHomeHookCommand } from './runtime-home-hook-command'
@@ -882,6 +883,26 @@ describe('buildWindowsAgentHookPostCommand', () => {
})
})
describe('buildPosixAgentHookPostCommand', () => {
it('uses raw JSON only when the listener advertises support', () => {
const command = buildPosixAgentHookPostCommand('claude').join('\n')
expect(command).toContain('ORCA_AGENT_HOOK_TRANSPORT:-}')
expect(command).toContain('raw-json-v1')
expect(command).toContain('command -v base64')
expect(command).toContain('command -v tr')
expect(command).toContain('Content-Type: application/json')
expect(command).toContain('X-Orca-Agent-Hook-Meta-Encoding: base64')
expect(command).toContain('X-Orca-Agent-Hook-Meta: ${orca_hook_metadata}')
expect(command).toContain("printf '%s\\037%s\\037%s\\037%s\\037%s\\037%s'")
expect(command).toContain('$ORCA_PANE_KEY')
expect(command).toContain('$ORCA_WORKTREE_ID')
expect(command).toContain('--data-binary @-')
expect(command).toContain('Content-Type: application/x-www-form-urlencoded')
expect(command).toContain('--data-urlencode "payload@-"')
})
})
describe('buildWindowsAgentHookCurlPostCommand', () => {
it('posts form fields via curl.exe and reads the payload from stdin', () => {
const command = buildWindowsAgentHookCurlPostCommand('codex')
@@ -284,7 +284,7 @@ describe('remote hook service installers', () => {
hooks: Record<string, { hooks: { command: string }[] }[]>
}
expect(hooks.hooks.Stop?.[0]?.hooks?.[0]?.command).toContain(
'/home/dev/.orca/agent-hooks/codex-hook.sh'
'/home/dev/.local/share/orca/codex-runtime-home/home/.orca/agent-hooks/codex-hook.sh'
)
expect(fs.files.get(`${runtimeHome}/config.toml`)).toContain(
`${runtimeHome}/hooks.json:stop:0:0`
@@ -66,6 +66,7 @@ describe('Endpoint file lifecycle', () => {
expect(contents).toContain(`${prefix}ORCA_AGENT_HOOK_TOKEN=${expectedToken}`)
expect(contents).toContain(`${prefix}ORCA_AGENT_HOOK_ENV=development`)
expect(contents).toContain(`${prefix}ORCA_AGENT_HOOK_VERSION=1`)
expect(contents).toContain(`${prefix}ORCA_AGENT_HOOK_TRANSPORT=raw-json-v1`)
} finally {
server.stop()
}
@@ -44,6 +44,38 @@ async function postClaudeHook(
}
describe('AgentHookServer listener replay', () => {
it('accepts raw JSON hook bodies with base64 metadata headers', async () => {
const server = new AgentHookServer()
await server.start({ env: 'production' })
try {
const env = server.buildPtyEnv()
const response = await fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/claude`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN,
'X-Orca-Agent-Hook-Meta-Encoding': 'base64',
'X-Orca-Agent-Hook-Meta': Buffer.from(
[PANE, 'tab-1', '', 'wt-1', 'production', ''].join('\x1f')
).toString('base64')
},
body: JSON.stringify({ hook_event_name: 'UserPromptSubmit', prompt: 'raw JSON' })
})
expect(response.status).toBe(204)
expect(server.getStatusSnapshot()).toEqual([
expect.objectContaining({
paneKey: PANE,
worktreeId: 'wt-1',
state: 'working',
prompt: 'raw JSON'
})
])
} finally {
server.stop()
}
})
it('caches and notifies status/main/plugin before retry scheduling and HTTP response', async () => {
const server = new AgentHookServer()
await server.start({ env: 'production' })
+12 -5
View File
@@ -8,7 +8,10 @@ import { join } from 'node:path'
import { track } from '../telemetry/client'
import { getCohortAtEmit } from '../telemetry/cohort-classifier'
import { AGENT_KIND_VALUES, type AgentKind } from '../../shared/telemetry-events'
import { ORCA_HOOK_PROTOCOL_VERSION } from '../../shared/agent-hook-types'
import {
ORCA_HOOK_PROTOCOL_VERSION,
ORCA_HOOK_RAW_JSON_TRANSPORT
} from '../../shared/agent-hook-types'
import {
clearAllListenerCaches,
clearPaneCacheState,
@@ -46,6 +49,7 @@ import {
} from '../../shared/agent-hook-listener/listener-limits'
import { isNewTurnEvent } from '../../shared/agent-hook-listener/provider-event-routing'
import { normalizeHookPayload } from '../../shared/agent-hook-listener'
import { mergeAgentHookRequestHeaders } from '../../shared/agent-hook-listener/hook-envelope'
import {
parseFormEncodedBody,
readRequestBody
@@ -2616,8 +2620,9 @@ export class AgentHookServer {
return
}
trackEmptyPaneKeyHook(body)
const aliasedBody = this.normalizeHookBodyPaneKeyAlias(body)
const hookBody = mergeAgentHookRequestHeaders(body, req.headers)
trackEmptyPaneKeyHook(hookBody)
const aliasedBody = this.normalizeHookBodyPaneKeyAlias(hookBody)
const normalized = this.normalizeLocalHookPayload(source, aliasedBody)
const statusDisposition = normalized.event
? this.getAgentStatusDisposition(normalized.event.paneKey, {
@@ -3129,7 +3134,8 @@ export class AgentHookServer {
ORCA_AGENT_HOOK_PORT: String(this.port),
ORCA_AGENT_HOOK_TOKEN: this.token,
ORCA_AGENT_HOOK_ENV: this.env,
ORCA_AGENT_HOOK_VERSION: ORCA_HOOK_PROTOCOL_VERSION
ORCA_AGENT_HOOK_VERSION: ORCA_HOOK_PROTOCOL_VERSION,
ORCA_AGENT_HOOK_TRANSPORT: ORCA_HOOK_RAW_JSON_TRANSPORT
}
// Why: hooks source this file at invocation; dev namespaces it so parallel `pnpm dev` runs don't steal each other's hooks.
if (this.endpointFileWritten && this.endpointFilePathCache) {
@@ -3156,7 +3162,8 @@ export class AgentHookServer {
port: this.port,
token: this.token,
env: this.env,
version: ORCA_HOOK_PROTOCOL_VERSION
version: ORCA_HOOK_PROTOCOL_VERSION,
transport: ORCA_HOOK_RAW_JSON_TRANSPORT
})
this.endpointFileWritten = ok
}
+24 -7
View File
@@ -12,19 +12,23 @@ import {
type ManagedHookDetectionSettings
} from './managed-hook-detection-commands'
import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer'
import { wslCodexRuntimeHomeForGuestHome } from '../pty/codex-home-wsl-env'
import { WSL_HOOK_FS_METHODS, type WslFsResult } from '../../shared/wsl-hook-relay-contract'
import type { AgentHookInstallStatus } from '../../shared/agent-hook-types'
/** Run the shared remote hook installers against a WSL guest over the relay's fs bridge. */
export async function installWslGuestHooks(options: {
mux: SshChannelMultiplexer
guestHome: string
codexHomePath: string | null
distro: string
installHooks: typeof installRemoteManagedAgentHooks
settings: ManagedHookDetectionSettings
warn: (message: string) => void
/** Canonical runtime-host installer for redirected Codex homes. */
installCodex: (runtimeHomePath: string, distro: string) => Promise<AgentHookInstallStatus | null>
}): Promise<void> {
const { mux, guestHome, distro, installHooks, settings, warn } = options
const { mux, guestHome, codexHomePath, distro, installHooks, settings, warn, installCodex } =
options
let agents
try {
const detected = (await mux.request('preflight.detectAgents', {
@@ -42,12 +46,25 @@ export async function installWslGuestHooks(options: {
if (agents.length === 0) {
return
}
if (agents.includes('codex') && codexHomePath) {
try {
const status = await installCodex(codexHomePath, distro)
if (status?.state === 'error') {
warn(
`[agent-hooks] WSL Codex hook install for '${distro}' failed: ${status.detail ?? 'unknown error'}`
)
}
} catch (error) {
warn(
`[agent-hooks] WSL Codex hook install for '${distro}' failed: ${error instanceof Error ? error.message : String(error)}`
)
}
}
// Codex is redirected into the runtime home and must use the canonical
// runtime-host writer above; the relay adapter owns all other agents.
const remoteAgents = agents.filter((agent) => agent !== 'codex')
const results = await installHooks(createWslHookSftpAdapter(mux), guestHome, {
agents,
// WSL Codex launches use Orca's managed runtime CODEX_HOME, not ~/.codex.
// Keep relay hooks in that active home so status callbacks are received.
codexHomeDir: wslCodexRuntimeHomeForGuestHome(guestHome),
deferTrustUntilConfigToml: true
agents: remoteAgents
})
const failed = results.filter((r) => r.state === 'error').length
if (failed > 0) {
@@ -9,6 +9,8 @@ import { agentHookServer } from './server'
import type { ManagedHookDetectionSettings } from './managed-hook-detection-commands'
import { installRemoteManagedAgentHooks } from './remote-managed-hook-installers'
import { getOpenCodePluginSource } from '../opencode/hook-service'
import { codexHookService } from '../codex/hook-service'
import type { AgentHookInstallStatus } from '../../shared/agent-hook-types'
import type { PluginSources } from '../../relay/plugin-overlay'
import {
isWslDistroRunning,
@@ -61,6 +63,7 @@ export type WslHookRelayManagerDeps = {
waitForSentinel: typeof waitForWslRelaySentinel
ingest: (envelope: Record<string, unknown>, connectionId: string) => void
installHooks: typeof installRemoteManagedAgentHooks
installCodex: (runtimeHomePath: string, distro: string) => Promise<AgentHookInstallStatus | null>
managedHookSettings: () => ManagedHookDetectionSettings
/** Plugin source strings shipped to the guest relay so an Orca update needn't redeploy the relay bundle. */
pluginSources: () => PluginSources
@@ -102,6 +105,11 @@ export const defaultWslHookRelayDeps: WslHookRelayManagerDeps = {
connectionId
),
installHooks: installRemoteManagedAgentHooks,
installCodex: (runtimeHomePath, distro) =>
codexHookService.installForRuntimeHomeSerialized(runtimeHomePath, {
runtime: 'wsl',
wslDistro: distro
}),
managedHookSettings: () => null,
// Why: only OpenCode is in scope for WSL now; the payload shape stays identical to SSH so Pi/OMP are additive later.
pluginSources: () => ({ opencodePluginSource: getOpenCodePluginSource() }),
@@ -6,13 +6,14 @@ import type { ManagedHookDetectionSettings } from './managed-hook-detection-comm
import type { installRemoteManagedAgentHooks } from './remote-managed-hook-installers'
import { requestGuestOpenCodeOverlayDir } from './wsl-guest-plugin-install'
import { installWslGuestHooks } from './wsl-hook-fs-adapter'
import { REINSTALL_MIN_INTERVAL_MS } from './wsl-hook-relay-deps'
import { REINSTALL_MIN_INTERVAL_MS, type WslHookRelayManagerDeps } from './wsl-hook-relay-deps'
import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer'
import type { PluginSources } from '../../relay/plugin-overlay'
/** Structural slice of WslHookRelayManagerDeps — only what an install pass uses. */
type GuestInstallDeps = {
installHooks: typeof installRemoteManagedAgentHooks
installCodex: WslHookRelayManagerDeps['installCodex']
managedHookSettings: () => ManagedHookDetectionSettings
pluginSources: () => PluginSources
warn: (message: string) => void
@@ -23,6 +24,7 @@ type GuestInstallState = {
distro: string
mux?: SshChannelMultiplexer
guestHome?: string
codexHomePath?: string
opencodeOverlayDir?: string
lastInstallAt?: number
}
@@ -37,8 +39,10 @@ export async function runWslRelayGuestInstall(
await installWslGuestHooks({
mux,
guestHome,
codexHomePath: state.codexHomePath ?? null,
distro: state.distro,
installHooks: deps.installHooks,
installCodex: deps.installCodex,
settings: deps.managedHookSettings(),
warn: deps.warn
})
@@ -13,7 +13,8 @@ import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
import { AgentHookServer } from './server'
import { WslHookRelayManager } from './wsl-hook-relay-manager'
import { wslCodexRuntimeHomeForGuestHome } from '../pty/codex-home-wsl-env'
import { createManagedHookLocalFilesystem } from './managed-hook-local-filesystem'
import { codexHookService } from '../codex/hook-service'
const BUNDLE_DIR = join(process.cwd(), 'out', 'relay', 'wsl')
const BUNDLE_JS = join(BUNDLE_DIR, 'wsl-agent-hook-relay.js')
@@ -75,6 +76,7 @@ describe.skipIf(process.platform === 'win32')(
const server = orcaServer
const warns: string[] = []
const codexHome = join(fakeHome, '.local', 'share', 'orca', 'codex-runtime-home', 'home')
manager = new WslHookRelayManager({
platform: () => 'win32',
remoteHooksEnabled: () => true,
@@ -102,6 +104,11 @@ describe.skipIf(process.platform === 'win32')(
envelope as Parameters<AgentHookServer['ingestRemote']>[0],
connectionId
),
installCodex: (runtimeHomePath) =>
codexHookService.installRemote(createManagedHookLocalFilesystem(), fakeHome, {
codexHomeDir: runtimeHomePath,
deferTrustUntilConfigToml: true
}),
managedHookSettings: () => ({
agentCmdOverrides: {
claude: process.execPath,
@@ -112,15 +119,17 @@ describe.skipIf(process.platform === 'win32')(
transientRetryDelayMs: 1
})
manager.ensureForDistro('LiveDistro')
manager.ensureForDistro('LiveDistro', codexHome)
// Waiting on Codex's artifact (not Claude's, which is written first) keeps the
// assertions behind the still-running 14-agent installer loop.
const codexHome = wslCodexRuntimeHomeForGuestHome(fakeHome)
await vi.waitFor(() => expect(existsSync(join(codexHome, 'hooks.json'))).toBe(true), {
timeout: 15_000
})
expect(existsSync(join(fakeHome, '.claude', 'settings.json'))).toBe(true)
await vi.waitFor(
() => expect(existsSync(join(fakeHome, '.claude', 'settings.json'))).toBe(true),
{ timeout: 15_000 }
)
const claudeScript = readFileSync(
join(fakeHome, '.orca', 'agent-hooks', 'claude-hook.sh'),
'utf8'
@@ -147,6 +147,8 @@ describe('WslHookRelayManager', () => {
// hosts — installHooks is mocked here, so the fs bridge only ever serves
// the wslfs.home request and never touches the real filesystem.
const home = '/home/wsl-test-user'
const codexHome =
'\\\\wsl.localhost\\Ubuntu\\home\\wsl-test-user\\.local\\share\\orca\\codex-runtime-home\\home'
const opencodeOverlayDir = `${home}/.orca-relay/opencode-overlays/deadbeefcafe`
let harnesses: GuestHarness[]
@@ -229,6 +231,13 @@ describe('WslHookRelayManager', () => {
waitForSentinel: vi.fn(async () => guestTransport()),
ingest: vi.fn(),
installHooks: vi.fn(async () => []),
installCodex: vi.fn(async () => ({
agent: 'codex' as const,
state: 'installed' as const,
configPath: `${home}/.local/share/orca/codex-runtime-home/home/hooks.json`,
managedHooksPresent: true,
detail: null
})),
managedHookSettings: () => null,
pluginSources: () => ({ opencodePluginSource: '// opencode plugin source' }),
warn: vi.fn(),
@@ -240,14 +249,14 @@ describe('WslHookRelayManager', () => {
it('starts one relay per distro, installs hooks, exposes the guest endpoint path, and forwards envelopes', async () => {
const { manager, deps } = createManager({})
manager.ensureForDistro('Ubuntu')
manager.ensureForDistro('Ubuntu')
manager.ensureForDistro('Ubuntu', codexHome)
manager.ensureForDistro('Ubuntu', codexHome)
await vi.waitFor(() => expect(deps.installHooks).toHaveBeenCalledTimes(1))
expect(deps.spawnRelay).toHaveBeenCalledTimes(1)
expect(deps.installCodex).toHaveBeenCalledWith(codexHome, 'Ubuntu')
// Codex is owned by the canonical runtime-host writer, not the relay adapter.
expect(deps.installHooks).toHaveBeenCalledWith(expect.anything(), home, {
agents: ['codex'],
codexHomeDir: `${home}/.local/share/orca/codex-runtime-home/home`,
deferTrustUntilConfigToml: true
agents: []
})
expect(manager.getGuestEndpointFilePath('Ubuntu')).toBe(
@@ -272,9 +281,23 @@ describe('WslHookRelayManager', () => {
manager.disposeAll()
})
it('reinstalls into a newly resolved runtime home without restarting the relay', async () => {
const { manager, deps } = createManager({})
manager.ensureForDistro('Ubuntu', codexHome)
await vi.waitFor(() => expect(deps.installCodex).toHaveBeenCalledTimes(1))
const nextHome = codexHome.replace('codex-runtime-home', 'codex-accounts\\account-2')
manager.ensureForDistro('ubuntu', nextHome)
await vi.waitFor(() => expect(deps.installCodex).toHaveBeenCalledTimes(2))
expect(deps.installCodex).toHaveBeenLastCalledWith(nextHome, 'Ubuntu')
expect(deps.spawnRelay).toHaveBeenCalledTimes(1)
manager.disposeAll()
})
it('ships the OpenCode plugin to the guest and exposes the overlay dir', async () => {
const { manager } = createManager({})
manager.ensureForDistro('Ubuntu')
manager.ensureForDistro('Ubuntu', codexHome)
await vi.waitFor(() => expect(manager.getOpenCodeOverlayDir('Ubuntu')).toBe(opencodeOverlayDir))
manager.disposeAll()
})
@@ -397,7 +420,7 @@ describe('WslHookRelayManager', () => {
it('stops live relays and refuses to revive them once agent status hooks are switched off', async () => {
const settings = { agentStatusHooksEnabled: true }
const { manager, deps } = createManager({ managedHookSettings: () => settings })
manager.ensureForDistro('Ubuntu')
manager.ensureForDistro('Ubuntu', codexHome)
await vi.waitFor(() => expect(deps.installHooks).toHaveBeenCalledTimes(1))
settings.agentStatusHooksEnabled = false
@@ -414,6 +437,8 @@ describe('WslHookRelayManager', () => {
settings.agentStatusHooksEnabled = true
manager.resumeStoppedRelays()
await vi.waitFor(() => expect(deps.spawnRelay).toHaveBeenCalledTimes(2))
await vi.waitFor(() => expect(deps.installCodex).toHaveBeenCalledTimes(2))
expect(deps.installCodex).toHaveBeenLastCalledWith(codexHome, 'Ubuntu')
manager.disposeAll()
})
+27 -8
View File
@@ -29,6 +29,10 @@ import {
WSL_HOOK_FS_METHODS,
wslHookRelayEndpointFilePath
} from '../../shared/wsl-hook-relay-contract'
import {
recordManagedWslCodexHome,
wslRuntimeHomePathsEqual
} from '../codex/managed-wsl-codex-home-registry'
type DistroState = {
/** Original casing for wsl.exe argv and breadcrumbs; map keys are lowercased. */
@@ -37,6 +41,7 @@ type DistroState = {
child?: ChildProcessWithoutNullStreams
mux?: SshChannelMultiplexer
guestHome?: string
codexHomePath?: string
guestEndpointFilePath?: string
opencodeOverlayDir?: string
failures: number
@@ -52,7 +57,7 @@ export class WslHookRelayManager {
private recovery: WslRelayRecovery
private states = new Map<string, DistroState>()
/** Distros a hooks-off teardown stopped, so re-enabling can put them back. */
private stoppedByHooksOff = new Set<string>()
private stoppedByHooksOff = new Map<string, string | undefined>()
private defaultDistro: string | null = null
private disposed = false
private warnedBundleMissing = false
@@ -64,7 +69,7 @@ export class WslHookRelayManager {
warn: (message) => this.deps.warn(message),
isDisposed: () => this.disposed,
isCurrent: (state) => this.states.get(wslHookRelayStateKey(state.distro)) === state,
restart: (distro) => this.ensureForDistro(distro),
restart: (distro) => this.ensureForDistro(distro, this.stateFor(distro)?.codexHomePath),
dropState: (state) => {
// Why: identity-guarded — a fresh ensure() may own this key by now;
// deleting by key alone would orphan its live relay child.
@@ -81,11 +86,11 @@ export class WslHookRelayManager {
}
/** Fire-and-forget from every WSL PTY spawn-env build; errors breadcrumb. */
ensureForDistro(distro: string | null): void {
ensureForDistro(distro: string | null, codexHomePath?: string | null): void {
if (this.disposed || !isWslHookRelayAllowed(this.deps)) {
return
}
void this.ensureInternal(distro).catch((err) => {
void this.ensureInternal(distro, codexHomePath ?? undefined).catch((err) => {
const detail = err instanceof Error ? err.message : String(err)
this.deps.warn(`[agent-hooks] WSL hook relay ensure failed: ${detail}`)
})
@@ -118,7 +123,7 @@ export class WslHookRelayManager {
state.mux?.dispose()
state.child?.kill()
if (!permanent) {
this.stoppedByHooksOff.add(state.distro)
this.stoppedByHooksOff.set(state.distro, state.codexHomePath)
}
}
this.states.clear()
@@ -129,26 +134,39 @@ export class WslHookRelayManager {
resumeStoppedRelays(): void {
const distros = [...this.stoppedByHooksOff]
this.stoppedByHooksOff.clear()
for (const distro of distros) {
for (const [distro, codexHomePath] of distros) {
void this.deps
.isDistroRunning(distro)
.then((running) => {
if (running) {
this.ensureForDistro(distro)
this.ensureForDistro(distro, codexHomePath)
}
})
.catch(() => undefined)
}
}
private async ensureInternal(requestedDistro: string | null): Promise<void> {
private async ensureInternal(
requestedDistro: string | null,
requestedCodexHomePath?: string
): Promise<void> {
const distro = requestedDistro ?? (await this.resolveDefaultDistro())
if (!distro || this.disposed) {
return
}
const key = wslHookRelayStateKey(distro)
const existing = this.states.get(key)
if (requestedCodexHomePath) {
recordManagedWslCodexHome(distro, requestedCodexHomePath)
}
if (existing) {
if (
requestedCodexHomePath &&
!wslRuntimeHomePathsEqual(existing.codexHomePath, requestedCodexHomePath)
) {
existing.codexHomePath = requestedCodexHomePath
existing.lastInstallAt = 0
}
if (existing.phase === 'running') {
void maybeRerunWslRelayGuestInstall(this.deps, existing)
return
@@ -184,6 +202,7 @@ export class WslHookRelayManager {
// Why: instance-keyed and on the distro's persistent fs, so it outlives a relay
// crash — dropping it would blank status on panes spawned mid-relaunch.
opencodeOverlayDir: existing?.opencodeOverlayDir,
codexHomePath: requestedCodexHomePath ?? existing?.codexHomePath,
cooldownUntil: 0
}
this.states.set(key, state)
@@ -0,0 +1,210 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const browserMocks = vi.hoisted(() => ({
appGetPathMock: vi.fn(() => '/downloads'),
shellOpenExternalMock: vi.fn(),
browserWindowFromWebContentsMock: vi.fn(),
menuBuildFromTemplateMock: vi.fn(),
guestOffMock: vi.fn(),
guestOnMock: vi.fn(),
guestSetBackgroundThrottlingMock: vi.fn(),
guestSetWindowOpenHandlerMock: vi.fn(),
guestOpenDevToolsMock: vi.fn(),
webContentsFromIdMock: vi.fn(),
screenGetCursorScreenPointMock: vi.fn(() => ({ x: 0, y: 0 })),
openPopupWithOriginBarMock: vi.fn()
}))
vi.mock('electron', () => ({
app: { getPath: browserMocks.appGetPathMock },
BrowserWindow: { fromWebContents: browserMocks.browserWindowFromWebContentsMock },
clipboard: { writeText: vi.fn() },
shell: { openExternal: browserMocks.shellOpenExternalMock },
Menu: { buildFromTemplate: browserMocks.menuBuildFromTemplateMock },
screen: { getCursorScreenPoint: browserMocks.screenGetCursorScreenPointMock },
webContents: { fromId: browserMocks.webContentsFromIdMock }
}))
vi.mock('./popup-origin-bar-window', () => ({
openPopupWithOriginBar: browserMocks.openPopupWithOriginBarMock
}))
import { browserManager } from './browser-manager'
import type { BrowserAnnotationViewportBridgeOptions } from '../../shared/browser-annotation-viewport-bridge'
import {
rendererWebContentsId,
resetBrowserManagerMocks,
resetBrowserManagerState
} from './browser-manager-test-harness'
import {
createViewportGuestFactory,
flushViewportOps
} from './browser-manager-viewport-test-fixtures'
const { webContentsFromIdMock } = browserMocks
const makeGuest = createViewportGuestFactory(browserMocks)
const BRIDGE_OPTIONS: BrowserAnnotationViewportBridgeOptions = {
emitViewport: false,
enabled: true,
markers: [],
token: 'annotationviewporttoken'
}
function registerPage(pageId: string, guest: Record<string, unknown>): void {
webContentsFromIdMock.mockReturnValue(guest)
browserManager.attachGuestPolicies(guest as never)
browserManager.registerGuest({
browserPageId: pageId,
webContentsId: guest.id as number,
rendererWebContentsId
})
}
/** The production resolver's shape: read the registry now, not when the request was made. */
function resolveFromRegistry(pageId: string): () => Electron.WebContents | null {
return () => browserManager.getAuthorizedGuest(pageId, rendererWebContentsId)
}
describe('browserManager.setAnnotationViewportBridge', () => {
beforeEach(() => {
resetBrowserManagerMocks(browserMocks)
resetBrowserManagerState()
})
afterEach(() => {
vi.restoreAllMocks()
})
it('installs the bridge in an isolated world', async () => {
const { guest } = makeGuest(4646)
registerPage('tab-annotations', guest)
const ok = await browserManager.setAnnotationViewportBridge(
'tab-annotations',
BRIDGE_OPTIONS,
resolveFromRegistry('tab-annotations')
)
expect(ok).toBe(true)
expect(guest.executeJavaScriptInIsolatedWorld).toHaveBeenCalledWith(
expect.any(Number),
[
expect.objectContaining({
code: expect.stringContaining('__orcaBrowserAnnotationViewportBridge')
})
],
false
)
})
// Why this is the case that matters: a cross-process navigation re-registers the same page id
// with a new WebContents and leaves the retired one alive, so a bridge op that resolved its
// guest when the request arrived would inject into a page nobody is looking at — and the
// annotation badges would stop tracking the document on screen.
it('injects into the guest the page has when a queued op finally runs, not the one it was asked with', async () => {
const { guest: firstGuest } = makeGuest(5101)
let releaseFirstInjection = (): void => {}
// One shared gate for every call, so a wrongly-routed second injection still settles and the
// test fails on where it landed rather than on a timeout.
const firstInjectionGate = new Promise<void>((resolve) => {
releaseFirstInjection = () => resolve()
})
firstGuest.executeJavaScriptInIsolatedWorld = vi.fn(() => firstInjectionGate)
registerPage('tab-swap', firstGuest)
const firstDone = browserManager.setAnnotationViewportBridge(
'tab-swap',
BRIDGE_OPTIONS,
resolveFromRegistry('tab-swap')
)
await flushViewportOps()
// A second request arrives while the first still holds the chain — at this moment the page is
// still on the first guest, which is the guest a request-time resolution would capture.
const secondDone = browserManager.setAnnotationViewportBridge(
'tab-swap',
BRIDGE_OPTIONS,
resolveFromRegistry('tab-swap')
)
await flushViewportOps()
// Only now does the page swap renderer processes, while the second op is still queued.
const { guest: secondGuest } = makeGuest(5102)
registerPage('tab-swap', secondGuest)
releaseFirstInjection()
await expect(firstDone).resolves.toBe(true)
await expect(secondDone).resolves.toBe(true)
expect(secondGuest.executeJavaScriptInIsolatedWorld).toHaveBeenCalledTimes(1)
expect(firstGuest.executeJavaScriptInIsolatedWorld).toHaveBeenCalledTimes(1)
})
// Why this is the resolver's cleanup and not the bridge's: the authority that reads the registry
// is the one that can see the guest is gone. The bridge only reports the refusal.
it('refuses when its guest died while the op was queued, and the resolver drops the registration', async () => {
const { guest } = makeGuest(5103)
registerPage('tab-dies', guest)
expect(browserManager.getGuestWebContentsId('tab-dies')).toBe(5103)
webContentsFromIdMock.mockReturnValue(null)
await expect(
browserManager.setAnnotationViewportBridge(
'tab-dies',
BRIDGE_OPTIONS,
resolveFromRegistry('tab-dies')
)
).resolves.toBe(false)
// Why assert the registry and not just the answer: a stale guest has to clear every per-tab
// entry, or the page keeps a dead WebContents id that later ops resolve against.
expect(browserManager.getGuestWebContentsId('tab-dies')).toBeNull()
})
// Why this one exists: an unresolved guest is not the same as a dead one. A request addressed by
// the wrong renderer names a page that is alive and on screen, and answering it with teardown
// would cancel that page's in-flight downloads and grabs over a misaddressed message.
it('refuses a request from the wrong renderer without tearing down the healthy page it named', async () => {
const { guest } = makeGuest(5105)
registerPage('tab-mismatch', guest)
const unregisterGuest = vi.spyOn(browserManager, 'unregisterGuest')
await expect(
browserManager.setAnnotationViewportBridge('tab-mismatch', BRIDGE_OPTIONS, () =>
browserManager.getAuthorizedGuest('tab-mismatch', rendererWebContentsId + 1)
)
).resolves.toBe(false)
expect(unregisterGuest).not.toHaveBeenCalled()
expect(browserManager.getGuestWebContentsId('tab-mismatch')).toBe(5105)
expect(guest.executeJavaScriptInIsolatedWorld).not.toHaveBeenCalled()
})
it('refuses a destroyed guest without injecting into it', async () => {
const { guest } = makeGuest(5104)
registerPage('tab-destroyed', guest)
;(guest.isDestroyed as ReturnType<typeof vi.fn>).mockReturnValue(true)
await expect(
browserManager.setAnnotationViewportBridge(
'tab-destroyed',
BRIDGE_OPTIONS,
() => guest as never
)
).resolves.toBe(false)
expect(guest.executeJavaScriptInIsolatedWorld).not.toHaveBeenCalled()
})
// Why: a document page owns no browsing state, so treating its refusal as a stale page would run
// teardown against an id the browsing registry never held.
it('does not run page teardown when a document page resolves to nothing', async () => {
const unregisterGuest = vi.spyOn(browserManager, 'unregisterGuest')
await expect(
browserManager.setAnnotationViewportBridge('doc-page-1', BRIDGE_OPTIONS, () => null)
).resolves.toBe(false)
expect(unregisterGuest).not.toHaveBeenCalled()
})
})
@@ -45,6 +45,32 @@ import {
resetBrowserManagerMocks,
resetBrowserManagerState
} from './browser-manager-test-harness'
import { installDocPreviewGuestPolicy } from './doc-preview-guest-policy'
import { mintDocPreviewGrant, revokeAllDocPreviewGrants } from './doc-preview-grant-registry'
import { buildDocPreviewUrl } from '../../shared/doc-preview-scheme'
/**
* A page the document half of the registry really holds. Built rather than named: membership is
* what both doors refuse on now, so an id that merely looks like a preview's would be admitted.
*/
function registerWorkspaceDocPage(browserPageId: string): void {
const grant = mintDocPreviewGrant({
owner: { kind: 'ssh', connectionId: 'ssh-1' },
root: '/home/alice/docs',
entryRelativePath: 'index.html',
browserPageId
})
const guest = {
isFocused: () => false,
isDestroyed: () => false,
getURL: () => buildDocPreviewUrl(grant.id, grant.entryRelativePath),
on: vi.fn(),
once: vi.fn(),
setWindowOpenHandler: vi.fn(),
setWebRTCIPHandlingPolicy: vi.fn()
}
installDocPreviewGuestPolicy(guest as never, { id: rendererWebContentsId, send: vi.fn() })
}
const {
guestOffMock,
@@ -60,6 +86,7 @@ describe('browserManager', () => {
beforeEach(() => {
resetBrowserManagerMocks(browserMocks)
resetBrowserManagerState()
revokeAllDocPreviewGrants()
})
afterEach(() => {
@@ -92,6 +119,77 @@ describe('browserManager', () => {
expect(browserManager.getSessionProfileIdForTab('browser-1')).toBe('work')
})
// Why both doors: one id in both halves of the registry would make the tool door answer with a
// document guest for a page the reader is browsing in.
it.each(['registerGuest', 'registerOffscreenGuest'] as const)(
'refuses %s for a page the document registry already holds',
(entryPoint) => {
const guest = {
id: 129,
isDestroyed: vi.fn(() => false),
getType: vi.fn(() => 'webview'),
setBackgroundThrottling: guestSetBackgroundThrottlingMock,
setWindowOpenHandler: guestSetWindowOpenHandlerMock,
on: guestOnMock,
off: guestOffMock,
openDevTools: guestOpenDevToolsMock
}
webContentsFromIdMock.mockReturnValue(guest)
browserManager.attachGuestPolicies(guest as never)
const browserPageId = 'doc-page-1'
registerWorkspaceDocPage(browserPageId)
if (entryPoint === 'registerGuest') {
expect(
browserManager.registerGuest({
browserPageId,
webContentsId: guest.id,
rendererWebContentsId
})
).toBe(false)
} else {
expect(
browserManager.registerOffscreenGuest({ browserPageId, webContentsId: guest.id })
).toBe(false)
}
expect(browserManager.getGuestWebContentsId(browserPageId)).toBeNull()
}
)
// Why this answer is load-bearing: the headless backend destroys its window on false, so a true
// for a guest that is already gone would leave a page id registered onto nothing.
it.each(['missing', 'destroyed'] as const)(
'refuses registerOffscreenGuest when the named guest is %s',
(guestState) => {
webContentsFromIdMock.mockReturnValue(
guestState === 'missing' ? null : { id: 137, isDestroyed: vi.fn(() => true) }
)
expect(
browserManager.registerOffscreenGuest({ browserPageId: 'offscreen-1', webContentsId: 137 })
).toBe(false)
expect(browserManager.getGuestWebContentsId('offscreen-1')).toBeNull()
}
)
// Why the exit door needs the same check: a document page withdraws by revoking its grant, so its
// id here is misaddressed — and unregistering opens by evicting whatever grab that id names.
it('refuses unregisterGuest for a page the document registry holds', () => {
registerWorkspaceDocPage('doc-page-2')
const cancelGrabOp = vi.spyOn(browserManager, 'cancelGrabOp')
browserManager.unregisterGuest('doc-page-2')
expect(cancelGrabOp).not.toHaveBeenCalled()
// The presence half: the same door does evict a browsing page's grab.
browserManager.unregisterGuest('browser-page-1')
expect(cancelGrabOp).toHaveBeenCalledWith('browser-page-1', 'evicted')
cancelGrabOp.mockRestore()
})
it('blocks non-web guest navigations after attach', () => {
const guest = {
isDestroyed: vi.fn(() => false),
@@ -0,0 +1,250 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const browserMocks = vi.hoisted(() => ({
appGetPathMock: vi.fn(() => '/downloads'),
shellOpenExternalMock: vi.fn(),
browserWindowFromWebContentsMock: vi.fn(),
menuBuildFromTemplateMock: vi.fn(),
guestOffMock: vi.fn(),
guestOnMock: vi.fn(),
guestSetBackgroundThrottlingMock: vi.fn(),
guestSetWindowOpenHandlerMock: vi.fn(),
guestOpenDevToolsMock: vi.fn(),
webContentsFromIdMock: vi.fn(),
screenGetCursorScreenPointMock: vi.fn(() => ({ x: 0, y: 0 })),
openPopupWithOriginBarMock: vi.fn()
}))
vi.mock('electron', () => ({
app: { getPath: browserMocks.appGetPathMock },
BrowserWindow: { fromWebContents: browserMocks.browserWindowFromWebContentsMock },
clipboard: { writeText: vi.fn() },
shell: { openExternal: browserMocks.shellOpenExternalMock },
Menu: { buildFromTemplate: browserMocks.menuBuildFromTemplateMock },
screen: { getCursorScreenPoint: browserMocks.screenGetCursorScreenPointMock },
webContents: { fromId: browserMocks.webContentsFromIdMock }
}))
vi.mock('./popup-origin-bar-window', () => ({
openPopupWithOriginBar: browserMocks.openPopupWithOriginBarMock
}))
import { browserManager } from './browser-manager'
import { resetBrowserManagerMocks, resetBrowserManagerState } from './browser-manager-test-harness'
import { getWorkspaceDocPageGuest } from './doc-preview-guest-policy'
import { mintDocPreviewGrant, revokeAllDocPreviewGrants } from './doc-preview-grant-registry'
import { buildDocPreviewUrl } from '../../shared/doc-preview-scheme'
type GuestFake = {
id: number
url: string
listeners: Map<string, ((...args: never[]) => void)[]>
windowOpenHandler: ((details: { url: string; frameName: string }) => unknown) | null
webRtcPolicy: string | null
isDestroyed: () => boolean
isFocused: () => boolean
getURL: () => string
getType: () => string
setBackgroundThrottling: (value: boolean) => void
setWebRTCIPHandlingPolicy: (policy: string) => void
setWindowOpenHandler: (handler: (details: { url: string; frameName: string }) => unknown) => void
executeJavaScriptInIsolatedWorld: ReturnType<typeof vi.fn>
debugger: {
isAttached: () => boolean
attach: ReturnType<typeof vi.fn>
sendCommand: ReturnType<typeof vi.fn>
}
on: (event: string, listener: (...args: never[]) => void) => void
once: (event: string, listener: (...args: never[]) => void) => void
off: (event: string, listener: (...args: never[]) => void) => void
}
function createGuest(id: number, url: string): GuestFake {
const listeners = new Map<string, ((...args: never[]) => void)[]>()
const guest: GuestFake = {
id,
url,
listeners,
windowOpenHandler: null,
webRtcPolicy: null,
isDestroyed: () => false,
isFocused: () => true,
getURL: () => guest.url,
getType: () => 'webview',
setBackgroundThrottling: vi.fn(),
setWebRTCIPHandlingPolicy: (policy: string) => {
guest.webRtcPolicy = policy
},
setWindowOpenHandler: (handler) => {
guest.windowOpenHandler = handler
},
executeJavaScriptInIsolatedWorld: vi.fn(async () => undefined),
debugger: {
isAttached: () => true,
attach: vi.fn(),
sendCommand: vi.fn(async () => undefined)
},
on: (event, listener) => {
listeners.set(event, [...(listeners.get(event) ?? []), listener])
},
once: (event, listener) => {
listeners.set(event, [...(listeners.get(event) ?? []), listener])
},
off: (event, listener) => {
listeners.set(
event,
(listeners.get(event) ?? []).filter((entry) => entry !== listener)
)
}
}
return guest
}
function listenerCount(guest: GuestFake, event: string): number {
return guest.listeners.get(event)?.length ?? 0
}
/** Drives the guest's own will-navigate listeners and reports whether they refused. */
function navigateTo(guest: GuestFake, url: string): boolean {
let prevented = false
const event = { preventDefault: () => (prevented = true) } as never
for (const listener of guest.listeners.get('will-navigate') ?? []) {
;(listener as (event: unknown, url: string) => void)(event, url)
}
return prevented
}
const host = { id: 5001, send: vi.fn() } as unknown as Electron.WebContents
beforeEach(() => {
resetBrowserManagerMocks(browserMocks)
resetBrowserManagerState()
revokeAllDocPreviewGrants()
browserMocks.guestSetBackgroundThrottlingMock.mockReturnValue(undefined)
})
describe('guest policy profiles', () => {
function attachPreviewGuest(id = 301): {
guest: GuestFake
grantId: string
browserPageId: string
} {
const browserPageId = `doc-page-${id}`
const grant = mintDocPreviewGrant({
owner: { kind: 'ssh', connectionId: 'ssh-1' },
root: '/home/alice/docs',
entryRelativePath: 'index.html',
browserPageId
})
const guest = createGuest(id, buildDocPreviewUrl(grant.id, 'index.html'))
browserManager.attachGuestPolicies(guest as never, null, { profile: 'workspace-doc', host })
return { guest, grantId: grant.id, browserPageId }
}
// The presence half of every absence below: a browsing guest observably takes all of it through
// the same method, so a profile that fenced nothing — or an attach path that stopped installing
// anything at all — cannot pass these by being uniformly empty.
it('gives a browsing guest link routing, popups and anti-detection', () => {
const guest = createGuest(300, 'https://example.com/')
browserManager.attachGuestPolicies(guest as never)
expect(listenerCount(guest, 'dom-ready')).toBe(1)
expect(listenerCount(guest, 'frame-created')).toBe(1)
expect(listenerCount(guest, 'did-create-window')).toBe(1)
expect(guest.debugger.sendCommand).toHaveBeenCalled()
expect(navigateTo(guest, 'https://elsewhere.example/')).toBe(false)
})
it('gives a workspace-document guest none of it', () => {
const { guest } = attachPreviewGuest()
expect(listenerCount(guest, 'dom-ready')).toBe(0)
expect(listenerCount(guest, 'frame-created')).toBe(0)
expect(listenerCount(guest, 'did-create-window')).toBe(0)
expect(guest.debugger.sendCommand).not.toHaveBeenCalled()
expect(guest.executeJavaScriptInIsolatedWorld).not.toHaveBeenCalled()
})
it('holds a workspace-document guest inside the grant it is showing', () => {
const { guest, grantId } = attachPreviewGuest()
expect(navigateTo(guest, buildDocPreviewUrl(grantId, 'other.html'))).toBe(false)
expect(navigateTo(guest, 'https://example.com/')).toBe(true)
expect(guest.webRtcPolicy).toBe('disable_non_proxied_udp')
})
it('denies every window a workspace-document guest asks for', () => {
const { guest } = attachPreviewGuest()
expect(guest.windowOpenHandler?.({ url: 'https://example.com/', frameName: '' })).toEqual({
action: 'deny'
})
})
// Why teardown and not just install: registration and teardown both key on the guest having been
// policy-attached, so a profile that installs outside that bookkeeping leaves the id marked
// attached forever and stays answerable to tools after the surface is gone.
it('tears a workspace-document guest down through the same policy cleanup', () => {
const { guest, browserPageId } = attachPreviewGuest(302)
expect(getWorkspaceDocPageGuest(browserPageId, host.id)).toBe(guest as never)
for (const listener of guest.listeners.get('destroyed') ?? []) {
listener()
}
expect(getWorkspaceDocPageGuest(browserPageId, host.id)).toBeNull()
// The manager's own bookkeeping was reached too: a second attach is refused while the id is
// still marked policy-attached, and accepted once its teardown has run.
browserManager.attachGuestPolicies(guest as never)
expect(listenerCount(guest, 'dom-ready')).toBe(1)
})
// The seam the whole split rests on: one public door answers for both halves, and each page id
// resolves in exactly one of them. Asserted against the real manager, because the IPC census
// test has to mock it.
describe('the one door across both halves of the registry', () => {
const BROWSING_PAGE_ID = 'browser-page-1'
function registerBrowsingGuest(id = 400): GuestFake {
const guest = createGuest(id, 'https://example.com/')
browserMocks.webContentsFromIdMock.mockReturnValue(guest)
browserManager.attachGuestPolicies(guest as never)
browserManager.registerGuest({
browserPageId: BROWSING_PAGE_ID,
workspaceId: 'workspace-1',
worktreeId: 'wt-1',
webContentsId: id,
rendererWebContentsId: host.id
})
return guest
}
it('answers each page with the guest of its own half', () => {
const browsing = registerBrowsingGuest()
const { guest: document, browserPageId } = attachPreviewGuest()
expect(browserManager.getAuthorizedGuest(BROWSING_PAGE_ID, host.id)).toBe(browsing as never)
expect(browserManager.getAuthorizedGuest(browserPageId, host.id)).toBe(document as never)
})
// Why this is the containment claim and not a lookup detail: page management, agent commands,
// download routing and certificate attribution all read the browsing map directly, so a
// document page being absent from it is what fences them without a guard of their own.
it('keeps a document page out of the browsing map entirely', () => {
registerBrowsingGuest()
const { browserPageId } = attachPreviewGuest()
expect(browserManager.getGuestWebContentsId(browserPageId)).toBeNull()
expect([...browserManager.getWebContentsIdByTabId().keys()]).toEqual([BROWSING_PAGE_ID])
})
})
it('attaches a guest once whatever profile it was asked for', () => {
const { guest } = attachPreviewGuest(303)
browserManager.attachGuestPolicies(guest as never)
expect(listenerCount(guest, 'dom-ready')).toBe(0)
expect(listenerCount(guest, 'will-navigate')).toBe(1)
})
})
@@ -882,34 +882,5 @@ describe('browserManager', () => {
expect(debuggerAttach).toHaveBeenCalledWith('1.3')
expect(debuggerSendCommand).not.toHaveBeenCalled()
})
it('installs annotation viewport bridge in an isolated world', async () => {
const { guest } = makeGuest(4646)
webContentsFromIdMock.mockReturnValue(guest)
browserManager.attachGuestPolicies(guest as never)
browserManager.registerGuest({
browserPageId: 'tab-annotations',
webContentsId: guest.id as number,
rendererWebContentsId
})
const ok = await browserManager.setAnnotationViewportBridge('tab-annotations', {
emitViewport: false,
enabled: true,
markers: [],
token: 'annotationviewporttoken'
})
expect(ok).toBe(true)
expect(guest.executeJavaScriptInIsolatedWorld).toHaveBeenCalledWith(
expect.any(Number),
[
expect.objectContaining({
code: expect.stringContaining('__orcaBrowserAnnotationViewportBridge')
})
],
false
)
})
})
})
+91 -15
View File
@@ -63,6 +63,11 @@ import {
BROWSER_ANNOTATION_VIEWPORT_BRIDGE_WORLD_ID,
buildBrowserAnnotationViewportBridgeScript
} from '../../shared/browser-annotation-viewport-bridge'
import {
getWorkspaceDocPageGuest,
installDocPreviewGuestPolicy,
isWorkspaceDocPageId
} from './doc-preview-guest-policy'
import type { KeybindingOverrides } from '../../shared/keybindings'
import {
BrowserCertificateTrustController,
@@ -157,6 +162,15 @@ type PopupOwnerContext = {
browserTabId: string
rootGuestWebContentsId: number
}
/**
* What a guest is allowed to be. A browsing guest is the web — popups, clicked-link routing and
* anti-detection all apply. A workspace-document guest renders one granted document and gets none
* of that; `host` is the renderer that minted its grant, and the only sink for what it reports.
*/
export type BrowserGuestPolicy =
| { profile: 'browsing' }
| { profile: 'workspace-doc'; host: Electron.WebContents }
const BROWSING_GUEST_POLICY: BrowserGuestPolicy = { profile: 'browsing' }
type PendingMainFrameNavigation = {
currentUrl: string
supersededUrls: string[]
@@ -671,12 +685,20 @@ export class BrowserManager {
attachGuestPolicies(
guest: Electron.WebContents,
inheritedOwnerContext: PopupOwnerContext | null = null
inheritedOwnerContext: PopupOwnerContext | null = null,
policy: BrowserGuestPolicy = BROWSING_GUEST_POLICY
): void {
if (this.policyAttachedGuestIds.has(guest.id)) {
return
}
this.policyAttachedGuestIds.add(guest.id)
// Why one door with a profile rather than a second installer beside it: whether a guest was
// policy-attached at all is what registration and teardown both key on, so a guest that took
// another path into the app is invisible to both.
if (policy.profile === 'workspace-doc') {
this.attachWorkspaceDocGuestPolicies(guest, policy.host)
return
}
if (inheritedOwnerContext) {
this.popupOwnerContextByGuestId.set(guest.id, inheritedOwnerContext)
}
@@ -1016,6 +1038,30 @@ export class BrowserManager {
})
}
/**
* A workspace document is not the web: no popups, no link routing, no anti-detection, and no
* navigation bookkeeping for chrome it does not have. What it does share with a browsing guest is
* this method's teardown, so a retired preview drops its listeners on the same path.
*/
private attachWorkspaceDocGuestPolicies(
guest: Electron.WebContents,
host: Electron.WebContents
): void {
const disposeDocPolicy = installDocPreviewGuestPolicy(guest, host)
const handleDestroyed = (): void => {
this.cleanupGuestPolicyAttachment(guest.id)
}
guest.on('destroyed', handleDestroyed)
this.policyCleanupByGuestId.set(guest.id, () => {
disposeDocPolicy()
try {
guest.off('destroyed', handleDestroyed)
} catch {
// guest may already be destroyed
}
})
}
// Why: navigator.userAgent (read by Google's auth JS) reflects the WebContents UA,
// not the request header, so the header-level Firefox switch in setupClientHintsOverride
// must be matched here per navigation or the two layers disagree — itself a bot tell.
@@ -1318,7 +1364,9 @@ export class BrowserManager {
rendererWebContentsId
}: BrowserGuestRegistration): boolean {
const browserTabId = browserPageId ?? legacyBrowserTabId
if (!browserTabId) {
// Why refuse rather than overwrite: the two halves of the registry must stay disjoint, or one
// id resolves in both and the tool door silently prefers the document guest over the page.
if (!browserTabId || isWorkspaceDocPageId(browserTabId)) {
return false
}
// Why: on guest-surface swap, cancel any grab bound to the old guest's listeners so it doesn't strand on a stale webContents.
@@ -1377,6 +1425,12 @@ export class BrowserManager {
}
unregisterGuest(browserTabId: string): void {
// Why the check on the exit door too: a document page withdraws by revoking its grant, never
// through here, so its id arriving is misaddressed — and the cancel below would evict that
// preview's live grab on the strength of it.
if (isWorkspaceDocPageId(browserTabId)) {
return
}
// Why: teardown mid-grab must cancel it so the renderer gets a signal, not a dangling Promise.
this.cancelGrabOp(browserTabId, 'evicted')
@@ -1444,10 +1498,15 @@ export class BrowserManager {
sessionProfileId?: string | null
userAgentMode?: BrowserSessionUserAgentMode
webContentsId: number
}): void {
}): boolean {
// Why the same check on both registration doors: one id resolving in both halves is the exact
// confusion the split registries exist to prevent.
if (isWorkspaceDocPageId(browserPageId)) {
return false
}
const guest = webContents.fromId(webContentsId)
if (!guest || guest.isDestroyed()) {
return
return false
}
// Why: offscreen pages have no renderer webview listeners, so main owns their load-failure lifecycle.
this.offscreenGuestIds.add(webContentsId)
@@ -1468,6 +1527,7 @@ export class BrowserManager {
this.worktreeIdByTabId.set(browserPageId, worktreeId)
}
this.certificateTrustController?.onGuestRegistered(webContentsId, browserPageId)
return true
}
unregisterAll(): void {
@@ -1843,12 +1903,13 @@ export class BrowserManager {
async setAnnotationViewportBridge(
browserTabId: string,
options: BrowserAnnotationViewportBridgeOptions
options: BrowserAnnotationViewportBridgeOptions,
resolveGuest: () => Electron.WebContents | null
): Promise<boolean> {
const prev = this.annotationViewportBridgeOpsByTabId.get(browserTabId) ?? Promise.resolve()
const next = prev
.catch(() => {})
.then(() => this.doSetAnnotationViewportBridgeImpl(browserTabId, options))
.then(() => this.doSetAnnotationViewportBridgeImpl(options, resolveGuest))
this.annotationViewportBridgeOpsByTabId.set(browserTabId, next)
try {
return await next
@@ -1859,18 +1920,22 @@ export class BrowserManager {
}
}
// Why the caller resolves the guest: the same bridge serves browsing pages and workspace
// documents, which live in different halves of the page registry.
// Why a resolver and not the guest itself: this op may have waited behind another one, and a
// cross-process navigation meanwhile swaps the tab's contents without destroying the old one —
// injecting into the guest the request named would bridge a page nobody is looking at.
// Why no tab id: with teardown gone this reaches only the guest the resolver hands back, and
// taking an id it cannot act on would invite the next reader to act on it.
private async doSetAnnotationViewportBridgeImpl(
browserTabId: string,
options: BrowserAnnotationViewportBridgeOptions
options: BrowserAnnotationViewportBridgeOptions,
resolveGuest: () => Electron.WebContents | null
): Promise<boolean> {
const webContentsId = this.webContentsIdByTabId.get(browserTabId)
if (!webContentsId) {
return false
}
const guest = webContents.fromId(webContentsId)
// Why no teardown here: the resolver already unregisters a page whose guest died, and the only
// case it uniquely leaves is an ownership mismatch on a healthy page — where tearing down would
// cancel that page's in-flight downloads and grabs over a request that was merely misaddressed.
const guest = resolveGuest()
if (!guest || guest.isDestroyed()) {
// Why: a stale guest must clear every per-tab registry entry, not just the WebContents maps.
this.unregisterGuest(browserTabId)
return false
}
@@ -1977,10 +2042,21 @@ export class BrowserManager {
// --- Browser Context Grab — main-owned operations ---
/** Validate that the sender owns browserTabId; returns the guest WebContents or null. */
/**
* The guest a request from `senderWebContentsId` may act on, across both halves of the page
* registry. This is the only door taught about workspace-document guests: they are kept out of
* the browsing maps entirely, so page management, agent commands, download routing and
* certificate attribution all miss them without a guard of their own — and a reader who opens a
* tool on the document in front of them still gets an answer.
*/
getAuthorizedGuest(
browserTabId: string,
senderWebContentsId: number
): Electron.WebContents | null {
const docGuest = getWorkspaceDocPageGuest(browserTabId, senderWebContentsId)
if (docGuest) {
return docGuest
}
const registeredRenderer = this.rendererWebContentsIdByTabId.get(browserTabId)
if (registeredRenderer == null || registeredRenderer !== senderWebContentsId) {
return null
@@ -0,0 +1,185 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { BrowserSessionProfile } from '../../shared/browser-workspace-types'
const mocks = vi.hoisted(() => ({
handleGuestWillDownload: vi.fn(),
noticeDocPreviewDownloadBlocked: vi.fn()
}))
type WillDownloadListener = (
event: { preventDefault: () => void },
item: { id: string },
webContents: { id: number }
) => void
type FakeSession = {
listeners: WillDownloadListener[]
on: ReturnType<typeof vi.fn>
removeListener: ReturnType<typeof vi.fn>
getUserAgent: () => string
setUserAgent: ReturnType<typeof vi.fn>
setPermissionRequestHandler: ReturnType<typeof vi.fn>
setPermissionCheckHandler: ReturnType<typeof vi.fn>
setDisplayMediaRequestHandler: ReturnType<typeof vi.fn>
}
const sessionsByPartition = new Map<string, FakeSession>()
function fakeSession(): FakeSession {
const listeners: WillDownloadListener[] = []
return {
listeners,
on: vi.fn((event: string, listener: WillDownloadListener) => {
if (event === 'will-download') {
listeners.push(listener)
}
}),
removeListener: vi.fn((event: string, listener: WillDownloadListener) => {
if (event !== 'will-download') {
return
}
const index = listeners.indexOf(listener)
if (index !== -1) {
listeners.splice(index, 1)
}
}),
getUserAgent: () => 'Mozilla/5.0 Orca',
setUserAgent: vi.fn(),
setPermissionRequestHandler: vi.fn(),
setPermissionCheckHandler: vi.fn(),
setDisplayMediaRequestHandler: vi.fn()
}
}
vi.mock('electron', () => ({
session: {
fromPartition: (partition: string) => {
const existing = sessionsByPartition.get(partition)
if (existing) {
return existing
}
const created = fakeSession()
sessionsByPartition.set(partition, created)
return created
}
}
}))
vi.mock('./browser-manager', () => ({
browserManager: {
handleGuestWillDownload: mocks.handleGuestWillDownload,
installCertificateRequestGuard: vi.fn(),
removeCertificateRequestGuard: vi.fn(),
notifyPermissionDenied: vi.fn()
}
}))
vi.mock('./doc-preview-download-block-notice', () => ({
noticeDocPreviewDownloadBlocked: mocks.noticeDocPreviewDownloadBlocked
}))
vi.mock('./browser-media-access', () => ({
hasSystemMediaAccess: () => false,
requestSystemMediaAccess: async () => false
}))
vi.mock('./browser-session-ua', () => ({
cleanElectronUserAgent: (userAgent: string) => userAgent,
setupClientHintsOverride: vi.fn()
}))
vi.mock('./browser-session-user-agent-mode', () => ({
setBrowserSessionUserAgentMode: vi.fn()
}))
vi.mock('./browser-webauthn-access', () => ({
allowsBrowserWebAuthnPermission: () => false,
clearBrowserWebAuthnAccessHandlers: vi.fn(),
installBrowserWebAuthnAccessHandlers: vi.fn()
}))
type PartitionPolicyInstaller = (
profile: BrowserSessionProfile,
options?: { downloads?: 'route' | 'deny' }
) => void
// Why imported per test rather than at the top: the installer remembers which partitions it has
// already configured in module state, so a shared import would make the second test's install a
// no-op and leave it reading the first test's listener.
async function loadInstaller(): Promise<PartitionPolicyInstaller> {
const module = await import('./browser-session-partition-policies')
return module.installBrowserSessionPartitionPolicies
}
function profileFor(partition: string): BrowserSessionProfile {
return {
id: partition,
scope: 'isolated',
partition,
label: partition,
source: null,
userAgentMode: 'clean'
}
}
/** Fires the partition's real `will-download` listener and reports what it decided. */
function fireWillDownload(partition: string): { cancelled: boolean } {
const sess = sessionsByPartition.get(partition)
if (!sess || sess.listeners.length !== 1) {
throw new Error(`expected exactly one will-download listener on ${partition}`)
}
let cancelled = false
sess.listeners[0]({ preventDefault: () => (cancelled = true) }, { id: 'item-1' }, { id: 42 })
return { cancelled }
}
beforeEach(() => {
vi.clearAllMocks()
sessionsByPartition.clear()
vi.resetModules()
})
describe('partition download policy', () => {
// The presence half: without it, a deny assertion passes for a partition that installed no
// listener at all, and would keep passing if the whole download path were removed.
it('routes a download on a partition that did not ask for the deny', async () => {
const install = await loadInstaller()
install(profileFor('persist:browsing-1'))
expect(fireWillDownload('persist:browsing-1').cancelled).toBe(false)
expect(mocks.handleGuestWillDownload).toHaveBeenCalledWith(
expect.objectContaining({ guestWebContentsId: 42 })
)
})
it('cancels a download on a partition that asked for the deny, routing nothing', async () => {
const install = await loadInstaller()
install(profileFor('orca-doc-preview'), { downloads: 'deny' })
expect(fireWillDownload('orca-doc-preview').cancelled).toBe(true)
expect(mocks.handleGuestWillDownload).not.toHaveBeenCalled()
})
// Why in the same run as the routing test above: a refusal the reader cannot see is a pressed
// button that does nothing, and a notice on the routing partition would announce a download that
// is about to arrive normally.
it('tells the reader about the refusal, and only on the partition that refused', async () => {
const install = await loadInstaller()
install(profileFor('orca-doc-preview'), { downloads: 'deny' })
install(profileFor('persist:browsing-1'))
fireWillDownload('persist:browsing-1')
expect(mocks.noticeDocPreviewDownloadBlocked).not.toHaveBeenCalled()
fireWillDownload('orca-doc-preview')
expect(mocks.noticeDocPreviewDownloadBlocked).toHaveBeenCalledWith(
expect.objectContaining({ id: 42 })
)
})
// Why both partitions in one run: the listener is module state shared across sessions, so a deny
// installed for one partition must not follow the next partition that installs after it.
it('keeps each partition on its own decision', async () => {
const install = await loadInstaller()
install(profileFor('orca-doc-preview'), { downloads: 'deny' })
install(profileFor('persist:browsing-1'))
expect(fireWillDownload('orca-doc-preview').cancelled).toBe(true)
expect(fireWillDownload('persist:browsing-1').cancelled).toBe(false)
expect(mocks.handleGuestWillDownload).toHaveBeenCalledTimes(1)
})
})
@@ -11,6 +11,7 @@ import {
clearBrowserWebAuthnAccessHandlers,
installBrowserWebAuthnAccessHandlers
} from './browser-webauthn-access'
import { noticeDocPreviewDownloadBlocked } from './doc-preview-download-block-notice'
// Why: one shared installer keeps every partition's deny-by-default permission/download policies from drifting apart.
const configuredPartitions = new Set<string>()
@@ -22,6 +23,23 @@ const handleWillDownload = (
browserManager.handleGuestWillDownload({ guestWebContentsId: webContents.id, item })
}
/**
* Why a second listener instead of a branch inside the shared one: `will-download` is a session
* event that names no partition, so the only place the decision can be keyed by partition is which
* listener that partition's session got. A workspace-document guest has no page of its own to
* attribute a download to, so routing one lands it in this desktop's Downloads folder under a
* remote-authored name that nothing in the UI accounts for.
*/
const handleDeniedWillDownload = (
event: Electron.Event,
_item: Electron.DownloadItem,
webContents: Electron.WebContents
): void => {
event.preventDefault()
// The page gets nothing back; the reader gets a sentence, or a pressed button just does nothing.
noticeDocPreviewDownloadBlocked(webContents)
}
function resolvePermissionNoticeUrl(
webContents: Electron.WebContents,
details: Electron.PermissionRequest | undefined
@@ -37,7 +55,13 @@ function resolvePermissionNoticeUrl(
}
}
export function installBrowserSessionPartitionPolicies(profile: BrowserSessionProfile): void {
/** `route` hands the item to the owning page's download flow; `deny` cancels it before it starts. */
export type BrowserPartitionDownloadPolicy = 'route' | 'deny'
export function installBrowserSessionPartitionPolicies(
profile: BrowserSessionProfile,
options?: { downloads?: BrowserPartitionDownloadPolicy }
): void {
const { partition } = profile
const sess = session.fromPartition(partition)
setBrowserSessionUserAgentMode(sess, profile.userAgentMode ?? 'clean')
@@ -106,7 +130,11 @@ export function installBrowserSessionPartitionPolicies(profile: BrowserSessionPr
callback({ video: undefined, audio: undefined })
})
sess.removeListener('will-download', handleWillDownload)
sess.on('will-download', handleWillDownload)
sess.removeListener('will-download', handleDeniedWillDownload)
sess.on(
'will-download',
options?.downloads === 'deny' ? handleDeniedWillDownload : handleWillDownload
)
configuredPartitions.add(partition)
}
@@ -115,6 +143,7 @@ export function clearBrowserSessionPartitionPolicies(partition: string, sess: Se
configuredPartitions.delete(partition)
browserManager.removeCertificateRequestGuard(sess)
sess.removeListener('will-download', handleWillDownload)
sess.removeListener('will-download', handleDeniedWillDownload)
clearBrowserWebAuthnAccessHandlers(sess)
sess.setPermissionRequestHandler(null)
sess.setPermissionCheckHandler(null)
@@ -0,0 +1,107 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
publishDocPreviewFailure: vi.fn(),
boundGrantIdByGuest: new Map<object, string>()
}))
vi.mock('./doc-preview-failure-notice', () => ({
publishDocPreviewFailure: mocks.publishDocPreviewFailure
}))
vi.mock('./doc-preview-guest-policy', () => ({
readDocPreviewGuestBoundGrantId: (guest: object) => mocks.boundGrantIdByGuest.get(guest) ?? null
}))
const GRANT_ID = 'a'.repeat(32)
const OTHER_GRANT_ID = 'b'.repeat(32)
/** Only the identity matters: the module asks the guest registry what grant this contents holds. */
function guestBoundTo(grantId: string | null): Electron.WebContents {
const guest = {} as Electron.WebContents
if (grantId !== null) {
mocks.boundGrantIdByGuest.set(guest, grantId)
}
return guest
}
async function loadNotifier(): Promise<(guest: Electron.WebContents) => void> {
const module = await import('./doc-preview-download-block-notice')
return module.noticeDocPreviewDownloadBlocked
}
beforeEach(() => {
vi.clearAllMocks()
mocks.boundGrantIdByGuest.clear()
// Why per test: the module remembers which grants it has already told the reader about.
vi.resetModules()
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-08-27T00:00:00.000Z'))
})
afterEach(() => {
vi.useRealTimers()
})
describe('noticeDocPreviewDownloadBlocked', () => {
it('tells the shell which preview had a download refused', async () => {
const notice = await loadNotifier()
notice(guestBoundTo(GRANT_ID))
expect(mocks.publishDocPreviewFailure).toHaveBeenCalledWith({
grantId: GRANT_ID,
reason: 'download-blocked'
})
})
// Why: a document can ask in a loop. The reader learns nothing from the second notice, and every
// attempt would otherwise cross the IPC boundary and re-render the strip.
it('says it once however often the document asks', async () => {
const notice = await loadNotifier()
const guest = guestBoundTo(GRANT_ID)
notice(guest)
notice(guest)
vi.advanceTimersByTime(1_500)
notice(guest)
expect(mocks.publishDocPreviewFailure).toHaveBeenCalledTimes(1)
})
it('says it again for an attempt long after the last one', async () => {
const notice = await loadNotifier()
const guest = guestBoundTo(GRANT_ID)
notice(guest)
vi.advanceTimersByTime(2_500)
notice(guest)
expect(mocks.publishDocPreviewFailure).toHaveBeenCalledTimes(2)
})
// Why not one throttle for the whole app: two previews are two readers, and silencing the second
// because the first just refused something leaves that press unexplained.
it('throttles each preview on its own', async () => {
const notice = await loadNotifier()
notice(guestBoundTo(GRANT_ID))
notice(guestBoundTo(OTHER_GRANT_ID))
expect(mocks.publishDocPreviewFailure).toHaveBeenCalledTimes(2)
expect(mocks.publishDocPreviewFailure).toHaveBeenLastCalledWith({
grantId: OTHER_GRANT_ID,
reason: 'download-blocked'
})
})
// The absence half of the first test: no shell is showing this contents, so there is no preview
// to put a notice on. Without the presence tests above, this would pass on a module that never
// published anything at all.
it('says nothing for a contents no preview is bound to', async () => {
const notice = await loadNotifier()
notice(guestBoundTo(null))
expect(mocks.publishDocPreviewFailure).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,34 @@
import { publishDocPreviewFailure } from './doc-preview-failure-notice'
import { readDocPreviewGuestBoundGrantId } from './doc-preview-guest-policy'
/**
* Why the reader is told at all, when the point of the fence is that nothing happens: a press that
* produces no file and no explanation reads as Orca being broken. The notice is chrome the document
* cannot see or read back, so the refusal stays as silent to the page as it was.
*
* Why a floor between notices: a document can ask in a loop, and every attempt would otherwise
* cross the IPC boundary and re-render the strip. The reader learns nothing from the thousandth.
*/
const NOTICE_MIN_INTERVAL_MS = 2_000
const noticedAtByGrantId = new Map<string, number>()
export function noticeDocPreviewDownloadBlocked(guest: Electron.WebContents): void {
const grantId = readDocPreviewGuestBoundGrantId(guest)
// Nothing to route a notice to: no shell is showing this contents as a preview.
if (grantId === null) {
return
}
const now = Date.now()
// Doubles as the expiry check, so what survives is exactly "noticed inside the window", and a
// grant that stops downloading stops being remembered.
for (const [noticedGrantId, noticedAt] of noticedAtByGrantId) {
if (now - noticedAt >= NOTICE_MIN_INTERVAL_MS) {
noticedAtByGrantId.delete(noticedGrantId)
}
}
if (noticedAtByGrantId.has(grantId)) {
return
}
noticedAtByGrantId.set(grantId, now)
publishDocPreviewFailure({ grantId, reason: 'download-blocked' })
}
@@ -0,0 +1,70 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { DOC_PREVIEW_LOAD_FAILURE_CHANNEL } from '../../shared/doc-preview-scheme'
import { publishDocPreviewFailure, setDocPreviewFailureSink } from './doc-preview-failure-notice'
afterEach(() => {
setDocPreviewFailureSink(null)
})
describe('publishDocPreviewFailure', () => {
it('sends the grant, path, and reason on the failure channel', () => {
const send = vi.fn()
setDocPreviewFailureSink({ send })
publishDocPreviewFailure({
grantId: 'a'.repeat(32),
relativePath: 'index.html',
reason: 'too-large'
})
expect(send).toHaveBeenCalledWith(DOC_PREVIEW_LOAD_FAILURE_CHANNEL, {
grantId: 'a'.repeat(32),
relativePath: 'index.html',
reason: 'too-large'
})
})
// Why: reads can outlive the window that asked for them; a missing sink must not throw inside
// the protocol handler.
it('is a no-op with no sink registered', () => {
expect(() =>
publishDocPreviewFailure({
grantId: 'b'.repeat(32),
relativePath: 'index.html',
reason: 'unreadable'
})
).not.toThrow()
})
it('sends nothing into a destroyed window', () => {
const send = vi.fn()
setDocPreviewFailureSink({ send, isDestroyed: () => true })
publishDocPreviewFailure({
grantId: 'c'.repeat(32),
relativePath: 'index.html',
reason: 'unreadable'
})
expect(send).not.toHaveBeenCalled()
})
// Why: a WebContents can be torn down between the liveness check and the send.
it('swallows a throwing sink and stops using it', () => {
const send = vi.fn(() => {
throw new Error('Object has been destroyed')
})
setDocPreviewFailureSink({ send })
const failure = {
grantId: 'd'.repeat(32),
relativePath: 'index.html',
reason: 'unreadable' as const
}
expect(() => publishDocPreviewFailure(failure)).not.toThrow()
publishDocPreviewFailure(failure)
expect(send).toHaveBeenCalledTimes(1)
})
})
@@ -0,0 +1,34 @@
import {
DOC_PREVIEW_LOAD_FAILURE_CHANNEL,
type DocPreviewFailure
} from '../../shared/doc-preview-scheme'
type DocPreviewFailureSink = {
send: (channel: string, payload: DocPreviewFailure) => void
isDestroyed?: () => boolean
}
let failureSink: DocPreviewFailureSink | null = null
export function setDocPreviewFailureSink(sink: DocPreviewFailureSink | null): void {
failureSink = sink
}
/**
* The preview shell cannot read the guest's HTTP status, and a 4xx body renders as
* if it were the document. Pushing the reason lets the shell replace that with a
* localized notice for the failure the user actually hit.
*/
export function publishDocPreviewFailure(failure: DocPreviewFailure): void {
const sink = failureSink
// Why: a read can outlive the window that asked for it, and sending into torn-down
// WebContents throws — an unreadable asset must not take the protocol handler with it.
if (!sink || sink.isDestroyed?.()) {
return
}
try {
sink.send(DOC_PREVIEW_LOAD_FAILURE_CHANNEL, failure)
} catch {
failureSink = null
}
}
@@ -0,0 +1,333 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
callRuntimeEnvironment: vi.fn(),
readFile: vi.fn(),
realpath: vi.fn(),
requireSshFilesystemProvider: vi.fn()
}))
vi.mock('../ipc/runtime-environment-transport-routing', () => ({
callRuntimeEnvironment: mocks.callRuntimeEnvironment
}))
vi.mock('../persistence', () => ({ getCanonicalUserDataPath: () => '/user-data' }))
vi.mock('../providers/ssh-filesystem-dispatch', () => ({
requireSshFilesystemProvider: mocks.requireSshFilesystemProvider
}))
import { FileReadCapExceededError } from '../ssh/ssh-filesystem-stream-reader'
import { docPreviewContentType, readDocPreviewFile } from './doc-preview-file-reader'
import { mintDocPreviewGrant, revokeAllDocPreviewGrants } from './doc-preview-grant-registry'
function sshGrant(): ReturnType<typeof mintDocPreviewGrant> {
return mintDocPreviewGrant({
owner: { kind: 'ssh', connectionId: 'ssh-1' },
root: '/home/alice/docs',
entryRelativePath: 'index.html',
browserPageId: 'page-1'
})
}
function runtimeGrant(root = '/srv/repo/docs'): ReturnType<typeof mintDocPreviewGrant> {
return mintDocPreviewGrant({
owner: {
kind: 'runtime',
environmentId: 'env-1',
worktreeSelector: 'id:wt-1',
worktreeRoot: '/srv/repo'
},
root,
entryRelativePath: 'index.html',
browserPageId: 'page-1'
})
}
beforeEach(() => {
vi.clearAllMocks()
revokeAllDocPreviewGrants()
// Why: an unsymlinked host canonicalizes to the path it was given.
mocks.realpath.mockImplementation((path: string) => Promise.resolve(path))
mocks.requireSshFilesystemProvider.mockReturnValue({
readFile: mocks.readFile,
realpath: mocks.realpath
})
})
describe('docPreviewContentType', () => {
it('maps document and asset extensions, defaulting to octet-stream', () => {
expect(docPreviewContentType('index.html')).toBe('text/html; charset=utf-8')
expect(docPreviewContentType('assets/app.CSS')).toBe('text/css; charset=utf-8')
expect(docPreviewContentType('assets/logo.png')).toBe('image/png')
expect(docPreviewContentType('data.bin')).toBe('application/octet-stream')
})
})
describe('readDocPreviewFile — ssh owner', () => {
it('reads text through the SSH filesystem provider', async () => {
mocks.readFile.mockResolvedValue({ content: '<h1>hi</h1>', isBinary: false })
const outcome = await readDocPreviewFile(sshGrant(), 'index.html')
expect(mocks.requireSshFilesystemProvider).toHaveBeenCalledWith('ssh-1')
expect(mocks.readFile).toHaveBeenCalledWith('/home/alice/docs/index.html')
expect(outcome).toEqual({
ok: true,
bytes: Buffer.from('<h1>hi</h1>', 'utf8'),
contentType: 'text/html; charset=utf-8'
})
})
it('decodes a base64 binary asset', async () => {
const png = Buffer.from([0x89, 0x50, 0x4e, 0x47])
mocks.readFile.mockResolvedValue({ content: png.toString('base64'), isBinary: true })
const outcome = await readDocPreviewFile(sshGrant(), 'assets/logo.png')
expect(outcome).toEqual({ ok: true, bytes: png, contentType: 'image/png' })
})
// Why: the SSH reader rejects an over-cap file rather than clamping it, so a completed read is
// always whole and needs no truncation flag.
it('serves a whole SSH read that carries no truncation flag', async () => {
mocks.readFile.mockResolvedValue({ content: '<h1>whole</h1>', isBinary: false })
expect(await readDocPreviewFile(sshGrant(), 'index.html')).toMatchObject({ ok: true })
})
// Why: the SSH read path only serves images and PDFs as bytes, so a font is refused there by
// design — the failure must name the file type, not a stale server.
it('reports a file type the host will not send as unsupported-asset', async () => {
mocks.readFile.mockResolvedValue({ content: '', isBinary: true })
const outcome = await readDocPreviewFile(sshGrant(), 'assets/font.woff2')
expect(outcome).toMatchObject({ ok: false, status: 415, reason: 'unsupported-asset' })
})
// Why: a host that still named the type read a 0-byte file, so 0 bytes is the honest answer —
// reporting it as a refused format would be a failure the workspace never reported.
it('serves an empty file the host still typed instead of calling it unsupported', async () => {
mocks.readFile.mockResolvedValue({ content: '', isBinary: true, mimeType: 'image/png' })
const outcome = await readDocPreviewFile(sshGrant(), 'assets/logo.png')
expect(outcome).toEqual({ ok: true, bytes: Buffer.alloc(0), contentType: 'image/png' })
})
// Why: containment above is lexical, and the SSH read RPC enforces no root of its own, so a
// symlink inside the grant would otherwise read anything the account can reach.
it('404s a path that canonicalizes outside the grant root', async () => {
mocks.realpath.mockImplementation((path: string) =>
Promise.resolve(path === '/home/alice/docs/escape.html' ? '/etc/shadow' : path)
)
const outcome = await readDocPreviewFile(sshGrant(), 'escape.html')
expect(outcome).toMatchObject({ ok: false, status: 404 })
expect(mocks.readFile).not.toHaveBeenCalled()
})
it('reads the canonical path once containment holds', async () => {
mocks.realpath.mockImplementation((path: string) =>
Promise.resolve(path === '/home/alice/docs/link.html' ? '/home/alice/docs/real.html' : path)
)
mocks.readFile.mockResolvedValue({ content: '<h1>real</h1>', isBinary: false })
expect(await readDocPreviewFile(sshGrant(), 'link.html')).toMatchObject({ ok: true })
expect(mocks.readFile).toHaveBeenCalledWith('/home/alice/docs/real.html')
})
// Why: a symlinked root is legitimate; containment must be judged on what both sides resolve to.
it('keeps serving a grant whose own root is a symlink', async () => {
mocks.realpath.mockImplementation((path: string) =>
Promise.resolve(path.replace('/home/alice/docs', '/mnt/data/docs'))
)
mocks.readFile.mockResolvedValue({ content: '<h1>hi</h1>', isBinary: false })
expect(await readDocPreviewFile(sshGrant(), 'index.html')).toMatchObject({ ok: true })
expect(mocks.readFile).toHaveBeenCalledWith('/mnt/data/docs/index.html')
})
it('404s when the host cannot canonicalize the path at all', async () => {
mocks.realpath.mockRejectedValue(new Error('no such file'))
expect(await readDocPreviewFile(sshGrant(), 'index.html')).toMatchObject({
ok: false,
status: 404
})
expect(mocks.readFile).not.toHaveBeenCalled()
})
it('404s a path outside the grant root without touching the provider', async () => {
const outcome = await readDocPreviewFile(sshGrant(), '../../etc/passwd')
expect(outcome).toMatchObject({ ok: false, status: 404 })
expect(mocks.requireSshFilesystemProvider).not.toHaveBeenCalled()
})
it('reports an over-cap SSH file as too large rather than unreadable', async () => {
mocks.readFile.mockRejectedValue(new FileReadCapExceededError('exceeds client cap'))
expect(await readDocPreviewFile(sshGrant(), 'huge.html')).toMatchObject({
ok: false,
status: 413
})
})
it('404s when the provider read fails', async () => {
mocks.readFile.mockRejectedValue(new Error('no such file'))
expect(await readDocPreviewFile(sshGrant(), 'missing.html')).toMatchObject({
ok: false,
status: 404
})
})
})
describe('readDocPreviewFile — paired runtime owner', () => {
it('reads text over worktree-relative files.read', async () => {
mocks.callRuntimeEnvironment.mockResolvedValue({
ok: true,
result: { content: '<h1>remote</h1>', truncated: false, byteLength: 15 }
})
const outcome = await readDocPreviewFile(runtimeGrant(), 'index.html')
expect(mocks.callRuntimeEnvironment).toHaveBeenCalledWith(
'/user-data',
'env-1',
'files.read',
{ worktree: 'id:wt-1', relativePath: 'docs/index.html' },
15_000
)
expect(outcome).toEqual({
ok: true,
bytes: Buffer.from('<h1>remote</h1>', 'utf8'),
contentType: 'text/html; charset=utf-8'
})
})
it('falls back to the base64 preview RPC for a binary asset', async () => {
const png = Buffer.from([0x89, 0x50, 0x4e, 0x47])
mocks.callRuntimeEnvironment
.mockResolvedValueOnce({
ok: false,
error: { code: 'runtime_error', message: 'binary_file' }
})
.mockResolvedValueOnce({
ok: true,
result: {
content: png.toString('base64'),
isBinary: true,
isImage: true,
mimeType: 'image/png'
}
})
const outcome = await readDocPreviewFile(runtimeGrant(), 'assets/logo.png')
expect(mocks.callRuntimeEnvironment).toHaveBeenNthCalledWith(
2,
'/user-data',
'env-1',
'files.readPreview',
{ worktree: 'id:wt-1', relativePath: 'docs/assets/logo.png' },
15_000
)
expect(outcome).toEqual({ ok: true, bytes: png, contentType: 'image/png' })
})
it('degrades on an old server whose empty binary preview carries no metadata', async () => {
mocks.callRuntimeEnvironment
.mockResolvedValueOnce({
ok: false,
error: { code: 'runtime_error', message: 'binary_file' }
})
.mockResolvedValueOnce({ ok: true, result: { content: '', isBinary: true } })
expect(await readDocPreviewFile(runtimeGrant(), 'assets/logo.png')).toMatchObject({
ok: false,
status: 415,
reason: 'unsupported-asset'
})
})
it('serves an empty paired asset the host typed rather than reporting a refusal', async () => {
mocks.callRuntimeEnvironment
.mockResolvedValueOnce({
ok: false,
error: { code: 'runtime_error', message: 'binary_file' }
})
.mockResolvedValueOnce({
ok: true,
result: { content: '', isBinary: true, isImage: true, mimeType: 'image/png' }
})
expect(await readDocPreviewFile(runtimeGrant(), 'assets/logo.png')).toEqual({
ok: true,
bytes: Buffer.alloc(0),
contentType: 'image/png'
})
})
// Why: files.read clamps text at the host cap and only says so in `truncated`; serving the
// clamped bytes renders a document that silently stops halfway.
it('refuses a truncated text read instead of serving the clamped bytes', async () => {
mocks.callRuntimeEnvironment.mockResolvedValue({
ok: true,
result: { content: '<h1>half of', truncated: true, byteLength: 40_000_000 }
})
const outcome = await readDocPreviewFile(runtimeGrant(), 'index.html')
expect(outcome).toMatchObject({ ok: false, status: 413 })
expect(outcome).not.toMatchObject({ ok: true })
})
it('serves a read the host reports as complete', async () => {
mocks.callRuntimeEnvironment.mockResolvedValue({
ok: true,
result: { content: '<h1>all</h1>', truncated: false, byteLength: 12 }
})
expect(await readDocPreviewFile(runtimeGrant(), 'index.html')).toMatchObject({ ok: true })
})
// Why: the binary RPC has no `truncated` field — it rejects an over-cap asset with this error.
it('reports the host rejecting an over-cap binary as too large', async () => {
mocks.callRuntimeEnvironment
.mockResolvedValueOnce({
ok: false,
error: { code: 'runtime_error', message: 'binary_file' }
})
.mockResolvedValueOnce({
ok: false,
error: { code: 'runtime_error', message: 'file_too_large' }
})
expect(await readDocPreviewFile(runtimeGrant(), 'assets/huge.png')).toMatchObject({
ok: false,
status: 413
})
})
it('does not treat an unrelated RPC failure as a binary fallback', async () => {
mocks.callRuntimeEnvironment.mockResolvedValue({
ok: false,
error: { code: 'runtime_error', message: 'permission_denied' }
})
expect(await readDocPreviewFile(runtimeGrant(), 'index.html')).toMatchObject({
ok: false,
status: 404
})
expect(mocks.callRuntimeEnvironment).toHaveBeenCalledOnce()
})
it('404s a document outside the worktree, which files.read cannot address', async () => {
const outcome = await readDocPreviewFile(runtimeGrant('/tmp/agent-docs'), 'index.html')
expect(outcome).toMatchObject({ ok: false, status: 404 })
expect(mocks.callRuntimeEnvironment).not.toHaveBeenCalled()
})
})
+198
View File
@@ -0,0 +1,198 @@
import { extname } from 'node:path'
import type {
RuntimeFilePreviewResult,
RuntimeFileReadResult
} from '../../shared/runtime-file-contracts'
import type { DocPreviewFileFailureReason } from '../../shared/doc-preview-scheme'
import { callRuntimeEnvironment } from '../ipc/runtime-environment-transport-routing'
import { FileReadCapExceededError } from '../ssh/ssh-filesystem-stream-reader'
import { getCanonicalUserDataPath } from '../persistence'
import { requireSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch'
import {
resolveCanonicalDocPreviewPath,
resolveDocPreviewTargetPath,
toRuntimeWorktreeRelativePath,
type DocPreviewGrant
} from './doc-preview-grant-registry'
const DOC_PREVIEW_READ_TIMEOUT_MS = 15_000
/** Why not "needs a newer server": the SSH read path only ever serves images and PDFs as bytes, so
* a font is refused there by design, not by version. Name the file type, not the host's age. */
const UNSERVABLE_ASSET_PREVIEW_MESSAGE = 'This workspace cannot send this file type to a preview.'
/** `files.read` clamps text at the host's cap and reports it; serving the clamped bytes would
* render a silently half-finished document. */
const TRUNCATED_PREVIEW_MESSAGE = 'This document is too large for the server to send in full.'
/** The paired host rejects an over-cap asset outright instead of clamping it. */
const RUNTIME_TOO_LARGE_ERROR = 'file_too_large'
/** Both owners refuse an over-cap file; only their error shapes differ. */
function isTooLargeReadError(error: unknown): boolean {
return (
error instanceof FileReadCapExceededError ||
(error instanceof Error && error.message === RUNTIME_TOO_LARGE_ERROR)
)
}
export type DocPreviewReadOutcome =
| { ok: true; bytes: Buffer; contentType: string }
| { ok: false; status: number; reason: DocPreviewFileFailureReason; message: string }
const DOC_PREVIEW_CONTENT_TYPES: Record<string, string> = {
'.html': 'text/html; charset=utf-8',
'.htm': 'text/html; charset=utf-8',
'.css': 'text/css; charset=utf-8',
'.js': 'text/javascript; charset=utf-8',
'.mjs': 'text/javascript; charset=utf-8',
'.json': 'application/json; charset=utf-8',
'.txt': 'text/plain; charset=utf-8',
'.md': 'text/plain; charset=utf-8',
'.csv': 'text/plain; charset=utf-8',
'.svg': 'image/svg+xml',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.gif': 'image/gif',
'.webp': 'image/webp',
'.bmp': 'image/bmp',
'.ico': 'image/x-icon',
'.pdf': 'application/pdf',
'.woff': 'font/woff',
'.woff2': 'font/woff2',
'.ttf': 'font/ttf',
'.otf': 'font/otf'
}
export function docPreviewContentType(relativePath: string): string {
return (
DOC_PREVIEW_CONTENT_TYPES[extname(relativePath).toLowerCase()] ?? 'application/octet-stream'
)
}
type PreviewFileBytes = {
content: string
isBinary: boolean
truncated?: boolean
/** Set by every owner that agreed to serve the bytes, so it also survives a 0-byte file. */
mimeType?: string
}
function toOutcome(source: PreviewFileBytes, contentType: string): DocPreviewReadOutcome {
if (source.truncated) {
return { ok: false, status: 413, reason: 'too-large', message: TRUNCATED_PREVIEW_MESSAGE }
}
if (!source.isBinary) {
return { ok: true, bytes: Buffer.from(source.content, 'utf8'), contentType }
}
if (source.content) {
return { ok: true, bytes: Buffer.from(source.content, 'base64'), contentType }
}
// Why: an empty binary body is two different answers. A host that still named the file's type
// read a 0-byte file, and 0 bytes is what it should serve; a host that named no type declined
// the format outright and has nothing to send.
return source.mimeType
? { ok: true, bytes: Buffer.alloc(0), contentType }
: {
ok: false,
status: 415,
reason: 'unsupported-asset',
message: UNSERVABLE_ASSET_PREVIEW_MESSAGE
}
}
async function readRuntimeDocPreviewFile(
environmentId: string,
worktreeSelector: string,
relativePath: string
): Promise<PreviewFileBytes> {
const userDataPath = getCanonicalUserDataPath()
const response = await callRuntimeEnvironment(
userDataPath,
environmentId,
'files.read',
{ worktree: worktreeSelector, relativePath },
DOC_PREVIEW_READ_TIMEOUT_MS
)
if (response.ok) {
const result = response.result as RuntimeFileReadResult
return { content: result.content, isBinary: false, truncated: result.truncated === true }
}
// Why: files.read rejects binaries with a typed error; the base64 preview RPC serves
// images and fonts the same way it does for markdown previews. Match the exact
// message so an unrelated failure can't spoof the fallback.
if (response.error.message !== 'binary_file') {
throw new Error(response.error.message)
}
const previewResponse = await callRuntimeEnvironment(
userDataPath,
environmentId,
'files.readPreview',
{ worktree: worktreeSelector, relativePath },
DOC_PREVIEW_READ_TIMEOUT_MS
)
if (!previewResponse.ok) {
throw new Error(previewResponse.error.message)
}
const preview = previewResponse.result as RuntimeFilePreviewResult
// Why: readPreview never clamps — it rejects an over-cap asset — so its body is whole or absent.
return {
content: preview.content,
isBinary: preview.isBinary,
...(preview.mimeType ? { mimeType: preview.mimeType } : {})
}
}
function notFoundOutcome(message = 'Not found'): DocPreviewReadOutcome {
return { ok: false, status: 404, reason: 'unreadable', message }
}
/** Reads one in-grant path over the same channel the editor uses for that owner. */
export async function readDocPreviewFile(
grant: DocPreviewGrant,
relativePath: string
): Promise<DocPreviewReadOutcome> {
const absolutePath = resolveDocPreviewTargetPath(grant, relativePath)
if (!absolutePath) {
return notFoundOutcome()
}
const contentType = docPreviewContentType(relativePath)
try {
if (grant.owner.kind === 'ssh') {
const provider = requireSshFilesystemProvider(grant.owner.connectionId)
// Why: the SSH read RPC enforces no root of its own, so containment has to survive a symlink
// before the read — the lexical check above only proves the requested path looked contained.
const canonicalPath = await resolveCanonicalDocPreviewPath(grant, absolutePath, (path) =>
provider.realpath(path)
)
if (!canonicalPath) {
return notFoundOutcome()
}
// Why: the SSH reader rejects an over-cap file outright, so its result is never partial.
return toOutcome(await provider.readFile(canonicalPath), contentType)
}
const worktreeRelativePath = toRuntimeWorktreeRelativePath(
grant.owner.worktreeRoot,
absolutePath
)
if (!worktreeRelativePath) {
// Why: files.read is worktree-scoped, so a doc outside the worktree has no client-side channel.
return notFoundOutcome()
}
// Why no realpath pass here: the host resolves this path through resolveAuthorizedPath, which
// canonicalizes and re-checks the worktree root server-side before reading.
return toOutcome(
await readRuntimeDocPreviewFile(
grant.owner.environmentId,
grant.owner.worktreeSelector,
worktreeRelativePath
),
contentType
)
} catch (error) {
return isTooLargeReadError(error)
? { ok: false, status: 413, reason: 'too-large', message: TRUNCATED_PREVIEW_MESSAGE }
: notFoundOutcome(error instanceof Error ? error.message : undefined)
}
}
@@ -0,0 +1,164 @@
import { beforeEach, describe, expect, it } from 'vitest'
import {
getDocPreviewGrant,
mintDocPreviewGrant,
resolveCanonicalDocPreviewPath,
resolveDocPreviewTargetPath,
revokeAllDocPreviewGrants,
revokeDocPreviewGrant,
toRuntimeWorktreeRelativePath,
type DocPreviewGrant
} from './doc-preview-grant-registry'
const sshOwner = { kind: 'ssh', connectionId: 'ssh-1' } as const
function mintPosixGrant(root = '/srv/repo/docs'): DocPreviewGrant {
return mintDocPreviewGrant({
owner: sshOwner,
root,
entryRelativePath: 'index.html',
browserPageId: 'page-1'
})
}
beforeEach(() => {
revokeAllDocPreviewGrants()
})
describe('doc preview grants', () => {
it('mints unguessable ids and looks them up', () => {
const first = mintPosixGrant()
const second = mintPosixGrant()
expect(first.id).toMatch(/^[0-9a-f]{32}$/)
expect(first.id).not.toBe(second.id)
expect(getDocPreviewGrant(first.id)).toBe(first)
})
it('returns nothing for an unknown or revoked grant', () => {
const grant = mintPosixGrant()
expect(getDocPreviewGrant('0'.repeat(32))).toBeNull()
expect(revokeDocPreviewGrant(grant.id)).toBe(true)
expect(getDocPreviewGrant(grant.id)).toBeNull()
expect(revokeDocPreviewGrant(grant.id)).toBe(false)
})
})
describe('resolveDocPreviewTargetPath', () => {
it('resolves paths inside the grant root', () => {
const grant = mintPosixGrant()
expect(resolveDocPreviewTargetPath(grant, 'index.html')).toBe('/srv/repo/docs/index.html')
expect(resolveDocPreviewTargetPath(grant, 'assets/logo.png')).toBe(
'/srv/repo/docs/assets/logo.png'
)
})
it('refuses parent traversal, absolute escapes and empty paths', () => {
const grant = mintPosixGrant()
expect(resolveDocPreviewTargetPath(grant, '../secret.env')).toBeNull()
expect(resolveDocPreviewTargetPath(grant, 'assets/../../secret.env')).toBeNull()
expect(resolveDocPreviewTargetPath(grant, '..')).toBeNull()
expect(resolveDocPreviewTargetPath(grant, '')).toBeNull()
expect(resolveDocPreviewTargetPath(grant, 'a//b')).toBeNull()
})
it('refuses backslash and NUL segments that could re-split on the owning host', () => {
const grant = mintPosixGrant()
expect(resolveDocPreviewTargetPath(grant, '..\\secret.env')).toBeNull()
expect(resolveDocPreviewTargetPath(grant, 'index.html\0.png')).toBeNull()
})
// Why this is a traversal test and not a containment test: every request path that names a
// sibling directory has to climb out of the root first, so the `..` segment guard answers it
// before the prefix check runs. Sibling containment is exercised where it is reachable —
// against a canonicalized path, below.
it('refuses a sibling directory by refusing the traversal that reaches it', () => {
const grant = mintPosixGrant()
expect(resolveDocPreviewTargetPath(grant, '../docs-private/secret.html')).toBeNull()
})
it('keeps a Windows drive root addressable instead of turning it drive-relative', () => {
const grant = mintDocPreviewGrant({
owner: sshOwner,
root: 'C:\\',
entryRelativePath: 'index.html',
browserPageId: 'page-1'
})
expect(resolveDocPreviewTargetPath(grant, 'index.html')).toBe('C:\\index.html')
})
it('follows the owning host path flavor rather than this process platform', () => {
const windowsGrant = mintDocPreviewGrant({
owner: sshOwner,
root: 'C:\\srv\\repo\\docs',
entryRelativePath: 'index.html',
browserPageId: 'page-1'
})
expect(resolveDocPreviewTargetPath(windowsGrant, 'assets/logo.png')).toBe(
'C:\\srv\\repo\\docs\\assets\\logo.png'
)
expect(resolveDocPreviewTargetPath(windowsGrant, '../secret.env')).toBeNull()
})
it('normalizes a trailing separator on the root', () => {
const grant = mintDocPreviewGrant({
owner: sshOwner,
root: '/srv/repo/docs/',
entryRelativePath: 'index.html',
browserPageId: 'page-1'
})
expect(resolveDocPreviewTargetPath(grant, 'index.html')).toBe('/srv/repo/docs/index.html')
expect(resolveDocPreviewTargetPath(grant, '../secret.env')).toBeNull()
})
})
describe('resolveCanonicalDocPreviewPath', () => {
// Why here and not above: a canonical path is the one input that can name a sibling directory
// without traversing — the host resolved a symlink to it — so this is where the prefix check
// is the only thing standing between the grant and `/srv/repo/docs-private`.
it('refuses a canonical path in a sibling directory that shares the root prefix', async () => {
const grant = mintPosixGrant()
await expect(
resolveCanonicalDocPreviewPath(grant, '/srv/repo/docs/report.html', async (path) =>
path === grant.root ? path : '/srv/repo/docs-private/secret.html'
)
).resolves.toBeNull()
})
it('answers the canonical path when it stays inside the canonical root', async () => {
const grant = mintPosixGrant()
await expect(
resolveCanonicalDocPreviewPath(grant, '/srv/repo/docs/report.html', async (path) => path)
).resolves.toBe('/srv/repo/docs/report.html')
})
})
describe('toRuntimeWorktreeRelativePath', () => {
it('produces a worktree-relative path for files inside the worktree', () => {
expect(toRuntimeWorktreeRelativePath('/srv/repo', '/srv/repo/docs/index.html')).toBe(
'docs/index.html'
)
})
it('rejects paths outside the worktree, which files.read cannot address', () => {
expect(toRuntimeWorktreeRelativePath('/srv/repo', '/tmp/agent/report.html')).toBeNull()
expect(toRuntimeWorktreeRelativePath('/srv/repo', '/srv/repo')).toBeNull()
})
it('uses Windows semantics for a Windows worktree root', () => {
expect(toRuntimeWorktreeRelativePath('C:\\srv\\repo', 'C:\\srv\\repo\\docs\\index.html')).toBe(
'docs/index.html'
)
expect(toRuntimeWorktreeRelativePath('C:\\srv\\repo', 'D:\\other\\index.html')).toBeNull()
})
})
@@ -0,0 +1,208 @@
import { randomBytes } from 'node:crypto'
import { posix, win32 } from 'node:path'
import { isWindowsAbsolutePathLike } from '../../shared/cross-platform-path'
/**
* A preview grant is the only authority that turns an `orca-preview://` request
* into bytes: it names the host that owns the file and the single directory
* subtree requests may resolve inside. No grant, no bytes.
*/
export type DocPreviewOwner =
| { kind: 'ssh'; connectionId: string }
| {
kind: 'runtime'
environmentId: string
/** Selector the runtime resolves `files.read` against. */
worktreeSelector: string
/** Worktree root on the runtime host; `files.read` only accepts paths inside it. */
worktreeRoot: string
}
export type DocPreviewGrant = {
id: string
owner: DocPreviewOwner
/** Containing directory of the opened document, on the owning host. */
root: string
/** Path of the opened document relative to `root`. */
entryRelativePath: string
/**
* Browser page the reader opened this document in. Main registers the guest under it once the
* guest commits to the grant, so the surface a tool names is the page the reader is looking at
* and not the grant, which a re-mint replaces underneath the same page.
*/
browserPageId: string
}
const grantsById = new Map<string, DocPreviewGrant>()
function pathFlavorFor(root: string): typeof posix | typeof win32 {
return isWindowsAbsolutePathLike(root) ? win32 : posix
}
function normalizeRootPath(root: string): string {
const flavor = pathFlavorFor(root)
const normalized =
flavor === win32 ? flavor.normalize(root.replace(/\//g, '\\')) : flavor.normalize(root)
// Why: `C:\` is the whole root, and trimming its separator would make win32.join answer the
// drive-relative `C:x`, which resolves against the host's cwd instead of inside the grant.
if (flavor === win32 && /^[a-zA-Z]:\\$/.test(normalized)) {
return normalized
}
// Why: a trailing separator would make the containment prefix check accept a sibling directory.
return normalized.length > 1 && normalized.endsWith(flavor.sep)
? normalized.slice(0, -1)
: normalized
}
export function mintDocPreviewGrant(params: {
owner: DocPreviewOwner
root: string
entryRelativePath: string
browserPageId: string
}): DocPreviewGrant {
const grant: DocPreviewGrant = {
id: randomBytes(16).toString('hex'),
owner: params.owner,
root: normalizeRootPath(params.root),
entryRelativePath: params.entryRelativePath.replace(/\\/g, '/'),
browserPageId: params.browserPageId
}
grantsById.set(grant.id, grant)
return grant
}
export function getDocPreviewGrant(grantId: string): DocPreviewGrant | null {
return grantsById.get(grantId) ?? null
}
/**
* Why anything listens at all: a grant is the only thing that names a preview's lifetime. State
* elsewhere in main keyed by a preview's tool target — grab intent, a queued grab chain — has no
* other signal telling it the surface is gone, and would otherwise accrete one entry per grant
* for the life of the process.
*/
const revocationListeners = new Set<(grant: DocPreviewGrant) => void>()
/** Why the whole grant and not its id: it is already gone from the registry when listeners run. */
export function onDocPreviewGrantRevoked(listener: (grant: DocPreviewGrant) => void): () => void {
revocationListeners.add(listener)
return () => revocationListeners.delete(listener)
}
function notifyRevoked(grant: DocPreviewGrant): void {
for (const listener of revocationListeners) {
listener(grant)
}
}
export function revokeDocPreviewGrant(grantId: string): boolean {
canonicalRootByGrantId.delete(grantId)
const grant = grantsById.get(grantId)
if (!grant) {
return false
}
grantsById.delete(grantId)
notifyRevoked(grant)
return true
}
export function revokeAllDocPreviewGrants(): void {
canonicalRootByGrantId.clear()
const revoked = [...grantsById.values()]
grantsById.clear()
for (const grant of revoked) {
notifyRevoked(grant)
}
}
function hasUnsafeSegment(segments: string[]): boolean {
return segments.some(
(segment) =>
segment.length === 0 ||
segment === '.' ||
segment === '..' ||
segment.includes('\0') ||
segment.includes('\\')
)
}
/**
* Resolves a request path to an absolute path on the owning host, or null when
* it would escape the grant's root. Path flavor follows the root (the owning
* host may be Windows while this client is not), never `process.platform`.
*/
export function resolveDocPreviewTargetPath(
grant: DocPreviewGrant,
relativePath: string
): string | null {
const segments = relativePath.split('/').filter((segment, index, all) => {
// Why: keep empty segments visible to the safety check except a single trailing one from `dir/`.
return !(segment === '' && index === all.length - 1)
})
if (segments.length === 0 || hasUnsafeSegment(segments)) {
return null
}
const flavor = pathFlavorFor(grant.root)
const resolved = flavor.normalize(flavor.join(grant.root, ...segments))
return isInsideRoot(grant.root, resolved, flavor) ? resolved : null
}
function isInsideRoot(
root: string,
candidate: string,
flavor: typeof posix | typeof win32
): boolean {
const rootPrefix = root.endsWith(flavor.sep) ? root : `${root}${flavor.sep}`
return candidate.startsWith(rootPrefix)
}
/** Why: realpath is a host round-trip, and a grant's root is fixed for its lifetime. */
const canonicalRootByGrantId = new Map<string, Promise<string>>()
/**
* Second containment pass for hosts where the lexical one is not enough: a symlink
* inside the root can point anywhere, and the SSH read RPC applies no root of its
* own. Both sides are canonicalized on the owning host before the prefix re-check;
* a host that cannot canonicalize a path answers nothing.
*/
export async function resolveCanonicalDocPreviewPath(
grant: DocPreviewGrant,
absolutePath: string,
realpath: (path: string) => Promise<string>
): Promise<string | null> {
try {
let canonicalRoot = canonicalRootByGrantId.get(grant.id)
if (!canonicalRoot) {
canonicalRoot = realpath(grant.root).then(normalizeRootPath)
canonicalRootByGrantId.set(grant.id, canonicalRoot)
}
const [root, canonicalPath] = await Promise.all([canonicalRoot, realpath(absolutePath)])
const flavor = pathFlavorFor(root)
return isInsideRoot(root, canonicalPath, flavor) ? canonicalPath : null
} catch {
// Why: a root that no longer canonicalizes must not fall back to the lexical answer.
canonicalRootByGrantId.delete(grant.id)
return null
}
}
/**
* Path a runtime `files.read` can address, i.e. relative to the worktree root.
* Returns null when the grant root sits outside the worktree — the runtime file
* RPCs are worktree-scoped, so those documents are unreadable client-side.
*/
export function toRuntimeWorktreeRelativePath(
worktreeRoot: string,
absolutePath: string
): string | null {
const flavor = pathFlavorFor(worktreeRoot)
const normalizedRoot = normalizeRootPath(worktreeRoot)
const relative = flavor.relative(normalizedRoot, absolutePath)
if (!relative || relative === '..' || relative.startsWith(`..${flavor.sep}`)) {
return null
}
if (flavor === win32 && /^[a-zA-Z]:/.test(relative)) {
return null
}
return relative.replace(/\\/g, '/')
}
@@ -0,0 +1,550 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
getWorkspaceDocPageGuest,
installDocPreviewGuestPolicy,
readDocPreviewGuestBoundGrantId,
reportDocPreviewLinkClick
} from './doc-preview-guest-policy'
import { buildDocPreviewUrl } from '../../shared/doc-preview-scheme'
import {
mintDocPreviewGrant,
revokeAllDocPreviewGrants,
revokeDocPreviewGrant
} from './doc-preview-grant-registry'
type GuestHandlers = Record<string, (...args: never[]) => void>
const HOST_RENDERER_ID = 42
function installOnFakeGuest(
hostId: number = HOST_RENDERER_ID,
/** What the guest is already showing when the embedder hands it over, as a real one usually is. */
initialUrl = ''
): {
contents: object
handlers: GuestHandlers
hostId: number
isFocused: ReturnType<typeof vi.fn>
send: ReturnType<typeof vi.fn>
setWebRTCIPHandlingPolicy: ReturnType<typeof vi.fn>
windowOpenHandler: (details: { url: string }) => { action: string }
/** Why without the `destroyed` event: Chromium tears the contents down before main runs it. */
markContentsDestroyed: () => void
} {
const handlers: GuestHandlers = {}
let contentsDestroyed = false
const send = vi.fn()
const isFocused = vi.fn(() => true)
const setWebRTCIPHandlingPolicy = vi.fn()
let windowOpenHandler: (details: { url: string }) => { action: string } = () => ({
action: 'deny'
})
const register = (event: string, handler: (...args: never[]) => void): void => {
handlers[event] = handler
}
const guest = {
isFocused,
isDestroyed: () => contentsDestroyed,
getURL: () => initialUrl,
on: vi.fn(register),
once: vi.fn(register),
setWindowOpenHandler: vi.fn((handler: (details: { url: string }) => { action: string }) => {
windowOpenHandler = handler
}),
setWebRTCIPHandlingPolicy
}
installDocPreviewGuestPolicy(guest as never, { id: hostId, send })
return {
contents: guest,
handlers,
hostId,
isFocused,
send,
setWebRTCIPHandlingPolicy,
windowOpenHandler: (details) => windowOpenHandler(details),
markContentsDestroyed: () => {
contentsDestroyed = true
}
}
}
type FakeGuest = ReturnType<typeof installOnFakeGuest>
function startMainFrameNavigation(guest: FakeGuest, url: string): void {
guest.handlers['did-start-navigation']?.({ url, isMainFrame: true } as never)
}
/** A guest already showing a document, which is the only state a link can be pressed in. */
function boundGuest(): { grant: ReturnType<typeof mintGrant>; guest: FakeGuest } {
const grant = mintGrant()
const guest = installOnFakeGuest()
startMainFrameNavigation(guest, buildDocPreviewUrl(grant.id, 'index.html'))
return { grant, guest }
}
function reportClick(guest: FakeGuest, url: string): void {
reportDocPreviewLinkClick(guest.contents as never, url)
}
// Why a fresh page per grant: the registry is keyed by the page now, so two grants sharing one
// would have the second silently replace the first rather than stand beside it.
let nextDocPageOrdinal = 0
function mintGrant(): ReturnType<typeof mintDocPreviewGrant> {
nextDocPageOrdinal += 1
return mintDocPreviewGrant({
owner: { kind: 'ssh', connectionId: 'ssh-1' },
root: '/home/alice/docs',
entryRelativePath: 'index.html',
browserPageId: `doc-page-${nextDocPageOrdinal}`
})
}
beforeEach(() => {
vi.clearAllMocks()
revokeAllDocPreviewGrants()
})
describe('doc preview guest policy', () => {
it('allows relative navigation within the bound grant', () => {
const { grant, guest } = boundGuest()
const preventDefault = vi.fn()
guest.handlers['will-navigate']?.(
{ preventDefault } as never,
buildDocPreviewUrl(grant.id, 'guide.html') as never
)
expect(preventDefault).not.toHaveBeenCalled()
expect(guest.send).not.toHaveBeenCalled()
})
// Why an unlatched guest is refused rather than trusted: the renderer-set src is
// browser-initiated and never reaches will-navigate, so a navigation arriving before the latch is
// one the guest started for itself.
it('blocks a navigation the guest starts before a document has bound it', () => {
const grant = mintGrant()
const guest = installOnFakeGuest()
const preventDefault = vi.fn()
guest.handlers['will-navigate']?.(
{ preventDefault } as never,
buildDocPreviewUrl(grant.id, 'index.html') as never
)
expect(preventDefault).toHaveBeenCalledOnce()
expect(guest.send).not.toHaveBeenCalled()
})
it('blocks navigation into a different live grant once bound', () => {
const { guest } = boundGuest()
const otherGrant = mintGrant()
const preventDefault = vi.fn()
guest.handlers['will-navigate']?.(
{ preventDefault } as never,
buildDocPreviewUrl(otherGrant.id, 'index.html') as never
)
expect(preventDefault).toHaveBeenCalledOnce()
})
it('blocks a revoked grant even when it matches the bound id', () => {
const { grant, guest } = boundGuest()
revokeAllDocPreviewGrants()
const preventDefault = vi.fn()
guest.handlers['will-navigate']?.(
{ preventDefault } as never,
buildDocPreviewUrl(grant.id, 'guide.html') as never
)
expect(preventDefault).toHaveBeenCalledOnce()
})
it('blocks an external navigation without offering it to the renderer', () => {
const { guest } = boundGuest()
const preventDefault = vi.fn()
guest.handlers['will-navigate']?.({ preventDefault } as never, 'https://example.com/' as never)
expect(preventDefault).toHaveBeenCalledOnce()
expect(guest.send).not.toHaveBeenCalled()
})
it('blocks a file: navigation', () => {
const { guest } = boundGuest()
const preventDefault = vi.fn()
guest.handlers['will-navigate']?.({ preventDefault } as never, 'file:///etc/passwd' as never)
expect(preventDefault).toHaveBeenCalledOnce()
expect(guest.send).not.toHaveBeenCalled()
})
it('applies the same rule to redirects', () => {
const { guest } = boundGuest()
const preventDefault = vi.fn()
guest.handlers['will-redirect']?.({ preventDefault } as never, 'https://evil.test/' as never)
expect(preventDefault).toHaveBeenCalledOnce()
expect(guest.send).not.toHaveBeenCalled()
})
it('denies every popup and routes none of them', () => {
const { guest } = boundGuest()
expect(guest.windowOpenHandler({ url: 'https://example.com/docs' })).toEqual({
action: 'deny'
})
expect(guest.windowOpenHandler({ url: 'file:///etc/passwd' })).toEqual({ action: 'deny' })
expect(guest.send).not.toHaveBeenCalled()
})
// Why: will-navigate never fires for a subframe, so an <iframe src="https://…"> would load
// off-machine even though the top frame cannot.
it('blocks a subframe navigating outside the grant, without opening a tab for it', () => {
const { guest } = boundGuest()
const preventDefault = vi.fn()
guest.handlers['will-frame-navigate']?.({
preventDefault,
url: 'https://tracker.test/pixel',
isMainFrame: false
} as never)
expect(preventDefault).toHaveBeenCalledOnce()
expect(guest.send).not.toHaveBeenCalled()
})
it('lets a subframe load an in-grant asset', () => {
const { grant, guest } = boundGuest()
const preventDefault = vi.fn()
guest.handlers['will-frame-navigate']?.({
preventDefault,
url: buildDocPreviewUrl(grant.id, 'chart.html'),
isMainFrame: false
} as never)
expect(preventDefault).not.toHaveBeenCalled()
})
// Why this group exists: the external-link route ends in a real browser tab with full network, so
// a document that can read its grant and reach that route can exfiltrate it. Only a reader's own
// press on a link may, and only the guest's preload can tell that a press was one.
describe('the trusted-click route out of the preview', () => {
// The headline. The earlier design read a recent-input timestamp, so a script that navigated
// shortly after any genuine press was routed out as if the press had asked for it. Here the
// navigation sinks route nothing at all, so when the input happened cannot matter.
it('routes nothing for a scripted location change or window.open, whenever input happened', () => {
const { guest } = boundGuest()
const preventDefault = vi.fn()
// Genuine input the guest would report, delivered immediately before the document acts.
guest.handlers['input-event']?.({} as never, { type: 'mouseDown' } as never)
guest.handlers['before-input-event']?.({} as never, { type: 'keyDown' } as never)
guest.handlers['will-navigate']?.(
{ preventDefault } as never,
'https://attacker.test/?d=secret' as never
)
const popup = guest.windowOpenHandler({ url: 'https://attacker.test/?d=secret' })
expect(preventDefault).toHaveBeenCalledOnce()
expect(popup).toEqual({ action: 'deny' })
expect(guest.send).not.toHaveBeenCalled()
// Why assert the absence of the listeners too: with them gone there is no timing a document
// could hit, rather than a window it merely failed to hit in this test.
expect(guest.handlers['input-event']).toBeUndefined()
expect(guest.handlers['before-input-event']).toBeUndefined()
})
it('routes a reported click on an external link exactly once', () => {
const { guest } = boundGuest()
reportClick(guest, 'https://example.com/docs')
expect(guest.send).toHaveBeenCalledExactlyOnceWith('docPreview:externalLink', {
url: 'https://example.com/docs'
})
})
it('drops a click reported while the guest is not the contents the reader is looking at', () => {
const { guest } = boundGuest()
guest.isFocused.mockReturnValue(false)
reportClick(guest, 'https://example.com/docs')
expect(guest.send).not.toHaveBeenCalled()
})
it('drops a click reported by a sender that is not a preview guest', () => {
const { guest } = boundGuest()
reportDocPreviewLinkClick({ isFocused: () => true } as never, 'https://example.com/docs')
expect(guest.send).not.toHaveBeenCalled()
})
it('drops a click reported by a guest that never bound a grant', () => {
mintGrant()
const guest = installOnFakeGuest()
reportClick(guest, 'https://example.com/docs')
expect(guest.send).not.toHaveBeenCalled()
})
it("drops a click once the guest's bound grant is revoked", () => {
const { guest } = boundGuest()
revokeAllDocPreviewGrants()
reportClick(guest, 'https://example.com/docs')
expect(guest.send).not.toHaveBeenCalled()
})
it.each([
'file:///etc/passwd',
'javascript:fetch("https://attacker.test")',
'orca-preview://a/b',
'/Users/alice/secrets.txt',
''
])('drops a reported click on %s, which is not the web', (url) => {
const { guest } = boundGuest()
reportClick(guest, url)
expect(guest.send).not.toHaveBeenCalled()
})
it('stops routing for a guest that has been destroyed', () => {
const { guest } = boundGuest()
guest.handlers['destroyed']?.()
reportClick(guest, 'https://example.com/docs')
expect(guest.send).not.toHaveBeenCalled()
})
})
// Why: a peer connection is UDP straight off the network stack — the response CSP and the
// session's request filter both miss it, so this is the only place it can be refused.
it('denies the guest non-proxied WebRTC UDP at attach', () => {
const guest = installOnFakeGuest()
expect(guest.setWebRTCIPHandlingPolicy).toHaveBeenCalledWith('disable_non_proxied_udp')
})
// Why: latching from a subframe would let an in-document iframe decide which grant the guest
// belongs to, and every later main-frame check would be measured against that.
it('binds the guest from the main frame only', () => {
const grant = mintGrant()
const otherGrant = mintGrant()
const guest = installOnFakeGuest()
guest.handlers['did-start-navigation']?.({
url: buildDocPreviewUrl(otherGrant.id, 'index.html'),
isMainFrame: false
} as never)
startMainFrameNavigation(guest, buildDocPreviewUrl(grant.id, 'index.html'))
const preventDefault = vi.fn()
guest.handlers['will-navigate']?.(
{ preventDefault } as never,
buildDocPreviewUrl(otherGrant.id, 'index.html') as never
)
expect(preventDefault).toHaveBeenCalledOnce()
})
describe('tool authorization', () => {
it('answers the hosting renderer for a guest bound to a live grant', () => {
const { grant, guest } = boundGuest()
expect(getWorkspaceDocPageGuest(grant.browserPageId, HOST_RENDERER_ID)).toBe(guest.contents)
})
it('refuses a renderer that does not host the preview', () => {
const { grant } = boundGuest()
expect(getWorkspaceDocPageGuest(grant.browserPageId, HOST_RENDERER_ID + 1)).toBeNull()
})
// Why this is the ordinary case and not an edge one: the embedder hands the guest over after
// it has already started loading its src, so the navigation that binds most previews to their
// grant happens before any listener here exists. Missing it leaves the tools unable to name
// the guest for as long as the reader stays on the page they opened.
it('binds a guest that is already showing its document when the policy installs', () => {
const grant = mintGrant()
const guest = installOnFakeGuest(HOST_RENDERER_ID, buildDocPreviewUrl(grant.id, 'index.html'))
expect(getWorkspaceDocPageGuest(grant.browserPageId, HOST_RENDERER_ID)).toBe(guest.contents)
})
// The same miss, one event later: the load started before the listener and commits after it.
it('binds a guest whose first navigation only reaches the commit', () => {
const grant = mintGrant()
const guest = installOnFakeGuest()
guest.handlers['did-navigate']?.(
{} as never,
buildDocPreviewUrl(grant.id, 'index.html') as never
)
expect(getWorkspaceDocPageGuest(grant.browserPageId, HOST_RENDERER_ID)).toBe(guest.contents)
})
// Why: the src commit is what proves this guest is showing that grant. Before it, the id names
// a document nothing has been asked to render.
it('refuses a guest that has not committed a document yet', () => {
const grant = mintGrant()
installOnFakeGuest()
expect(getWorkspaceDocPageGuest(grant.browserPageId, HOST_RENDERER_ID)).toBeNull()
})
// Why: revoking is how a closed tab withdraws its preview, and it happens while the guest is
// still being torn down — a tool must stop answering at revoke, not at destroy.
it('stops answering once the grant is revoked', () => {
const { grant } = boundGuest()
revokeDocPreviewGrant(grant.id)
expect(getWorkspaceDocPageGuest(grant.browserPageId, HOST_RENDERER_ID)).toBeNull()
})
it('drops the guest when it is destroyed', () => {
const { grant, guest } = boundGuest()
guest.handlers['destroyed']?.()
expect(getWorkspaceDocPageGuest(grant.browserPageId, HOST_RENDERER_ID)).toBeNull()
})
// Why this is separate from the destroy event: the contents die before main runs that listener,
// so in that window the registration still looks live and only this check refuses it.
it('refuses a guest whose contents died before the destroy event arrived', () => {
const { grant, guest } = boundGuest()
guest.markContentsDestroyed()
expect(getWorkspaceDocPageGuest(grant.browserPageId, HOST_RENDERER_ID)).toBeNull()
})
it('answers nothing for a grant no preview ever rendered', () => {
const grant = mintGrant()
expect(getWorkspaceDocPageGuest(grant.browserPageId, HOST_RENDERER_ID)).toBeNull()
})
// Why this is what makes three latch call sites safe: install, did-start-navigation and
// did-navigate all feed the same latch, so without the no-rebind guard the second and third
// would let a later navigation move a bound guest onto another grant — and a tool asking for
// that grant would be handed a guest showing someone else's document.
it('never rebinds a bound guest onto a second grant', () => {
const { grant, guest } = boundGuest()
const other = mintGrant()
startMainFrameNavigation(guest, buildDocPreviewUrl(other.id, 'index.html'))
guest.handlers['did-navigate']?.(
{} as never,
buildDocPreviewUrl(other.id, 'index.html') as never
)
expect(getWorkspaceDocPageGuest(other.browserPageId, HOST_RENDERER_ID)).toBeNull()
expect(getWorkspaceDocPageGuest(grant.browserPageId, HOST_RENDERER_ID)).toBe(guest.contents)
})
// Why the replacement is registered before the old guest's teardown runs: a re-mint attaches
// the new guest first, and Chromium runs the outgoing guest's destroyed listener afterwards. A
// teardown that deleted by page alone would unregister the guest the reader is now looking at.
it('leaves a re-minted preview registered when the guest it replaced tears down', () => {
const first = mintGrant()
const outgoing = installOnFakeGuest(
HOST_RENDERER_ID,
buildDocPreviewUrl(first.id, 'index.html')
)
const remint = mintDocPreviewGrant({
owner: { kind: 'ssh', connectionId: 'ssh-1' },
root: '/home/alice/docs',
entryRelativePath: 'index.html',
browserPageId: first.browserPageId
})
const incoming = installOnFakeGuest(
HOST_RENDERER_ID,
buildDocPreviewUrl(remint.id, 'index.html')
)
expect(getWorkspaceDocPageGuest(first.browserPageId, HOST_RENDERER_ID)).toBe(
incoming.contents
)
outgoing.handlers['destroyed']?.()
expect(getWorkspaceDocPageGuest(first.browserPageId, HOST_RENDERER_ID)).toBe(
incoming.contents
)
})
// Why the grant has to be live at the latch and not only at each later request: registering the
// page a dead grant names would put a guest that can read nothing into the registry the tool
// door answers from, under a page a live grant may later want.
it('registers nothing for a guest showing a grant that is already revoked', () => {
const grant = mintGrant()
revokeDocPreviewGrant(grant.id)
const guest = installOnFakeGuest(HOST_RENDERER_ID, buildDocPreviewUrl(grant.id, 'index.html'))
expect(getWorkspaceDocPageGuest(grant.browserPageId, HOST_RENDERER_ID)).toBeNull()
// The presence half: the same install does register when the grant is still live.
const live = mintGrant()
installOnFakeGuest(HOST_RENDERER_ID, buildDocPreviewUrl(live.id, 'index.html'))
expect(getWorkspaceDocPageGuest(live.browserPageId, HOST_RENDERER_ID)).not.toBeNull()
expect(guest.contents).toBeDefined()
})
// Why both directions: two previews open at once must not be able to drive each other's guest.
it('keeps two live previews on their own guests', () => {
const first = boundGuest()
const second = boundGuest()
expect(getWorkspaceDocPageGuest(first.grant.browserPageId, HOST_RENDERER_ID)).toBe(
first.guest.contents
)
expect(getWorkspaceDocPageGuest(second.grant.browserPageId, HOST_RENDERER_ID)).toBe(
second.guest.contents
)
})
})
// Session events name the contents and nothing else, so this is how a fence firing on the whole
// partition works out which preview — if any — the reader should be told about.
describe('naming the grant a contents is showing', () => {
it('answers the bound grant for a live preview guest', () => {
const { grant, guest } = boundGuest()
expect(readDocPreviewGuestBoundGrantId(guest.contents as never)).toBe(grant.id)
})
it('answers nothing for a contents that is not a preview guest', () => {
boundGuest()
expect(readDocPreviewGuestBoundGrantId({} as never)).toBeNull()
})
it('answers nothing for a preview guest that has committed no document', () => {
const guest = installOnFakeGuest()
expect(readDocPreviewGuestBoundGrantId(guest.contents as never)).toBeNull()
})
it('answers nothing once the guest is gone', () => {
const { guest } = boundGuest()
guest.handlers['destroyed']?.()
expect(readDocPreviewGuestBoundGrantId(guest.contents as never)).toBeNull()
})
})
})
@@ -0,0 +1,247 @@
import {
DOC_PREVIEW_EXTERNAL_LINK_CHANNEL,
parseDocPreviewUrl
} from '../../shared/doc-preview-scheme'
import { normalizeExternalBrowserUrl } from '../../shared/browser-url'
import { enforceBrowserRouteWebRtcPolicy } from './browser-route-webrtc-policy'
import { getDocPreviewGrant } from './doc-preview-grant-registry'
/** The trusted renderer hosting the preview: the sink for link reports and the only sender allowed to drive tools on it. */
type PreviewHostRenderer = {
id: number
send: (channel: string, payload: { url: string }) => void
}
type PreviewGuestRegistration = {
host: PreviewHostRenderer
readBoundGrantId: () => string | null
isFocused: () => boolean
}
/**
* Why a registry: the click report arrives on an IPC channel, where the only thing main holds is
* the sender. Without this, "is this a live preview guest, and which grant is it bound to" has no
* answer, and any WebContents that learned the channel name would be routed out.
*/
const previewGuests = new WeakMap<object, PreviewGuestRegistration>()
/**
* The workspace-document half of the browser page registry, keyed by the page the reader opened
* the document in. It is deliberately a separate map from the browsing one: page management,
* agent commands, download routing and certificate attribution all read that map directly, in
* more places than a guard could be remembered in, so a document guest is simply not in it. The
* one door taught about both is `browserManager.getAuthorizedGuest`, because acting on the guest
* the reader is looking at is the single operation that legitimately spans them.
*/
const docGuestsByPageId = new Map<
string,
{ guest: Electron.WebContents; hostId: number; grantId: string }
>()
const registrationListeners = new Set<(browserPageId: string) => void>()
/** Why anything listens: a tool request can beat the guest's own attach, and the wait it parks in lives in the IPC layer. */
export function onWorkspaceDocGuestRegistered(
listener: (browserPageId: string) => void
): () => void {
registrationListeners.add(listener)
return () => registrationListeners.delete(listener)
}
/**
* Resolve the document guest a browser tool should act on. Grants the guest nothing — it answers
* only whether the trusted renderer asking is the one hosting a live, grant-bound preview on that
* page. Reached only through `browserManager.getAuthorizedGuest`.
*/
export function getWorkspaceDocPageGuest(
browserPageId: string,
senderWebContentsId: number
): Electron.WebContents | null {
const registration = docGuestsByPageId.get(browserPageId)
if (!registration || registration.hostId !== senderWebContentsId) {
return null
}
// Why: revocation is how a closed tab withdraws its preview, and it happens before the guest is torn down.
if (!getDocPreviewGrant(registration.grantId)) {
return null
}
if (registration.guest.isDestroyed()) {
docGuestsByPageId.delete(browserPageId)
return null
}
return registration.guest
}
/** True when the page renders a workspace document, so the browsing registry must never hold it. */
export function isWorkspaceDocPageId(browserPageId: string): boolean {
return docGuestsByPageId.has(browserPageId)
}
/**
* The grant a live preview guest is bound to, or null for any other WebContents — the same identity
* question `reportDocPreviewLinkClick` asks, for a session event that hands over only the contents.
*/
export function readDocPreviewGuestBoundGrantId(guest: Electron.WebContents): string | null {
return previewGuests.get(guest)?.readBoundGrantId() ?? null
}
/**
* The preview guest renders a workspace document, not the web: it may only move within its own
* grant, and nothing it does by itself leaves the preview. The one route out is
* `reportDocPreviewLinkClick`, which answers for a click the reader really made.
*
* Reached only through `browserManager.attachGuestPolicies` under the workspace-doc profile, so
* every guest in the app is policy-attached by one door whatever it renders. Returns the disposal
* that door stores: without it a retired guest keeps its listeners and stays answerable through the
* grant-keyed authority until the WebContents itself is collected.
*/
export function installDocPreviewGuestPolicy(
guest: Electron.WebContents,
host: PreviewHostRenderer
): () => void {
// Why: the first commit is the renderer-set src, already admitted by will-attach-webview;
// latching it pins every later navigation to that one grant.
let boundGrantId: string | null = null
let boundPageId: string | null = null
const latchGrantFromUrl = (rawUrl: string): void => {
if (boundGrantId !== null) {
return
}
const grantId = parseDocPreviewUrl(rawUrl)?.grantId ?? null
// Why the grant must still be live: registering under the page a dead grant names would put a
// guest nothing can read into the registry the tool door answers from.
const grant = grantId === null ? null : getDocPreviewGrant(grantId)
if (!grant) {
return
}
boundGrantId = grant.id
boundPageId = grant.browserPageId
docGuestsByPageId.set(boundPageId, { guest, hostId: host.id, grantId: grant.id })
for (const listener of registrationListeners) {
listener(boundPageId)
}
}
previewGuests.set(guest, {
host,
readBoundGrantId: () => boundGrantId,
isFocused: () => guest.isFocused()
})
const forgetGuest = (): void => {
previewGuests.delete(guest)
// Why the identity check: a re-mint registers the replacement under the same page before this
// guest's own teardown runs, and deleting by key alone would unregister the live one.
if (boundPageId !== null && docGuestsByPageId.get(boundPageId)?.guest === guest) {
docGuestsByPageId.delete(boundPageId)
}
}
guest.once('destroyed', forgetGuest)
const isAllowedPreviewNavigation = (rawUrl: string): boolean => {
const target = parseDocPreviewUrl(rawUrl)
if (!target || !getDocPreviewGrant(target.grantId)) {
return false
}
// Why the latch is required and not just consistent: the renderer-set src is browser-initiated,
// so will-navigate never fires for it. Anything reaching here before the latch is the guest
// moving itself, which no grant has admitted yet.
return boundGrantId !== null && target.grantId === boundGrantId
}
/**
* Why deny-only, with nothing routed: a navigation the guest starts cannot be attributed to the
* reader. The document may read its whole grant over `connect-src 'self'`, so routing an
* unattributable URL to a real browser tab with full network is how those bytes would get out.
*/
const navigationGuard = (event: Electron.Event, url: string): void => {
if (isAllowedPreviewNavigation(url)) {
return
}
event.preventDefault()
}
// Why here and not only on navigation events: the guest is already loading its src by the time
// the embedder hands it to us, so the only navigation most previews ever make has already
// started. Latching what it is on now is what binds the usual preview to its grant at all.
latchGrantFromUrl(guest.getURL())
const latchFromMainFrameNavigation = (details: { isMainFrame: boolean; url: string }): void => {
// Why: only the top document defines which grant this guest belongs to. Latching from a
// subframe would let an in-document iframe rebind the guest to another grant.
if (!details.isMainFrame) {
return
}
latchGrantFromUrl(details.url)
}
// Why a committed URL too: a navigation that started before this listener existed still commits
// after it, and a preview that never navigates again would otherwise stay bound to nothing.
const latchFromCommittedUrl = (_event: Electron.Event, url: string): void =>
latchGrantFromUrl(url)
// Why: will-navigate never fires for a subframe, so without this an <iframe src="https://…">
// inside a previewed document would load off-machine even though the top frame cannot.
const frameNavigationGuard = (details: {
isMainFrame: boolean
url: string
preventDefault: () => void
}): void => {
if (details.isMainFrame || isAllowedPreviewNavigation(details.url)) {
return
}
details.preventDefault()
}
guest.on('did-start-navigation', latchFromMainFrameNavigation)
guest.on('did-navigate', latchFromCommittedUrl)
guest.on('will-navigate', navigationGuard)
guest.on('will-redirect', navigationGuard)
guest.on('will-frame-navigate', frameNavigationGuard)
// Why deny with nothing routed: previews own no native child windows, and a popup the document
// asked for is the document asking, not the reader. A link the reader presses is intercepted
// before Chromium ever considers a popup.
guest.setWindowOpenHandler(() => ({ action: 'deny' }))
// Why a second fence for one API: WebRTC opens UDP straight from the network stack, so neither the
// response CSP nor the session's request filter ever sees it. This is the only layer that can.
enforceBrowserRouteWebRtcPolicy(guest, () => {})
return () => {
forgetGuest()
if (guest.isDestroyed()) {
return
}
guest.off('destroyed', forgetGuest)
guest.off('did-start-navigation', latchFromMainFrameNavigation)
guest.off('did-navigate', latchFromCommittedUrl)
guest.off('will-navigate', navigationGuard)
guest.off('will-redirect', navigationGuard)
guest.off('will-frame-navigate', frameNavigationGuard)
}
}
function isWebUrl(url: string): boolean {
return url.startsWith('http://') || url.startsWith('https://')
}
/**
* The only way a URL leaves a preview. Every condition is load-bearing: the sender must be a live
* preview guest still bound to a grant, it must be the contents the reader is looking at, and the
* target must be the web. Anything else is dropped without a trace the document could observe.
*/
export function reportDocPreviewLinkClick(sender: Electron.WebContents, rawUrl: string): void {
const registration = previewGuests.get(sender)
if (!registration) {
return
}
const boundGrantId = registration.readBoundGrantId()
if (boundGrantId === null || !getDocPreviewGrant(boundGrantId)) {
return
}
// Why focus and not just the preload's trusted-click check: that check runs inside the guest, so
// it holds only while the guest renderer does. Focus is the half main can verify for itself.
if (!registration.isFocused()) {
return
}
const externalUrl = normalizeExternalBrowserUrl(rawUrl)
if (!externalUrl || !isWebUrl(externalUrl)) {
return
}
registration.host.send(DOC_PREVIEW_EXTERNAL_LINK_CHANNEL, { url: externalUrl })
}
@@ -0,0 +1,336 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
readDocPreviewFile: vi.fn(),
installBrowserSessionPartitionPolicies: vi.fn()
}))
function createFakeSession(): {
protocol: { isProtocolHandled: () => boolean; handle: ReturnType<typeof vi.fn> }
webRequest: { onBeforeRequest: ReturnType<typeof vi.fn> }
} {
return {
protocol: { isProtocolHandled: () => false, handle: vi.fn() },
webRequest: { onBeforeRequest: vi.fn() }
}
}
// Why one session per partition and a distinct default: installing the handler on the default
// session instead of the preview session is otherwise invisible — every read would come back
// from the same object.
const previewSession = createFakeSession()
const defaultSession = createFakeSession()
vi.mock('electron', () => ({
protocol: { registerSchemesAsPrivileged: vi.fn() },
session: {
get defaultSession() {
return defaultSession
},
fromPartition: (partition: string) => {
if (partition !== 'orca-doc-preview') {
throw new Error(`unexpected partition ${partition}`)
}
return previewSession
}
}
}))
vi.mock('./doc-preview-file-reader', () => ({ readDocPreviewFile: mocks.readDocPreviewFile }))
vi.mock('./browser-session-partition-policies', () => ({
installBrowserSessionPartitionPolicies: mocks.installBrowserSessionPartitionPolicies
}))
import { protocol } from 'electron'
import {
getDocPreviewSession,
handleDocPreviewRequest,
installDocPreviewProtocolHandler,
isAllowedDocPreviewRequestUrl,
isDocPreviewSession,
registerDocPreviewSchemePrivileges
} from './doc-preview-protocol'
import {
mintDocPreviewGrant,
revokeAllDocPreviewGrants,
revokeDocPreviewGrant
} from './doc-preview-grant-registry'
import {
buildDocPreviewUrl,
DOC_PREVIEW_LOAD_FAILURE_CHANNEL
} from '../../shared/doc-preview-scheme'
import { setDocPreviewFailureSink } from './doc-preview-failure-notice'
function mintGrant(): ReturnType<typeof mintDocPreviewGrant> {
return mintDocPreviewGrant({
owner: { kind: 'ssh', connectionId: 'ssh-1' },
root: '/home/alice/docs',
entryRelativePath: 'index.html',
browserPageId: 'page-1'
})
}
beforeEach(() => {
vi.clearAllMocks()
revokeAllDocPreviewGrants()
setDocPreviewFailureSink(null)
mocks.readDocPreviewFile.mockResolvedValue({
ok: true,
bytes: Buffer.from('<h1>hi</h1>', 'utf8'),
contentType: 'text/html; charset=utf-8'
})
})
describe('handleDocPreviewRequest', () => {
it('serves an in-grant document with no-store so reload re-reads the workspace', async () => {
const grant = mintGrant()
const response = await handleDocPreviewRequest(
new Request(buildDocPreviewUrl(grant.id, 'index.html'))
)
expect(response.status).toBe(200)
expect(response.headers.get('Content-Type')).toBe('text/html; charset=utf-8')
expect(response.headers.get('Cache-Control')).toBe('no-store')
expect(await response.text()).toBe('<h1>hi</h1>')
expect(mocks.readDocPreviewFile).toHaveBeenCalledWith(grant, 'index.html')
})
it('falls back to the granted entry document for a root request', async () => {
const grant = mintGrant()
await handleDocPreviewRequest(new Request(`orca-preview://${grant.id}/`))
expect(mocks.readDocPreviewFile).toHaveBeenCalledWith(grant, 'index.html')
})
it('decodes percent-encoded segments before resolving', async () => {
const grant = mintGrant()
await handleDocPreviewRequest(new Request(buildDocPreviewUrl(grant.id, 'a b/c#d.html')))
expect(mocks.readDocPreviewFile).toHaveBeenCalledWith(grant, 'a b/c#d.html')
})
it('404s an unknown grant without reading anything', async () => {
const response = await handleDocPreviewRequest(
new Request(`orca-preview://${'0'.repeat(32)}/index.html`)
)
expect(response.status).toBe(404)
expect(mocks.readDocPreviewFile).not.toHaveBeenCalled()
})
it('404s a revoked grant', async () => {
const grant = mintGrant()
revokeDocPreviewGrant(grant.id)
const response = await handleDocPreviewRequest(
new Request(buildDocPreviewUrl(grant.id, 'index.html'))
)
expect(response.status).toBe(404)
expect(mocks.readDocPreviewFile).not.toHaveBeenCalled()
})
it('404s a malformed grant id', async () => {
const response = await handleDocPreviewRequest(new Request('orca-preview://not-a-grant/x.html'))
expect(response.status).toBe(404)
expect(mocks.readDocPreviewFile).not.toHaveBeenCalled()
})
it('propagates the reader status for an unservable asset', async () => {
const grant = mintGrant()
mocks.readDocPreviewFile.mockResolvedValue({
ok: false,
status: 415,
reason: 'unsupported-asset',
message: 'cannot send this file type'
})
const response = await handleDocPreviewRequest(
new Request(buildDocPreviewUrl(grant.id, 'a.woff2'))
)
expect(response.status).toBe(415)
expect(await response.text()).toBe('cannot send this file type')
})
// Why: previewed documents are agent-authored, so an outbound request is an exfiltration channel
// for everything else the grant can read.
it('serves documents under a self-only content security policy', async () => {
const grant = mintGrant()
const response = await handleDocPreviewRequest(
new Request(buildDocPreviewUrl(grant.id, 'index.html'))
)
const policy = response.headers.get('Content-Security-Policy') ?? ''
expect(policy).toContain("default-src 'self'")
expect(policy).toContain("connect-src 'self'")
expect(policy).toContain("object-src 'none'")
expect(policy).toContain("img-src 'self' data:")
expect(policy).not.toContain('https:')
// Why assert an absence: `webrtc 'block'` is the obvious directive to reach for here and this
// Chromium does not implement it — it logs "Unrecognized Content-Security-Policy directive
// 'webrtc'" and gathers candidates regardless. Adding it back would document a fence that is
// not there; the guest's IP-handling policy is what actually refuses.
expect(policy).not.toContain('webrtc')
})
// Why: the guest paints a 4xx body as if it were the document, so the shell only learns the
// reason from this push.
it('pushes the failure reason for the requested path', async () => {
const send = vi.fn()
setDocPreviewFailureSink({ send })
const grant = mintGrant()
mocks.readDocPreviewFile.mockResolvedValue({
ok: false,
status: 413,
reason: 'too-large',
message: 'too large'
})
await handleDocPreviewRequest(new Request(buildDocPreviewUrl(grant.id, 'index.html')))
expect(send).toHaveBeenCalledWith(DOC_PREVIEW_LOAD_FAILURE_CHANNEL, {
grantId: grant.id,
relativePath: 'index.html',
reason: 'too-large'
})
})
it('pushes nothing when the document is served', async () => {
const send = vi.fn()
setDocPreviewFailureSink({ send })
const grant = mintGrant()
await handleDocPreviewRequest(new Request(buildDocPreviewUrl(grant.id, 'index.html')))
expect(send).not.toHaveBeenCalled()
})
// Why: a grant revoked underneath a live guest 404s like a missing file, and without this push
// the shell leaves the handler's "Not found" body on screen as if it were the document.
it('pushes a failure for a revoked grant so the shell can replace the 404 body', async () => {
const send = vi.fn()
const grant = mintGrant()
revokeDocPreviewGrant(grant.id)
setDocPreviewFailureSink({ send })
await handleDocPreviewRequest(new Request(buildDocPreviewUrl(grant.id, 'index.html')))
expect(send).toHaveBeenCalledWith(DOC_PREVIEW_LOAD_FAILURE_CHANNEL, {
grantId: grant.id,
relativePath: 'index.html',
reason: 'unreadable'
})
})
})
describe('installDocPreviewProtocolHandler', () => {
// Why the default session is named here: it is the session every other Electron API reaches for
// by default, and handling the scheme there would serve preview bytes to ordinary browsing.
it('handles the scheme on the preview session and nowhere else', () => {
installDocPreviewProtocolHandler()
expect(previewSession.protocol.handle).toHaveBeenCalledWith(
'orca-preview',
handleDocPreviewRequest
)
expect(previewSession.webRequest.onBeforeRequest).toHaveBeenCalled()
expect(defaultSession.protocol.handle).not.toHaveBeenCalled()
expect(defaultSession.webRequest.onBeforeRequest).not.toHaveBeenCalled()
})
it('cancels every request the preview session should never carry', () => {
installDocPreviewProtocolHandler()
const filter = previewSession.webRequest.onBeforeRequest.mock.calls.at(-1)?.[0] as (
details: { url: string },
callback: (response: { cancel: boolean }) => void
) => void
const cancelled = (url: string): boolean => {
let response: { cancel: boolean } | null = null
filter({ url }, (value) => {
response = value
})
return (response as { cancel: boolean } | null)?.cancel === true
}
expect(cancelled('https://cdn.example.com/tracker.js')).toBe(true)
expect(cancelled(`orca-preview://${'a'.repeat(32)}/index.html`)).toBe(false)
})
// Why: preview guests are webviews like any other, so they must not skip the deny-by-default
// permission and display-media policy every browser partition gets.
it('applies the shared browser partition policies to the preview session', () => {
installDocPreviewProtocolHandler()
expect(mocks.installBrowserSessionPartitionPolicies).toHaveBeenCalledWith(
expect.objectContaining({ partition: 'orca-doc-preview', userAgentMode: 'clean' }),
expect.anything()
)
})
// Why downloads and nothing else: the browser download flow attributes a file to the page that
// asked for it, and a previewed document is no page. Routed, it would write remote-authored bytes
// into this desktop's Downloads folder with no prompt and no tab to name as the source.
it('asks for downloads to be denied on the preview partition', () => {
installDocPreviewProtocolHandler()
expect(mocks.installBrowserSessionPartitionPolicies).toHaveBeenCalledWith(expect.anything(), {
downloads: 'deny'
})
})
})
describe('registerDocPreviewSchemePrivileges', () => {
// Why the whole literal and not a subset: every privilege this scheme does not claim is one the
// document cannot use to escape it. `allowServiceWorkers` would outlive the tab that was granted
// the read, and `bypassCSP` would undo the self-only policy the handler serves with.
it('claims exactly the privileges the preview document needs', () => {
registerDocPreviewSchemePrivileges()
expect(protocol.registerSchemesAsPrivileged).toHaveBeenCalledWith([
{
scheme: 'orca-preview',
privileges: {
standard: true,
secure: true,
supportFetchAPI: true,
corsEnabled: true,
stream: true
}
}
])
})
})
describe('isAllowedDocPreviewRequestUrl', () => {
// Why: the session refuses to carry the request at all, so a CSP bypass in one element type
// still reaches nothing off-machine.
it('admits in-document schemes and refuses everything that leaves the machine', () => {
expect(isAllowedDocPreviewRequestUrl(`orca-preview://${'a'.repeat(32)}/index.html`)).toBe(true)
expect(isAllowedDocPreviewRequestUrl('devtools://devtools/bundled/inspector.html')).toBe(true)
expect(isAllowedDocPreviewRequestUrl('data:image/png;base64,AAA')).toBe(true)
expect(isAllowedDocPreviewRequestUrl('blob:orca-preview://abc/123')).toBe(true)
expect(isAllowedDocPreviewRequestUrl('https://cdn.example.com/app.css')).toBe(false)
expect(isAllowedDocPreviewRequestUrl('http://127.0.0.1:9999/exfil')).toBe(false)
expect(isAllowedDocPreviewRequestUrl('ws://evil.example.com/socket')).toBe(false)
expect(isAllowedDocPreviewRequestUrl('file:///etc/passwd')).toBe(false)
})
})
describe('isDocPreviewSession', () => {
it('claims no session until the preview session has been materialized', () => {
expect(isDocPreviewSession({} as never)).toBe(false)
})
it('matches only the memoized preview session', () => {
const created = getDocPreviewSession()
expect(isDocPreviewSession(created)).toBe(true)
expect(isDocPreviewSession({} as never)).toBe(false)
expect(getDocPreviewSession()).toBe(created)
})
})
+144
View File
@@ -0,0 +1,144 @@
import { protocol, session } from 'electron'
import {
DOC_PREVIEW_PARTITION,
DOC_PREVIEW_SCHEME,
parseDocPreviewUrl
} from '../../shared/doc-preview-scheme'
import { installBrowserSessionPartitionPolicies } from './browser-session-partition-policies'
import { readDocPreviewFile } from './doc-preview-file-reader'
import { publishDocPreviewFailure } from './doc-preview-failure-notice'
import { getDocPreviewGrant } from './doc-preview-grant-registry'
/** Must run before `app.whenReady()`; Electron freezes the privileged scheme table at ready. */
export function registerDocPreviewSchemePrivileges(): void {
protocol.registerSchemesAsPrivileged([
{
scheme: DOC_PREVIEW_SCHEME,
privileges: {
standard: true,
secure: true,
supportFetchAPI: true,
corsEnabled: true,
stream: true
}
}
])
}
let docPreviewSession: Electron.Session | null = null
/** Non-persistent session, so preview bytes never land in a browsing profile's storage. */
export function getDocPreviewSession(): Electron.Session {
docPreviewSession ??= session.fromPartition(DOC_PREVIEW_PARTITION)
return docPreviewSession
}
/** Pure identity check: every guest attach consults it, and none should materialize a session. */
export function isDocPreviewSession(candidate: Electron.Session): boolean {
return docPreviewSession !== null && candidate === docPreviewSession
}
/**
* Product decision, not a hardening default: previewed documents are agent-authored, so any
* outbound request they can make is an exfiltration channel for whatever else the page can read.
* Self-contained documents — inline CSS/JS/SVG and in-grant assets — render in full; a CDN
* stylesheet, font, script, or analytics beacon deliberately does not load.
*/
const DOC_PREVIEW_CONTENT_SECURITY_POLICY = [
"default-src 'self'",
"script-src 'self' 'unsafe-inline'",
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data:",
"font-src 'self' data:",
"connect-src 'self'",
"frame-src 'self'",
"object-src 'none'"
// Why no `webrtc 'block'` here, though a peer connection is exactly the outbound channel this
// policy is meant to close: Chromium 43-era answers that directive with "Unrecognized
// Content-Security-Policy directive 'webrtc'" and gathers candidates anyway, so listing it would
// read as a fence while fencing nothing. The guest's IP-handling policy is the one that holds —
// see `installDocPreviewGuestPolicy`.
].join('; ')
function notFound(message: string): Response {
return new Response(message, {
status: 404,
headers: { 'Content-Type': 'text/plain; charset=utf-8' }
})
}
export async function handleDocPreviewRequest(request: Request): Promise<Response> {
const target = parseDocPreviewUrl(request.url)
if (!target) {
return notFound('Not found')
}
const grant = getDocPreviewGrant(target.grantId)
if (!grant) {
// Why: a revoked or unknown grant is indistinguishable from a missing file by design — but the
// shell still needs to know, or the guest paints this body where the document should be.
publishDocPreviewFailure({
grantId: target.grantId,
relativePath: target.relativePath,
reason: 'unreadable'
})
return notFound('Not found')
}
const relativePath = target.relativePath || grant.entryRelativePath
const outcome = await readDocPreviewFile(grant, relativePath)
if (!outcome.ok) {
publishDocPreviewFailure({ grantId: target.grantId, relativePath, reason: outcome.reason })
return new Response(outcome.message, {
status: outcome.status,
headers: { 'Content-Type': 'text/plain; charset=utf-8' }
})
}
return new Response(new Uint8Array(outcome.bytes), {
status: 200,
headers: {
'Content-Type': outcome.contentType,
'Content-Security-Policy': DOC_PREVIEW_CONTENT_SECURITY_POLICY,
// Why: reload must re-read the workspace disk, so nothing may be cached.
'Cache-Control': 'no-store'
}
})
}
/** `data:`/`blob:` never leave the document; every other scheme would reach off-machine. */
export function isAllowedDocPreviewRequestUrl(url: string): boolean {
return (
url.startsWith(`${DOC_PREVIEW_SCHEME}://`) ||
url.startsWith('devtools://') ||
url.startsWith('data:') ||
url.startsWith('blob:')
)
}
export function installDocPreviewProtocolHandler(): void {
const previewSession = getDocPreviewSession()
if (previewSession.protocol.isProtocolHandled(DOC_PREVIEW_SCHEME)) {
return
}
previewSession.protocol.handle(DOC_PREVIEW_SCHEME, handleDocPreviewRequest)
// Why: the response CSP is the document's own promise to obey; this is the session refusing to
// carry the request at all, so a CSP bypass in one element type still reaches nothing.
previewSession.webRequest.onBeforeRequest((details, callback) => {
callback({ cancel: !isAllowedDocPreviewRequestUrl(details.url) })
})
// Why: preview guests are webviews like any other, so they inherit the same deny-by-default
// permission, display-media and user-agent policy every browser partition gets.
installBrowserSessionPartitionPolicies(
{
id: DOC_PREVIEW_PARTITION,
scope: 'isolated',
partition: DOC_PREVIEW_PARTITION,
label: 'Document preview',
source: null,
userAgentMode: 'clean'
},
// Why downloads are the one policy that does not carry over: the browser download flow needs a
// page to attribute the file to, and a previewed document is not one. Routed here it would
// reserve a name in this desktop's Downloads folder and write remote-authored bytes into it
// with nothing in the UI naming the tab that asked, and no prompt in front of it.
{ downloads: 'deny' }
)
}
@@ -2,7 +2,7 @@ import { EventEmitter } from 'node:events'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
windows: [] as { webContents: MockWebContents }[],
windows: [] as MockBrowserWindow[],
BrowserWindow: vi.fn(),
finishLoads: true
}))
@@ -50,6 +50,41 @@ vi.mock('./browser-session-registry', () => ({
}))
import { OffscreenBrowserBackend } from './offscreen-browser-backend'
import { installDocPreviewGuestPolicy, isWorkspaceDocPageId } from './doc-preview-guest-policy'
import { mintDocPreviewGrant } from './doc-preview-grant-registry'
import { buildDocPreviewUrl } from '../../shared/doc-preview-scheme'
/** The real door's answer, so the backend is tested against the refusal it will actually get. */
function registerOffscreenGuestLikeBrowserManager({
browserPageId
}: {
browserPageId: string
}): boolean {
return !isWorkspaceDocPageId(browserPageId)
}
/**
* A page the document half of the registry really owns. Built rather than named, because the door
* refuses on registry membership now — a made-up id would be admitted and prove nothing.
*/
function registerWorkspaceDocPage(browserPageId: string): void {
const grant = mintDocPreviewGrant({
owner: { kind: 'ssh', connectionId: 'ssh-1' },
root: '/home/alice/docs',
entryRelativePath: 'index.html',
browserPageId
})
const guest = {
isFocused: () => false,
isDestroyed: () => false,
getURL: () => buildDocPreviewUrl(grant.id, grant.entryRelativePath),
on: vi.fn(),
once: vi.fn(),
setWindowOpenHandler: vi.fn(),
setWebRTCIPHandlingPolicy: vi.fn()
}
installDocPreviewGuestPolicy(guest as never, { id: 1, send: vi.fn() })
}
describe('OffscreenBrowserBackend lifecycle', () => {
beforeEach(() => {
@@ -73,7 +108,7 @@ describe('OffscreenBrowserBackend lifecycle', () => {
vi.useFakeTimers()
mocks.finishLoads = false
const browserManager = {
registerOffscreenGuest: vi.fn(),
registerOffscreenGuest: vi.fn(registerOffscreenGuestLikeBrowserManager),
unregisterGuest: vi.fn()
}
const backend = new OffscreenBrowserBackend(browserManager as never)
@@ -94,9 +129,36 @@ describe('OffscreenBrowserBackend lifecycle', () => {
vi.useRealTimers()
})
// Why the unregister assertion and not just the destroy: a refused id is one the document half
// of the registry owns, and the teardown hook would cancel that preview's work on the way out.
it('destroys a window whose registration was refused without unregistering the id', async () => {
const browserManager = {
registerOffscreenGuest: vi.fn(registerOffscreenGuestLikeBrowserManager),
unregisterGuest: vi.fn()
}
const backend = new OffscreenBrowserBackend(browserManager as never)
registerWorkspaceDocPage('doc-page-1')
await expect(
backend.createTab({
browserPageId: 'doc-page-1',
url: 'https://example.com',
worktreeId: 'wt'
})
).rejects.toThrow('was refused')
expect(mocks.windows[0].isDestroyed()).toBe(true)
expect(browserManager.unregisterGuest).not.toHaveBeenCalled()
// Why shutdown and not the map: a refused id left behind is invisible until teardown walks it
// and hands that id to the other authority after all.
await backend.destroyAll()
expect(browserManager.unregisterGuest).not.toHaveBeenCalled()
})
it('unregisters a closing page before awaiting owner retirement', async () => {
const browserManager = {
registerOffscreenGuest: vi.fn(),
registerOffscreenGuest: vi.fn(registerOffscreenGuestLikeBrowserManager),
unregisterGuest: vi.fn()
}
let releaseOwnerRetirement!: () => void
@@ -126,7 +188,7 @@ describe('OffscreenBrowserBackend lifecycle', () => {
it('preserves a replacement page when the old window finishes closing', async () => {
const browserManager = {
registerOffscreenGuest: vi.fn(),
registerOffscreenGuest: vi.fn(registerOffscreenGuestLikeBrowserManager),
unregisterGuest: vi.fn()
}
let releaseOwnerRetirement!: () => void
@@ -152,7 +214,7 @@ describe('OffscreenBrowserBackend lifecycle', () => {
it('retires the helper when an offscreen renderer is destroyed unexpectedly', async () => {
const browserManager = {
registerOffscreenGuest: vi.fn(),
registerOffscreenGuest: vi.fn(registerOffscreenGuestLikeBrowserManager),
unregisterGuest: vi.fn()
}
const onPageClosed = vi.fn(async () => {})
@@ -167,7 +229,7 @@ describe('OffscreenBrowserBackend lifecycle', () => {
it('cleans every helper owner during backend shutdown', async () => {
const browserManager = {
registerOffscreenGuest: vi.fn(),
registerOffscreenGuest: vi.fn(registerOffscreenGuestLikeBrowserManager),
unregisterGuest: vi.fn()
}
const onPageClosed = vi.fn(async () => {})
@@ -186,7 +248,7 @@ describe('OffscreenBrowserBackend lifecycle', () => {
it('rejects a concurrent create while shutdown is draining owned pages', async () => {
const browserManager = {
registerOffscreenGuest: vi.fn(),
registerOffscreenGuest: vi.fn(registerOffscreenGuestLikeBrowserManager),
unregisterGuest: vi.fn()
}
let releaseOwnerRetirement!: () => void
@@ -215,7 +277,7 @@ describe('OffscreenBrowserBackend lifecycle', () => {
it('joins owner retirement started by an unexpected renderer destroy', async () => {
const browserManager = {
registerOffscreenGuest: vi.fn(),
registerOffscreenGuest: vi.fn(registerOffscreenGuestLikeBrowserManager),
unregisterGuest: vi.fn()
}
let releaseOwnerRetirement!: () => void
@@ -244,7 +306,7 @@ describe('OffscreenBrowserBackend lifecycle', () => {
it('bounds concurrent helper retirements during shutdown', async () => {
const browserManager = {
registerOffscreenGuest: vi.fn(),
registerOffscreenGuest: vi.fn(registerOffscreenGuestLikeBrowserManager),
unregisterGuest: vi.fn()
}
let activeRetirements = 0
@@ -284,7 +346,10 @@ describe('OffscreenBrowserBackend lifecycle', () => {
})
it('closes the page even when daemon retirement throws', async () => {
const browserManager = { registerOffscreenGuest: vi.fn(), unregisterGuest: vi.fn() }
const browserManager = {
registerOffscreenGuest: vi.fn(registerOffscreenGuestLikeBrowserManager),
unregisterGuest: vi.fn()
}
const backend = new OffscreenBrowserBackend(browserManager as never, {
getAgentBrowserBridge: () => ({
onPageClosed: vi.fn(async () => {
+27 -17
View File
@@ -66,9 +66,33 @@ export class OffscreenBrowserBackend implements BrowserBackend {
this.windowsByPageId.set(browserPageId, win)
// Why: if the offscreen window is destroyed out from under us (crash, app
// teardown), drop the registry entry so commands fail cleanly instead of
// resolving a dead WebContents.
// Why: register the guest and return immediately so the new tab appears
// without waiting for the page to finish loading. Previously createTab
// awaited the full navigation, so clicking "New Browser Tab" did nothing for
// up to a second on real URLs. The page loads asynchronously and streams
// once it paints; a failed load leaves the (usable) tab open, matching how a
// normal browser tab survives a failed navigation.
const registered = this.browserManager.registerOffscreenGuest({
browserPageId,
worktreeId: params.worktreeId,
sessionProfileId: profile?.id ?? null,
userAgentMode: profile?.userAgentMode,
webContentsId: win.webContents.id
})
if (!registered) {
// Why destroy rather than carry on: the window already exists but carries none of the guest
// policies registration installs, so leaving it would navigate an unvalidated URL with no
// policy on it and hand back a page id nothing can drive. The renderer door aborts its mount
// the same way; this is that abort.
this.windowsByPageId.delete(browserPageId)
win.destroy()
throw new Error(`Browser page ${browserPageId} was refused`)
}
// Why only once registration took: this teardown unregisters the page id, and a refused id is
// one the other authority may own — cancelling its work would be the confusion we just refused.
// Why at all: if the window dies out from under us (crash, app teardown), drop the registry
// entry so commands fail cleanly instead of resolving a dead WebContents.
win.webContents.once('destroyed', () => {
// Explicit close removes the page first and performs awaited cleanup;
// only an unexpected destruction still owns the bridge retirement here.
@@ -80,20 +104,6 @@ export class OffscreenBrowserBackend implements BrowserBackend {
this.browserManager.unregisterGuest(browserPageId)
})
// Why: register the guest and return immediately so the new tab appears
// without waiting for the page to finish loading. Previously createTab
// awaited the full navigation, so clicking "New Browser Tab" did nothing for
// up to a second on real URLs. The page loads asynchronously and streams
// once it paints; a failed load leaves the (usable) tab open, matching how a
// normal browser tab survives a failed navigation.
this.browserManager.registerOffscreenGuest({
browserPageId,
worktreeId: params.worktreeId,
sessionProfileId: profile?.id ?? null,
userAgentMode: profile?.userAgentMode,
webContentsId: win.webContents.id
})
const url = params.url || 'about:blank'
void this.loadUrl(win, url).catch((error) => {
console.warn(
+6 -4
View File
@@ -539,12 +539,14 @@ describe('ClaudeHookService.installRemote', () => {
expect(script!.indexOf('printf "{}\\n"')).toBe(
script!.indexOf('#!/bin/sh') + '#!/bin/sh\n'.length
)
// Why: payload is piped to curl via stdin (`payload@-`) so it never lands
// on the curl command line (EDR oversized-command-line false positive),
// matching the Windows curl.exe hook post.
// Why: payload stays on stdin, while metadata headers avoid URL-encoded IDS signatures.
expect(script).toContain('printf \'%s\' "$payload" | curl')
expect(script).toContain('-H "Content-Type: application/json"')
expect(script).toContain('orca_hook_metadata=$(printf')
expect(script).toContain('unset ORCA_AGENT_HOOK_TRANSPORT')
expect(script).toContain('-H "X-Orca-Agent-Hook-Meta: ${orca_hook_metadata}"')
expect(script).toContain('--data-binary @-')
expect(script).toContain('--data-urlencode "payload@-"')
expect(script).not.toContain('--data-urlencode "payload=${payload}"')
expect(fs.modes.get('/home/dev/.orca/agent-hooks/claude-hook.sh')).toBe(0o755)
// Why: no remote statusLine — this path serves SSH remotes and WSL guests, whose relay
// listener doesn't route /statusline/claude and whose accounts aren't attributable locally.
+6 -13
View File
@@ -9,6 +9,7 @@ import {
writeManagedScript,
type HooksConfig
} from '../agent-hooks/installer-utils'
import { buildPosixAgentHookPostCommand } from '../agent-hooks/hook-post-command'
import {
readHooksJsonRemote,
writeHooksJsonRemote,
@@ -114,25 +115,17 @@ function getManagedScript(
// Why: refresh endpoint coordinates for PTYs surviving an Orca restart.
// Why: suppress parse errors so they neither leak nor trip outer set -e.
'if [ -n "$ORCA_AGENT_HOOK_ENDPOINT" ] && [ -r "$ORCA_AGENT_HOOK_ENDPOINT" ]; then',
' unset ORCA_AGENT_HOOK_TRANSPORT',
' . "$ORCA_AGENT_HOOK_ENDPOINT" 2>/dev/null || :',
'fi',
'if [ -z "$ORCA_AGENT_HOOK_PORT" ] || [ -z "$ORCA_AGENT_HOOK_TOKEN" ] || [ -z "$ORCA_PANE_KEY" ]; then',
' spool_hook_event',
' exit 0',
'fi',
// Why: post form fields because path-bearing payloads are unsafe in hand-built JSON.
// Why: pipe payload to curl stdin to keep large output off the command line.
'printf \'%s\' "$payload" | curl -sS -X POST "http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/claude" \\',
' --connect-timeout 0.5 --max-time 1.5 \\',
' -H "Content-Type: application/x-www-form-urlencoded" \\',
' -H "X-Orca-Agent-Hook-Token: ${ORCA_AGENT_HOOK_TOKEN}" \\',
' --data-urlencode "paneKey=${ORCA_PANE_KEY}" \\',
' --data-urlencode "tabId=${ORCA_TAB_ID}" \\',
' --data-urlencode "launchToken=${ORCA_AGENT_LAUNCH_TOKEN}" \\',
' --data-urlencode "worktreeId=${ORCA_WORKTREE_ID}" \\',
' --data-urlencode "env=${ORCA_AGENT_HOOK_ENV}" \\',
' --data-urlencode "version=${ORCA_AGENT_HOOK_VERSION}" \\',
' --data-urlencode "payload@-" >/dev/null 2>&1 || spool_hook_event',
// Why: keep full hook JSON off the command line and avoid IDS-friendly URL-encoded paths.
...buildPosixAgentHookPostCommand('claude').map((line, index, lines) =>
index === lines.length - 1 ? `${line} >/dev/null 2>&1 || spool_hook_event` : line
),
'exit 0',
''
].join('\n')
@@ -108,4 +108,36 @@ describe('runExclusivelyForCodexTrustConfig', () => {
await queued
expect(secondStarted).toBe(true)
})
it('coalesces WSL UNC aliases without folding the case-sensitive Linux path', async () => {
const aliasGate = deferred()
let aliasStarted = false
const blockedAlias = runExclusivelyForCodexTrustConfig(
String.raw`\\wsl.localhost\Ubuntu\home\Alice\.codex\config.toml`,
() => aliasGate.promise
)
const queuedAlias = runExclusivelyForCodexTrustConfig(
String.raw`\\wsl$\ubuntu\home\Alice\.codex\config.toml`,
() => {
aliasStarted = true
return Promise.resolve()
}
)
await Promise.resolve()
expect(aliasStarted).toBe(false)
let distinctStarted = false
await runExclusivelyForCodexTrustConfig(
String.raw`\\wsl.localhost\Ubuntu\home\alice\.codex\config.toml`,
async () => {
distinctStarted = true
}
)
expect(distinctStarted).toBe(true)
aliasGate.resolve()
await blockedAlias
await queuedAlias
expect(aliasStarted).toBe(true)
})
})
@@ -16,6 +16,7 @@ import {
type CodexTrustEntry
} from './config-toml-trust'
import {
CodexHookService,
_internals,
createCodexWslRuntimeHookInstallPlan,
type CodexWslRuntimeHookInstallPlan
@@ -82,7 +83,67 @@ function expectedManagedCommand(scriptPath: string): string {
}
describe('Codex WSL runtime hook install', () => {
it('plans WSL hook files with Linux command and trust paths', async () => {
it('coalesces aliases of one runtime home without blocking independent homes', async () => {
const service = new CodexHookService()
const releases: (() => void)[] = []
const started: string[] = []
vi.spyOn(service, 'installForRuntimeHome').mockImplementation(async (runtimeHomePath) => {
started.push(runtimeHomePath!)
await new Promise<void>((resolve) => releases.push(resolve))
return null
})
const firstHome = '\\\\wsl$\\Ubuntu\\home\\Alice\\.local\\share\\orca\\codex-runtime-home\\home'
const alias = firstHome.replace('\\\\wsl$', '\\\\wsl.localhost')
const independent = firstHome.replace('\\Alice\\', '\\Bob\\')
const first = service.installForRuntimeHomeSerialized(firstHome)
const second = service.installForRuntimeHomeSerialized(alias)
const third = service.installForRuntimeHomeSerialized(independent)
await vi.waitFor(() => expect(started).toEqual([firstHome, independent]))
expect(second).toBe(first)
releases.splice(0).forEach((release) => release())
await Promise.all([first, second, third])
expect(started).toEqual([firstHome, independent])
})
it('does not coalesce one drive-backed home across WSL distros', async () => {
const service = new CodexHookService()
const started: string[] = []
vi.spyOn(service, 'installForRuntimeHome').mockImplementation(async (_runtimeHome, target) => {
started.push(target?.wslDistro ?? '')
return null
})
const home = 'D:\\wsl-home\\.local\\share\\orca\\codex-runtime-home\\home'
await Promise.all([
service.installForRuntimeHomeSerialized(home, { runtime: 'wsl', wslDistro: 'Ubuntu' }),
service.installForRuntimeHomeSerialized(home, { runtime: 'wsl', wslDistro: 'Debian' })
])
expect(started).toEqual(['Ubuntu', 'Debian'])
})
it('keeps case-distinct Linux runtime homes on separate queues', async () => {
const service = new CodexHookService()
const started: string[] = []
vi.spyOn(service, 'installForRuntimeHome').mockImplementation(async (runtimeHomePath) => {
started.push(runtimeHomePath!)
return null
})
const upper =
'\\\\wsl.localhost\\Ubuntu\\home\\Alice\\.local\\share\\orca\\codex-runtime-home\\home'
const lower = upper.replace('\\Alice\\', '\\alice\\')
await Promise.all([
service.installForRuntimeHomeSerialized(upper),
service.installForRuntimeHomeSerialized(lower)
])
expect(started).toEqual([upper, lower])
})
it('plans WSL hook files with Linux command and trust paths', () => {
const runtimeHome =
'\\\\wsl.localhost\\Ubuntu\\home\\alice\\.local\\share\\orca\\codex-runtime-home\\home'
@@ -275,11 +336,15 @@ describe('Codex WSL runtime hook install', () => {
it('generates a POSIX hook that bridges WSL loopback failures through Windows curl', async () => {
const script = _internals.getManagedScript('posix')
expect(script).toContain('load_hook_endpoint()')
expect(script).toContain('unset ORCA_AGENT_HOOK_TRANSPORT')
expect(script).toContain('"set ORCA_AGENT_HOOK_TOKEN="*)')
expect(script).toContain('post_codex_hook()')
expect(script).toContain('is_wsl_runtime()')
expect(script).toContain('WSL_DISTRO_NAME')
expect(script).toContain('windows_curl=$(command -v curl.exe 2>/dev/null || true)')
expect(script).toContain('-H "Content-Type: application/json"')
expect(script).toContain('-H "X-Orca-Agent-Hook-Meta-Encoding: base64"')
expect(script).toContain('--data-binary @-')
expect(script).toContain('--data-urlencode "payload@-"')
expect(script).toContain('if post_codex_hook curl >/dev/null 2>&1; then')
expect(script).toContain('post_codex_hook "$windows_curl" 3 5 >/dev/null 2>&1 || true')
@@ -302,6 +367,7 @@ describe('Codex WSL runtime hook install', () => {
'set ORCA_AGENT_HOOK_TOKEN=fresh-token',
'set ORCA_AGENT_HOOK_ENV=development',
'set ORCA_AGENT_HOOK_VERSION=1',
'set ORCA_AGENT_HOOK_TRANSPORT=raw-json-v1',
''
].join('\r\n'),
'utf-8'
@@ -333,6 +399,7 @@ describe('Codex WSL runtime hook install', () => {
const posted = readFileSync(capturePath, 'utf-8')
expect(posted).toContain('http://127.0.0.1:43210/hook/codex')
expect(posted).toContain('X-Orca-Agent-Hook-Token: fresh-token')
expect(posted).toContain('Content-Type: application/json')
expect(posted).not.toContain('stale-token')
}
)
+47 -18
View File
@@ -19,6 +19,7 @@ import {
writeManagedScript,
type HookDefinition
} from '../agent-hooks/installer-utils'
import { buildPosixAgentHookPostCommand } from '../agent-hooks/hook-post-command'
import { refreshManagedScriptIfPresent } from '../agent-hooks/managed-hook-script-refresh'
import { resolveHooksJsonWritePath } from '../agent-hooks/hook-config-write-path'
import { writeFileAtomically } from '../codex-accounts/fs-utils'
@@ -823,6 +824,7 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string {
// Why: sourcing refreshes PORT/TOKEN/ENV/VERSION from the current Orca so a surviving PTY keeps reporting after a restart (see claude/hook-service.ts).
'load_hook_endpoint() {',
' endpoint_path="$1"',
' unset ORCA_AGENT_HOOK_TRANSPORT',
' case "$endpoint_path" in',
' *.cmd)',
// Why: Windows passes endpoint.cmd into WSL via WSLENV; parse only Orca's known assignments since cmd.exe `set` lines aren't shell syntax.
@@ -834,6 +836,7 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string {
' "set ORCA_AGENT_HOOK_TOKEN="*) ORCA_AGENT_HOOK_TOKEN=${endpoint_line#*=} ;;',
' "set ORCA_AGENT_HOOK_ENV="*) ORCA_AGENT_HOOK_ENV=${endpoint_line#*=} ;;',
' "set ORCA_AGENT_HOOK_VERSION="*) ORCA_AGENT_HOOK_VERSION=${endpoint_line#*=} ;;',
' "set ORCA_AGENT_HOOK_TRANSPORT="*) ORCA_AGENT_HOOK_TRANSPORT=${endpoint_line#*=} ;;',
' esac',
' done < "$endpoint_path"',
' ;;',
@@ -853,20 +856,11 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string {
' curl_bin="$1"',
' connect_timeout="${2:-0.5}"',
' max_time="${3:-1.5}"',
// Why: worktreeId embeds a path, so hand-building JSON in shell is unsafe with quotes/newlines; post raw payload plus metadata as form fields instead.
// Why: pipe payload to curl's stdin (`payload@-`) not an inline arg, so tens-of-KB tool output stays off the command line (EDR false positives).
' printf \'%s\' "$payload" | "$curl_bin" -sS -X POST "http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/codex" \\',
' --connect-timeout "$connect_timeout" --max-time "$max_time" \\',
' --noproxy "127.0.0.1" \\',
' -H "Content-Type: application/x-www-form-urlencoded" \\',
' -H "X-Orca-Agent-Hook-Token: ${ORCA_AGENT_HOOK_TOKEN}" \\',
' --data-urlencode "paneKey=${ORCA_PANE_KEY}" \\',
' --data-urlencode "tabId=${ORCA_TAB_ID}" \\',
' --data-urlencode "launchToken=${ORCA_AGENT_LAUNCH_TOKEN}" \\',
' --data-urlencode "worktreeId=${ORCA_WORKTREE_ID}" \\',
' --data-urlencode "env=${ORCA_AGENT_HOOK_ENV}" \\',
' --data-urlencode "version=${ORCA_AGENT_HOOK_VERSION}" \\',
' --data-urlencode "payload@-"',
// Why: keep full hook JSON off the command line and avoid URL-encoding paths/commands into IDS-friendly traversal signatures.
...buildPosixAgentHookPostCommand('codex', {
curlCommand: '"$curl_bin"',
indent: ' '
}).map((line) => ` ${line}`),
'}',
'is_wsl_runtime() {',
' [ -n "$WSL_DISTRO_NAME" ] && return 0',
@@ -1081,6 +1075,7 @@ export class CodexHookService {
}
private readonly wslReconciliationGeneration = new Map<string, number>()
private readonly wslInstallsInFlight = new Map<string, Promise<AgentHookInstallStatus | null>>()
private supersedeWslReconciliation(runtimeHomePath: string | null | undefined): number {
if (!runtimeHomePath) {
@@ -1177,6 +1172,30 @@ export class CodexHookService {
}
}
installForRuntimeHomeSerialized(
runtimeHomePath: string | null | undefined,
target?: CodexWslRuntimeHookTarget
): Promise<AgentHookInstallStatus | null> {
if (!runtimeHomePath) {
return Promise.resolve(null)
}
const targetKey = target?.runtime === 'wsl' ? target.wslDistro?.trim().toLowerCase() : ''
const key = `${getWslReconciliationKey(runtimeHomePath)}\0${targetKey ?? ''}`
const active = this.wslInstallsInFlight.get(key)
if (active) {
return active
}
const install = this.installForRuntimeHome(runtimeHomePath, target)
this.wslInstallsInFlight.set(key, install)
const clear = (): void => {
if (this.wslInstallsInFlight.get(key) === install) {
this.wslInstallsInFlight.delete(key)
}
}
void install.then(clear, clear)
return install
}
refreshRuntimeUserHooksForRuntimeHome(
runtimeHomePath: string | null | undefined,
target?: CodexWslRuntimeHookTarget
@@ -1492,7 +1511,13 @@ export class CodexHookService {
options?.codexHomeDir?.replace(/\/$/, '') ?? `${remoteHome.replace(/\/$/, '')}/.codex`
const remoteConfigPath = `${codexHomeBase}/hooks.json`
const remoteTomlPath = `${codexHomeBase}/config.toml`
const remoteScriptPath = `${remoteHome.replace(/\/$/, '')}/.orca/agent-hooks/codex-hook.sh`
// Redirected WSL homes must use the same script location and command shape
// as the runtime installer; two representations of one hooks.json race
// into stale trust keys. Plain SSH keeps its guest-home script contract.
const redirectedCodexHome = options?.codexHomeDir?.replace(/\/$/, '')
const remoteScriptPath = redirectedCodexHome
? `${redirectedCodexHome}/.orca/agent-hooks/codex-hook.sh`
: `${remoteHome.replace(/\/$/, '')}/.orca/agent-hooks/codex-hook.sh`
try {
const config = await readHooksJsonRemote(sftp, remoteConfigPath)
if (!config) {
@@ -1505,7 +1530,9 @@ export class CodexHookService {
}
}
const command = wrapPosixHookCommand(remoteScriptPath)
const command = redirectedCodexHome
? wrapReadablePosixHookCommand(remoteScriptPath)
: wrapPosixHookCommand(remoteScriptPath)
const nextHooks = { ...config.hooks }
const managedEvents = new Set<string>(CODEX_EVENTS)
const isManagedCommand = createManagedCommandMatcher('codex-hook.sh')
@@ -1529,11 +1556,13 @@ export class CodexHookService {
const definition: HookDefinition = {
hooks: [buildManagedCommandHook(command)]
}
nextHooks[eventName] = [...cleaned, definition]
nextHooks[eventName] = redirectedCodexHome
? [definition, ...cleaned]
: [...cleaned, definition]
trustEntries.push({
sourcePath: remoteConfigPath,
eventLabel: CODEX_EVENT_LABEL[eventName],
groupIndex: cleaned.length,
groupIndex: redirectedCodexHome ? 0 : cleaned.length,
handlerIndex: 0,
command,
timeoutSec: MANAGED_HOOK_TIMEOUT_SECONDS
@@ -2,10 +2,25 @@ import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
const { wslRealpathMock } = vi.hoisted(() => ({
wslRealpathMock: vi.fn(async (path: string) => path)
}))
vi.mock('node:fs/promises', () => ({ realpath: wslRealpathMock }))
import {
prepareManagedCodexHomeBeforeShellLaunch,
resolveManagedCodexShellPreflightHome
} from './managed-home-shell-preflight'
import {
prepareManagedWslCodexHomeBeforeShellLaunch,
resolveManagedWslCodexShellPreflightTarget
} from './managed-wsl-home-shell-preflight'
import {
_internals as managedWslHomeRegistryInternals,
recordManagedWslCodexHome
} from './managed-wsl-codex-home-registry'
const roots: string[] = []
@@ -16,6 +31,8 @@ function makeRoot(): string {
}
afterEach(() => {
wslRealpathMock.mockClear()
managedWslHomeRegistryInternals.clearRecordedManagedWslCodexHomes()
for (const root of roots.splice(0)) {
rmSync(root, { recursive: true, force: true })
}
@@ -160,3 +177,123 @@ describe('managed Codex shell preflight', () => {
).toBeNull()
})
})
describe('managed WSL Codex shell preflight', () => {
const home = '/home/jin/.local/share/orca/codex-runtime-home/home'
const runtimeHome =
'\\\\wsl.localhost\\Ubuntu-24.04\\home\\jin\\.local\\share\\orca\\codex-runtime-home\\home'
const env = {
CODEX_HOME: home,
ORCA_CODEX_HOME: home,
WSL_DISTRO_NAME: 'Ubuntu-24.04'
}
it('targets the pane-selected managed home through its UNC twin', () => {
recordManagedWslCodexHome('Ubuntu-24.04', runtimeHome)
expect(resolveManagedWslCodexShellPreflightTarget(env)).toEqual({
runtimeHomePath: runtimeHome,
wslDistro: 'Ubuntu-24.04'
})
})
it('reconstructs a validated managed home when a restarted runtime has no record', () => {
expect(resolveManagedWslCodexShellPreflightTarget(env)).toEqual({
runtimeHomePath: runtimeHome,
wslDistro: 'Ubuntu-24.04'
})
})
it('installs once through the WSL runtime-home lane while hooks are enabled', async () => {
recordManagedWslCodexHome('Ubuntu-24.04', runtimeHome)
const status = {
agent: 'codex' as const,
state: 'installed' as const,
configPath: 'config.toml',
managedHooksPresent: true,
detail: null
}
const install = vi.fn(() => status)
await expect(
prepareManagedWslCodexHomeBeforeShellLaunch({ env, hooksEnabled: true, install })
).resolves.toBe(status)
expect(install).toHaveBeenCalledExactlyOnceWith(runtimeHome, {
runtime: 'wsl',
wslDistro: 'Ubuntu-24.04'
})
await expect(
prepareManagedWslCodexHomeBeforeShellLaunch({ env, hooksEnabled: false, install })
).resolves.toBeNull()
expect(install).toHaveBeenCalledTimes(1)
})
it.each([
[
'a user home',
{ ...env, CODEX_HOME: '/home/jin/.codex', ORCA_CODEX_HOME: '/home/jin/.codex' }
],
['unequal routing markers', { ...env, ORCA_CODEX_HOME: `${home}-other` }],
[
'a parent traversal',
{
...env,
CODEX_HOME: `/home/jin/../jin${home.slice('/home/jin'.length)}`,
ORCA_CODEX_HOME: `/home/jin/../jin${home.slice('/home/jin'.length)}`
}
],
[
'a dot segment',
{
...env,
CODEX_HOME: `/home/./jin${home.slice('/home/jin'.length)}`,
ORCA_CODEX_HOME: `/home/./jin${home.slice('/home/jin'.length)}`
}
],
[
'a host path',
{ ...env, CODEX_HOME: 'C:\\Users\\jin\\.codex', ORCA_CODEX_HOME: 'C:\\Users\\jin\\.codex' }
],
['a missing distro', { ...env, WSL_DISTRO_NAME: '' }],
['a distro path escape', { ...env, WSL_DISTRO_NAME: 'Ubuntu\\..\\host' }],
[
'a repeated separator',
{
...env,
CODEX_HOME: home.replace('/jin/', '/jin//'),
ORCA_CODEX_HOME: home.replace('/jin/', '/jin//')
}
]
])('rejects %s', (_label, candidate) => {
recordManagedWslCodexHome('Ubuntu-24.04', runtimeHome)
expect(resolveManagedWslCodexShellPreflightTarget(candidate)).toBeNull()
})
it('preserves a recorded runtime spelling for a managed account home', () => {
const directHome = '/home/jin/.local/share/orca/codex-accounts/account-1/home'
const directRuntimeHome =
'\\\\wsl$\\Ubuntu-24.04\\home\\jin\\.local\\share\\orca\\codex-accounts\\account-1\\home'
recordManagedWslCodexHome('Ubuntu-24.04', directRuntimeHome)
expect(
resolveManagedWslCodexShellPreflightTarget({
CODEX_HOME: directHome,
ORCA_CODEX_HOME: directHome,
WSL_DISTRO_NAME: 'ubuntu-24.04'
})
).toEqual({ runtimeHomePath: directRuntimeHome, wslDistro: 'ubuntu-24.04' })
})
it('never authorizes a WSL system home even when it was offered by a PTY lane', () => {
const systemHome = '/home/jin/.codex'
recordManagedWslCodexHome('Ubuntu-24.04', '\\\\wsl.localhost\\Ubuntu-24.04\\home\\jin\\.codex')
expect(
resolveManagedWslCodexShellPreflightTarget({
CODEX_HOME: systemHome,
ORCA_CODEX_HOME: systemHome,
WSL_DISTRO_NAME: 'Ubuntu-24.04'
})
).toBeNull()
})
})
@@ -0,0 +1,115 @@
import { win32 as pathWin32 } from 'node:path'
import {
foldWslUncPathCaseInsensitiveParts,
parseWslUncPath,
toWindowsWslPath
} from '../../shared/wsl-paths'
type ManagedWslCodexHome = {
runtimeHomePath: string
linuxHomePath: string
}
const managedHomesByDistro = new Map<string, Map<string, ManagedWslCodexHome>>()
function distroKey(distro: string): string {
return distro.trim().toLowerCase()
}
export function isAbsolutePosixPathWithoutDotSegments(value: string): boolean {
const segments = value.split('/').slice(1)
return (
value.startsWith('/') &&
!value.startsWith('//') &&
!value.includes('\\') &&
segments.every((segment) => segment && segment !== '.' && segment !== '..')
)
}
function isOrcaManagedWslCodexHome(linuxHomePath: string): boolean {
const segments = linuxHomePath.split('/').filter(Boolean)
const orcaIndex = segments.findIndex(
(segment, index) =>
segment === 'orca' && segments[index - 1] === 'share' && segments[index - 2] === '.local'
)
if (orcaIndex === -1) {
return false
}
const tail = segments.slice(orcaIndex + 1)
return (
(tail.length === 2 && tail[0] === 'codex-runtime-home' && tail[1] === 'home') ||
(tail.length === 3 && tail[0] === 'codex-accounts' && Boolean(tail[1]) && tail[2] === 'home')
)
}
function linuxHomeForRuntimePath(runtimeHomePath: string, distro: string): string | null {
const wsl = parseWslUncPath(runtimeHomePath)
if (wsl) {
return wsl.distro.toLowerCase() === distroKey(distro) ? wsl.linuxPath : null
}
const drive = runtimeHomePath.match(/^([A-Za-z]):[/\\](.*)$/)
return drive ? `/mnt/${drive[1].toLowerCase()}/${drive[2].replace(/\\/g, '/')}` : null
}
export function recordManagedWslCodexHome(distro: string, runtimeHomePath: string): void {
const normalizedDistro = distro.trim()
const linuxHomePath = linuxHomeForRuntimePath(runtimeHomePath, normalizedDistro)
if (
!normalizedDistro ||
/[\\/\r\n]/.test(normalizedDistro) ||
!linuxHomePath ||
!isAbsolutePosixPathWithoutDotSegments(linuxHomePath) ||
!isOrcaManagedWslCodexHome(linuxHomePath)
) {
return
}
const homes = managedHomesByDistro.get(distroKey(normalizedDistro)) ?? new Map()
homes.set(linuxHomePath, { runtimeHomePath, linuxHomePath })
managedHomesByDistro.set(distroKey(normalizedDistro), homes)
}
export function resolveRecordedManagedWslCodexHome(
distro: string,
linuxHomePath: string
): string | null {
if (
!distro.trim() ||
/[\\/\r\n]/.test(distro) ||
!isAbsolutePosixPathWithoutDotSegments(linuxHomePath)
) {
return null
}
return managedHomesByDistro.get(distroKey(distro))?.get(linuxHomePath)?.runtimeHomePath ?? null
}
export function resolveManagedWslCodexHome(distro: string, linuxHomePath: string): string | null {
if (
!distro.trim() ||
/[\\/\r\n]/.test(distro) ||
!isAbsolutePosixPathWithoutDotSegments(linuxHomePath) ||
!isOrcaManagedWslCodexHome(linuxHomePath)
) {
return null
}
return (
resolveRecordedManagedWslCodexHome(distro, linuxHomePath) ??
toWindowsWslPath(linuxHomePath, distro)
)
}
export function wslRuntimeHomePathsEqual(left: string | undefined, right: string): boolean {
if (!left) {
return false
}
const leftWsl = foldWslUncPathCaseInsensitiveParts(left)
const rightWsl = foldWslUncPathCaseInsensitiveParts(right)
if (leftWsl || rightWsl) {
return leftWsl !== null && leftWsl === rightWsl
}
return pathWin32.normalize(left).toLowerCase() === pathWin32.normalize(right).toLowerCase()
}
export const _internals = {
clearRecordedManagedWslCodexHomes: () => managedHomesByDistro.clear(),
isOrcaManagedWslCodexHome
}
@@ -0,0 +1,75 @@
import { realpath } from 'node:fs/promises'
import type { AgentHookInstallStatus } from '../../shared/agent-hook-types'
import { withTimeout } from '../../shared/promise-timeout-fallback'
import type { CodexWslRuntimeHookTarget } from './codex-wsl-hook-install-plan'
import { codexHookService } from './hook-service'
import {
isAbsolutePosixPathWithoutDotSegments,
resolveManagedWslCodexHome,
wslRuntimeHomePathsEqual
} from './managed-wsl-codex-home-registry'
type WslShellPreflightEnvironment = {
CODEX_HOME?: string
ORCA_CODEX_HOME?: string
WSL_DISTRO_NAME?: string
}
const WSL_MANAGED_HOME_REALPATH_TIMEOUT_MS = 5_000
export type ManagedWslCodexShellPreflightTarget = {
runtimeHomePath: string
wslDistro: string
}
export function resolveManagedWslCodexShellPreflightTarget(
env: WslShellPreflightEnvironment
): ManagedWslCodexShellPreflightTarget | null {
const codexHome = env.CODEX_HOME?.trim()
const orcaCodexHome = env.ORCA_CODEX_HOME?.trim()
const wslDistro = env.WSL_DISTRO_NAME?.trim()
if (
!codexHome ||
codexHome !== orcaCodexHome ||
!wslDistro ||
/[\\/\r\n]/.test(wslDistro) ||
!isAbsolutePosixPathWithoutDotSegments(codexHome)
) {
return null
}
const runtimeHomePath = resolveManagedWslCodexHome(wslDistro, codexHome)
return runtimeHomePath ? { runtimeHomePath, wslDistro } : null
}
export async function prepareManagedWslCodexHomeBeforeShellLaunch(args: {
env: WslShellPreflightEnvironment
hooksEnabled: boolean
install?: (
runtimeHomePath: string,
target: CodexWslRuntimeHookTarget
) => AgentHookInstallStatus | Promise<AgentHookInstallStatus | null> | null
}): Promise<AgentHookInstallStatus | null> {
if (!args.hooksEnabled) {
return null
}
const target = resolveManagedWslCodexShellPreflightTarget(args.env)
if (!target) {
return null
}
const realHome = await withTimeout(
realpath(target.runtimeHomePath),
WSL_MANAGED_HOME_REALPATH_TIMEOUT_MS,
null
)
if (!realHome || !wslRuntimeHomePathsEqual(realHome, target.runtimeHomePath)) {
return null
}
const install =
args.install ??
((home: string, hookTarget: CodexWslRuntimeHookTarget) =>
codexHookService.installForRuntimeHomeSerialized(home, hookTarget))
return await install(target.runtimeHomePath, {
runtime: 'wsl',
wslDistro: target.wslDistro
})
}
+15
View File
@@ -13,6 +13,7 @@ import {
type Tray,
session
} from 'electron'
import { applyMacPressAndHoldDefaultAtStartup } from './macos-press-and-hold-default'
import { initTccPromptNotice, stopTccPromptNotice } from './macos-tcc-prompt-notice'
import { electronApp, is } from '@electron-toolkit/utils'
import {
@@ -318,6 +319,11 @@ import { browserCertificateTrustController, browserManager } from './browser/bro
import { RpcDispatcher } from './runtime/rpc/dispatcher'
import { OffscreenBrowserBackend } from './browser/offscreen-browser-backend'
import { initializeBrowserSessionsForApp } from './browser/browser-session-startup'
import {
installDocPreviewProtocolHandler,
registerDocPreviewSchemePrivileges
} from './browser/doc-preview-protocol'
import { registerDocPreviewGrantHandlers } from './ipc/doc-preview-grant-ipc'
import { initializeBrowserClientHostId } from './browser/browser-client-host-id'
import { setUnreadDockBadgeCount } from './dock/unread-badge'
import { AutomationService } from './automations/service'
@@ -938,6 +944,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'),
@@ -958,6 +967,9 @@ if (hasSingleInstanceLock) {
if (shouldApplyPreReadyAppName(devInstanceIdentity)) {
app.setName(devInstanceIdentity.appName)
}
// Why: Electron freezes the privileged scheme table at ready, so the doc-preview
// scheme must be declared here or its webview loses fetch/secure-origin privileges.
registerDocPreviewSchemePrivileges()
// Why: must precede app.whenReady() so Crashpad is installed before the
// first renderer spawns; a CHECK before this point is still exit-code-only.
startCrashpadCapture()
@@ -2433,6 +2445,9 @@ void app.whenReady().then(async () => {
} catch {
console.warn('[proxy] Failed to apply network proxy settings')
}
// Why: the preview session is protocol-scoped, so the handler must exist before any preview webview attaches.
installDocPreviewProtocolHandler()
registerDocPreviewGrantHandlers()
// Why: browser sessions serve desktop webviews and runtime profile commands, so init at app startup rather than via a renderer IPC path.
initializeBrowserSessionsForApp({
orcaProfileId: activeOrcaProfile.profile.id,
+19
View File
@@ -1,5 +1,6 @@
import { ipcMain } from 'electron'
import { browserManager } from '../browser/browser-manager'
import { onDocPreviewGrantRevoked } from '../browser/doc-preview-grant-registry'
import { isTrustedBrowserRenderer } from './browser-renderer-trust'
import { waitForNextTabRegistration } from './browser-tab-registration-wait'
import type {
@@ -48,7 +49,25 @@ export function disposeGrabModeStateForPage(browserPageId: string): void {
grabModeOperationByPageId.delete(browserPageId)
}
/**
* Why previews need their own disposal path: a browser page announces its own death on
* `browser:unregisterGuest`, and a preview never does — it withdraws by revoking its grant, which
* is also what a re-mint does. Without this, every grant leaves an intent entry behind forever.
*/
let disposePreviewGrantSubscription: (() => void) | null = null
function subscribeToPreviewGrantRevocation(): void {
disposePreviewGrantSubscription?.()
disposePreviewGrantSubscription = onDocPreviewGrantRevoked((grant) => {
// Why cancel first: a grab still armed on that guest would otherwise leave the renderer's
// await hanging on a surface the reader has already closed.
browserManager.cancelGrabOp(grant.browserPageId, 'evicted')
disposeGrabModeStateForPage(grant.browserPageId)
})
}
export function registerBrowserGrabHandlers(): void {
subscribeToPreviewGrantRevocation()
ipcMain.removeHandler('browser:setGrabMode')
ipcMain.removeHandler('browser:awaitGrabSelection')
ipcMain.removeHandler('browser:cancelGrab')
+20 -6
View File
@@ -85,12 +85,26 @@ export function registerBrowserGuestViewHandlers(): void {
) {
return false
}
return browserManager.setAnnotationViewportBridge(args.browserPageId, {
enabled: args.enabled,
emitViewport: args.emitViewport,
markers: args.markers,
token: args.token
})
// Why resolve here: this is a tool acting on a guest the reader is looking at, so it answers
// for a workspace document too — and routing through the authority also pins the request to
// the renderer that owns the target, which page-id-only resolution never checked.
const resolveGuest = (): Electron.WebContents | null =>
browserManager.getAuthorizedGuest(args.browserPageId, event.sender.id)
if (!resolveGuest()) {
return false
}
// Why hand over the resolver rather than that guest: the op is serialized per tab, and the
// one it finally runs against must be the one on screen then, not the one this request saw.
return browserManager.setAnnotationViewportBridge(
args.browserPageId,
{
enabled: args.enabled,
emitViewport: args.emitViewport,
markers: args.markers,
token: args.token
},
resolveGuest
)
}
)
@@ -0,0 +1,510 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type * as DocPreviewGuestPolicyModule from '../browser/doc-preview-guest-policy'
import type * as TabRegistrationWaitModule from './browser-tab-registration-wait'
const {
handleMock,
removeHandlerMock,
getAuthorizedGuestMock,
setGrabModeMock,
awaitGrabSelectionMock,
cancelGrabOpMock,
captureSelectionScreenshotMock,
extractHoverPayloadMock,
setAnnotationViewportBridgeMock,
openDevToolsMock,
setViewportOverrideMock,
previewAuthoritySpy,
registrationWaitSpy
} = vi.hoisted(() => ({
handleMock: vi.fn(),
removeHandlerMock: vi.fn(),
getAuthorizedGuestMock: vi.fn(),
setGrabModeMock: vi.fn().mockResolvedValue(true),
awaitGrabSelectionMock: vi.fn().mockResolvedValue({ opId: 'op', kind: 'cancelled' }),
cancelGrabOpMock: vi.fn(),
captureSelectionScreenshotMock: vi.fn().mockResolvedValue(null),
extractHoverPayloadMock: vi.fn().mockResolvedValue(null),
setAnnotationViewportBridgeMock: vi.fn().mockResolvedValue(true),
openDevToolsMock: vi.fn().mockResolvedValue(true),
setViewportOverrideMock: vi.fn().mockResolvedValue(true),
previewAuthoritySpy: vi.fn(),
registrationWaitSpy: vi.fn()
}))
vi.mock('electron', () => ({
BrowserWindow: { fromWebContents: vi.fn() },
ipcMain: { removeHandler: removeHandlerMock, handle: handleMock },
webContents: { fromId: vi.fn(() => ({ isDestroyed: () => false })) }
}))
vi.mock('../browser/browser-manager', () => ({
browserCertificateTrustController: { proceed: vi.fn(() => ({ ok: true })) },
browserManager: {
registerGuest: vi.fn(() => true),
attachGuestPolicies: vi.fn(),
unregisterGuest: vi.fn(),
getGuestWebContentsId: vi.fn(),
getWebContentsIdByTabId: vi.fn(() => new Map()),
getWorktreeIdForTab: vi.fn(),
getAuthorizedGuest: getAuthorizedGuestMock,
setGrabMode: setGrabModeMock,
awaitGrabSelection: awaitGrabSelectionMock,
cancelGrabOp: cancelGrabOpMock,
captureSelectionScreenshot: captureSelectionScreenshotMock,
extractHoverPayload: extractHoverPayloadMock,
setAnnotationViewportBridge: setAnnotationViewportBridgeMock,
openDevTools: openDevToolsMock,
setViewportOverride: setViewportOverrideMock,
cancelDownload: vi.fn()
}
}))
// Why the real policy module behind a spy: the point of these tests is which half of the page
// registry a channel reads, so a stub of it would prove nothing about what was consulted.
vi.mock('../browser/doc-preview-guest-policy', async (importOriginal) => {
const actual = await importOriginal<typeof DocPreviewGuestPolicyModule>()
return {
...actual,
getWorkspaceDocPageGuest: (browserPageId: string, senderWebContentsId: number) => {
previewAuthoritySpy(browserPageId, senderWebContentsId)
return actual.getWorkspaceDocPageGuest(browserPageId, senderWebContentsId)
}
}
})
// Why spy rather than stub: the wait is real machinery the browser-page path still depends on;
// only whether a preview target enters it is under test.
vi.mock('./browser-tab-registration-wait', async (importOriginal) => {
const actual = await importOriginal<typeof TabRegistrationWaitModule>()
return {
...actual,
waitForNextTabRegistration: (...args: Parameters<typeof actual.waitForNextTabRegistration>) => {
registrationWaitSpy(args[0])
return actual.waitForNextTabRegistration(...args)
}
}
})
import { registerBrowserHandlers } from './browser'
import { browserManager } from '../browser/browser-manager'
import {
getWorkspaceDocPageGuest,
installDocPreviewGuestPolicy
} from '../browser/doc-preview-guest-policy'
import {
mintDocPreviewGrant,
revokeAllDocPreviewGrants,
revokeDocPreviewGrant
} from '../browser/doc-preview-grant-registry'
import { buildDocPreviewUrl } from '../../shared/doc-preview-scheme'
const HOST_RENDERER_ID = 91
const OTHER_RENDERER_ID = 92
/** Mirrors browser-grab-ipc's own wait, so elapsing it here settles the same parked request. */
const GRAB_REGISTRATION_WAIT_MS = 1_000
/**
* Every `browser:*` invoke channel, split by whether it acts on a guest the reader is looking at
* (a tool) or manages browser-page, session and profile state. The split is asserted to be total,
* so a new channel cannot be added without deciding which side of the preview seam it belongs on.
*/
const TOOL_CHANNELS = [
'browser:setGrabMode',
'browser:awaitGrabSelection',
'browser:cancelGrab',
'browser:captureSelectionScreenshot',
'browser:extractHoverPayload',
'browser:setAnnotationViewportBridge'
]
const BROWSER_PAGE_CHANNELS = [
'browser:registerGuest',
'browser:prepareSshWorkspacePartition',
'browser:repairGuestRegistration',
'browser:isGuestRegistered',
'browser:unregisterGuest',
'browser:respondWebAuthnAccount',
'browser:proceedCertificate',
'browser:activeTabChanged',
'browser:openDevTools',
'browser:setViewportOverride',
'browser:publishClientPageMetadata',
'browser:cancelDownload',
'browser:session:listProfiles',
'browser:session:createProfile',
'browser:session:deleteProfile',
'browser:session:importCookies',
'browser:session:resolvePartition',
'browser:session:clearDefaultCookies',
'browser:session:importFromBrowserForClientHost',
'browser:session:clientRouteImportSources',
'browser:session:detectBrowsers',
'browser:session:detectBrowsersForClientHost',
'browser:session:importFromBrowser'
]
type Handler = (event: { sender: Electron.WebContents }, args: unknown) => unknown
function registeredHandlers(): Map<string, Handler> {
const handlers = new Map<string, Handler>()
for (const [channel, handler] of handleMock.mock.calls as [string, Handler][]) {
handlers.set(channel, handler)
}
return handlers
}
function trustedSender(id: number): { sender: Electron.WebContents } {
return {
sender: {
id,
isDestroyed: () => false,
getType: () => 'window',
getURL: () => 'file:///renderer/index.html'
} as unknown as Electron.WebContents
}
}
let nextDocPageOrdinal = 0
function grantForNewDocPage(): { id: string; browserPageId: string } {
nextDocPageOrdinal += 1
const browserPageId = `doc-page-${nextDocPageOrdinal}`
const grant = mintDocPreviewGrant({
owner: { kind: 'ssh', connectionId: 'ssh-1' },
root: '/home/alice/docs',
entryRelativePath: 'index.html',
browserPageId
})
return { id: grant.id, browserPageId }
}
/** A preview guest already showing its document, which is the only state a tool can act in. */
function renderPreviewForGrant(
grant: { id: string; browserPageId: string },
hostId: number = HOST_RENDERER_ID
): {
grantId: string
browserPageId: string
contents: object
markContentsDestroyed: () => void
} {
const browserPageId = grant.browserPageId
const handlers: Record<string, (...args: never[]) => void> = {}
const register = (event: string, handler: (...args: never[]) => void): void => {
handlers[event] = handler
}
// Why the guest already reports its URL: the embedder hands a preview over mid-load, so this is
// the state the policy really installs into.
const documentUrl = buildDocPreviewUrl(grant.id, 'index.html')
let contentsDestroyed = false
const guest = {
isFocused: () => true,
isDestroyed: () => contentsDestroyed,
getURL: () => documentUrl,
on: vi.fn(register),
once: vi.fn(register),
setWindowOpenHandler: vi.fn(),
setWebRTCIPHandlingPolicy: vi.fn()
}
installDocPreviewGuestPolicy(guest as never, { id: hostId, send: vi.fn() })
handlers['did-start-navigation']?.({ url: documentUrl, isMainFrame: true } as never)
return {
grantId: grant.id,
browserPageId,
contents: guest,
// Why without the `destroyed` event: Chromium tears the contents down before main runs that
// listener, so this is the window the authority has to answer for on its own.
markContentsDestroyed: () => {
contentsDestroyed = true
}
}
}
function liveRenderedPreview(
hostId: number = HOST_RENDERER_ID
): ReturnType<typeof renderPreviewForGrant> {
return renderPreviewForGrant(grantForNewDocPage(), hostId)
}
/** Minimal well-formed args per channel, so a refusal is authorization and not shape validation. */
function toolArgs(channel: string, browserPageId: string): Record<string, unknown> {
switch (channel) {
case 'browser:setGrabMode':
return { browserPageId, enabled: true }
case 'browser:awaitGrabSelection':
return { browserPageId, opId: 'op-1' }
case 'browser:captureSelectionScreenshot':
return { browserPageId, rect: { x: 0, y: 0, width: 10, height: 10 } }
case 'browser:setAnnotationViewportBridge':
return {
browserPageId,
enabled: true,
emitViewport: true,
markers: [],
token: 'annotation-bridge-token-1'
}
default:
return { browserPageId }
}
}
/** The viewport bridge is handed a resolver rather than the contents, so unwrap one call argument. */
function resolvesToGuest(argument: unknown, guest: object): boolean {
return argument === guest || (typeof argument === 'function' && argument() === guest)
}
const GUEST_RECEIVING_MOCKS = [
setGrabModeMock,
awaitGrabSelectionMock,
captureSelectionScreenshotMock,
extractHoverPayloadMock,
setAnnotationViewportBridgeMock
]
beforeEach(() => {
vi.stubEnv('ELECTRON_RENDERER_URL', '')
// Why before clearing: revoking the previous test's grants disposes their page state through the
// mocked manager, and those calls belong to that test, not this one.
revokeAllDocPreviewGrants()
vi.clearAllMocks()
// Why the mocked manager still answers for documents: this file's subject is which channels may
// reach a document guest, not how the manager splits its registry — that lives in
// browser-manager-guest-policy-profile.test.ts, against the real manager.
getAuthorizedGuestMock.mockImplementation((browserPageId: string, senderWebContentsId: number) =>
getWorkspaceDocPageGuest(browserPageId, senderWebContentsId)
)
setGrabModeMock.mockResolvedValue(true)
awaitGrabSelectionMock.mockResolvedValue({ opId: 'op-1', kind: 'cancelled' })
captureSelectionScreenshotMock.mockResolvedValue(null)
extractHoverPayloadMock.mockResolvedValue(null)
setAnnotationViewportBridgeMock.mockResolvedValue(true)
registerBrowserHandlers()
// Why fake timers for the whole file: a tool asking for a page whose guest has not attached parks
// in the registration wait, and several cases here are exactly the ones that never attach. Real
// time would spend that wait for each of them.
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
/** Settles a tool request, elapsing the registration wait it may be parked in. */
async function settle<T>(pending: Promise<T> | T | undefined): Promise<T | undefined> {
await vi.advanceTimersByTimeAsync(GRAB_REGISTRATION_WAIT_MS)
return pending
}
describe('doc preview tool authorization', () => {
it('classifies every registered browser channel as a tool or a browser-page channel', () => {
const registered = [...registeredHandlers().keys()].sort()
const classified = [...TOOL_CHANNELS, ...BROWSER_PAGE_CHANNELS].sort()
expect(registered).toEqual(classified)
})
it.each(TOOL_CHANNELS)('drives the preview guest from %s', async (channel) => {
const preview = liveRenderedPreview()
const handler = registeredHandlers().get(channel)
await handler?.(trustedSender(HOST_RENDERER_ID), toolArgs(channel, preview.browserPageId))
// Why assert on the guest and not on a return value: the whole point of the seam is which
// WebContents the tool ends up acting on.
const receivedGuest = GUEST_RECEIVING_MOCKS.some((mock) =>
mock.mock.calls.some((args) => args.some((arg) => resolvesToGuest(arg, preview.contents)))
)
expect(receivedGuest || cancelGrabOpMock.mock.calls.length > 0).toBe(true)
})
// The load-bearing containment claim: page and session management never reads the document half
// of the registry, so a document page can never be resolved as, or managed like, a browsing one.
// Nothing here is a per-channel guard — a document guest is simply not in the map these read.
it.each(BROWSER_PAGE_CHANNELS)('never reads the document registry from %s', async (channel) => {
const preview = liveRenderedPreview()
const handler = registeredHandlers().get(channel)
try {
await handler?.(trustedSender(HOST_RENDERER_ID), {
browserPageId: preview.browserPageId,
profileId: preview.browserPageId,
environmentId: preview.browserPageId
})
} catch {
// A malformed-for-this-channel payload may throw; the claim is about which registry was read.
}
expect(previewAuthoritySpy).not.toHaveBeenCalled()
})
it.each(TOOL_CHANNELS)(
'refuses %s from a renderer that does not host the preview',
async (channel) => {
const preview = liveRenderedPreview()
const handler = registeredHandlers().get(channel)
await settle(
handler?.(trustedSender(OTHER_RENDERER_ID), toolArgs(channel, preview.browserPageId))
)
for (const mock of [...GUEST_RECEIVING_MOCKS, cancelGrabOpMock]) {
expect(mock).not.toHaveBeenCalled()
}
}
)
it.each(TOOL_CHANNELS)('refuses %s for a page no preview rendered', async (channel) => {
const handler = registeredHandlers().get(channel)
await settle(
handler?.(trustedSender(HOST_RENDERER_ID), toolArgs(channel, 'doc-page-unrendered'))
)
for (const mock of [...GUEST_RECEIVING_MOCKS, cancelGrabOpMock]) {
expect(mock).not.toHaveBeenCalled()
}
})
// Why the contents check has to be its own condition: the grant is still live and the sender is
// still the host, so nothing else in the authority notices that the guest is gone.
it.each(TOOL_CHANNELS)('refuses %s once the preview contents are destroyed', async (channel) => {
const preview = liveRenderedPreview()
preview.markContentsDestroyed()
const handler = registeredHandlers().get(channel)
await settle(
handler?.(trustedSender(HOST_RENDERER_ID), toolArgs(channel, preview.browserPageId))
)
for (const mock of [...GUEST_RECEIVING_MOCKS, cancelGrabOpMock]) {
expect(mock).not.toHaveBeenCalled()
}
})
// Why this inverts what the split registries used to assert: a document page now registers under
// its own id, so the wait is reachable and useful — a tool opened while the guest is still
// attaching parks here instead of answering not-ready at the reader.
it('waits for a document page whose guest has not attached yet, and arms when it does', async () => {
const grant = grantForNewDocPage()
const handler = registeredHandlers().get('browser:setGrabMode')
const pending = handler?.(trustedSender(HOST_RENDERER_ID), {
browserPageId: grant.browserPageId,
enabled: true
})
await vi.advanceTimersByTimeAsync(0)
expect(registrationWaitSpy).toHaveBeenCalledWith(grant.browserPageId)
const preview = renderPreviewForGrant(grant)
await expect(pending).resolves.toEqual({ ok: true })
expect(setGrabModeMock).toHaveBeenCalledWith(grant.browserPageId, true, preview.contents)
})
// The absence half, with the same presence precondition: a page nothing ever renders still gives
// the reader an answer rather than hanging on the full timeout.
it('answers not-ready once the wait for an unrendered page elapses', async () => {
const handler = registeredHandlers().get('browser:setGrabMode')
await expect(
settle(
handler?.(trustedSender(HOST_RENDERER_ID), {
browserPageId: 'doc-page-never-rendered',
enabled: true
})
)
).resolves.toEqual({ ok: false, reason: 'not-ready' })
})
it('still waits for a browser page whose registration may be in flight', async () => {
const handler = registeredHandlers().get('browser:setGrabMode')
await settle(
handler?.(trustedSender(HOST_RENDERER_ID), {
browserPageId: 'browser-page-1',
enabled: true
})
)
expect(registrationWaitSpy).toHaveBeenCalledWith('browser-page-1')
})
// Why the grant and not the guest: a preview withdraws by revoking, which is also what a
// re-mint does, and nothing else tells main that this tool target will never be used again.
it('disposes the grab state a preview target accumulated when its grant is revoked', async () => {
const preview = liveRenderedPreview()
const handler = registeredHandlers().get('browser:setGrabMode')
await handler?.(trustedSender(HOST_RENDERER_ID), {
browserPageId: preview.browserPageId,
enabled: true
})
cancelGrabOpMock.mockClear()
revokeDocPreviewGrant(preview.grantId)
expect(cancelGrabOpMock).toHaveBeenCalledWith(preview.browserPageId, 'evicted')
})
// Why both halves of this door: the manager refuses a preview id on its own, but the grab
// disposal beside it takes a renderer-supplied id, and the intent it drops is compared by
// identity — dropping it makes the grab settle ok without ever arming the guest.
it('leaves an in-flight preview grab armed when unregisterGuest names its target', async () => {
const preview = liveRenderedPreview()
const handlers = registeredHandlers()
const pending = handlers.get('browser:setGrabMode')?.(trustedSender(HOST_RENDERER_ID), {
browserPageId: preview.browserPageId,
enabled: true
})
// Why synchronously here: the intent is recorded before the queued operation runs, so this is
// the exact window in which a disposal at this door would be invisible to the caller.
const unregistered = handlers.get('browser:unregisterGuest')?.(
trustedSender(HOST_RENDERER_ID),
{ browserPageId: preview.browserPageId }
)
// Why the guest and not the result: a dropped intent settles as ok either way, so only the
// guest actually being driven separates an armed grab from a silent no-op.
await expect(pending).resolves.toEqual({ ok: true })
expect(setGrabModeMock).toHaveBeenCalledWith(preview.browserPageId, true, preview.contents)
expect(unregistered).toBe(false)
expect(vi.mocked(browserManager.unregisterGuest)).not.toHaveBeenCalled()
})
// The converse, so the guard above cannot be widened into a door that stops closing tabs.
it('still disposes a browser page grab through the same door', async () => {
getAuthorizedGuestMock.mockReturnValue({ isDestroyed: () => false })
const handlers = registeredHandlers()
const pending = handlers.get('browser:setGrabMode')?.(trustedSender(HOST_RENDERER_ID), {
browserPageId: 'browser-page-1',
enabled: true
})
const unregistered = handlers.get('browser:unregisterGuest')?.(
trustedSender(HOST_RENDERER_ID),
{ browserPageId: 'browser-page-1' }
)
expect(unregistered).toBe(true)
await expect(pending).resolves.toEqual({ ok: true })
expect(setGrabModeMock).not.toHaveBeenCalled()
expect(vi.mocked(browserManager.unregisterGuest)).toHaveBeenCalledWith('browser-page-1')
})
// Why with a live preview standing beside it: the halves are looked up in one door now, so the
// thing to prove is that a browsing id is not answered by whatever document happens to be open.
it('never hands a browsing page id the guest of a document that is open', async () => {
const preview = liveRenderedPreview()
const handler = registeredHandlers().get('browser:extractHoverPayload')
await handler?.(trustedSender(HOST_RENDERER_ID), { browserPageId: 'browser-page-1' })
expect(getAuthorizedGuestMock).toHaveBeenCalledWith('browser-page-1', HOST_RENDERER_ID)
expect(previewAuthoritySpy).toHaveBeenCalledWith('browser-page-1', HOST_RENDERER_ID)
expect(extractHoverPayloadMock).not.toHaveBeenCalled()
// The presence half: the same channel does reach that guest under the page it really renders.
await handler?.(trustedSender(HOST_RENDERER_ID), { browserPageId: preview.browserPageId })
expect(extractHoverPayloadMock).toHaveBeenCalledWith(preview.browserPageId, preview.contents)
})
})
@@ -0,0 +1,108 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
getWebContentsIdByTabId: vi.fn(() => new Map<string, number>()),
getWorktreeIdForTab: vi.fn(() => undefined as string | undefined),
getGuestWebContentsId: vi.fn(() => null as number | null),
webContentsFromId: vi.fn(() => null as unknown)
}))
vi.mock('electron', () => ({ webContents: { fromId: mocks.webContentsFromId } }))
vi.mock('../browser/browser-manager', () => ({
browserManager: {
getWebContentsIdByTabId: mocks.getWebContentsIdByTabId,
getWorktreeIdForTab: mocks.getWorktreeIdForTab,
getGuestWebContentsId: mocks.getGuestWebContentsId
}
}))
import {
waitForAnyTabRegistration,
waitForNextTabRegistration,
waitForWorktreeTabRegistration
} from './browser-tab-registration-wait'
import { installDocPreviewGuestPolicy } from '../browser/doc-preview-guest-policy'
import {
mintDocPreviewGrant,
revokeAllDocPreviewGrants
} from '../browser/doc-preview-grant-registry'
import { buildDocPreviewUrl } from '../../shared/doc-preview-scheme'
const WAIT_MS = 1_000
/** Registers a document guest the way the attach door does, which is what notifies the waiters. */
function attachDocumentGuest(browserPageId: string): void {
const grant = mintDocPreviewGrant({
owner: { kind: 'ssh', connectionId: 'ssh-1' },
root: '/home/alice/docs',
entryRelativePath: 'index.html',
browserPageId
})
const guest = {
isFocused: () => false,
isDestroyed: () => false,
getURL: () => buildDocPreviewUrl(grant.id, grant.entryRelativePath),
on: vi.fn(),
once: vi.fn(),
setWindowOpenHandler: vi.fn(),
setWebRTCIPHandlingPolicy: vi.fn()
}
installDocPreviewGuestPolicy(guest as never, { id: 91, send: vi.fn() })
}
/** Whether a wait has settled, without letting its rejection escape as an unhandled one. */
function track(pending: Promise<void>): () => 'pending' | 'resolved' | 'rejected' {
let state: 'pending' | 'resolved' | 'rejected' = 'pending'
pending.then(
() => {
state = 'resolved'
},
() => {
state = 'rejected'
}
)
return () => state
}
beforeEach(() => {
revokeAllDocPreviewGrants()
vi.clearAllMocks()
mocks.getWebContentsIdByTabId.mockReturnValue(new Map())
mocks.getGuestWebContentsId.mockReturnValue(null)
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
describe('a workspace document registering', () => {
it('settles a wait that already names its page', async () => {
const settled = track(waitForNextTabRegistration('doc-page-1', WAIT_MS))
attachDocumentGuest('doc-page-1')
await vi.advanceTimersByTimeAsync(0)
expect(settled()).toBe('resolved')
})
// Why these two stay pending: they are how the CLI and agents ask for a browser tab to drive, and
// a preview is neither drivable by them nor visible to them. Satisfying either would hand the
// caller a surface that answers nothing, instead of letting it keep waiting for a real tab.
it('leaves the waits that ask for any browser tab still waiting', async () => {
const worktreeWait = track(waitForWorktreeTabRegistration('wt-1', WAIT_MS))
const anyWait = track(waitForAnyTabRegistration(WAIT_MS))
attachDocumentGuest('doc-page-2')
await vi.advanceTimersByTimeAsync(0)
expect(worktreeWait()).toBe('pending')
expect(anyWait()).toBe('pending')
// The presence half: those waits do settle, so a mutant that never resolves them would pass the
// assertions above by being uniformly stuck.
await vi.advanceTimersByTimeAsync(WAIT_MS)
expect(worktreeWait()).toBe('rejected')
expect(anyWait()).toBe('rejected')
})
})
@@ -1,5 +1,6 @@
import { webContents } from 'electron'
import { browserManager } from '../browser/browser-manager'
import { onWorkspaceDocGuestRegistered } from '../browser/doc-preview-guest-policy'
// Why: CLI-driven tab creation must wait until the renderer mounts the webview
// and calls registerGuest, so the tab has a webContentsId and is operable by
@@ -109,6 +110,18 @@ export function waitForAnyTabRegistration(timeoutMs = 8_000): Promise<void> {
return waitForRegistrationSet(pendingAnyTabRegistrations, timeoutMs, () => {})
}
/**
* Why a document page resolves only its own waiters: the worktree-wide and any-tab waits are how
* the CLI and agents ask for a browser tab to drive, and a preview is neither drivable by them nor
* visible to them. Only a request already naming this page — a tool the reader opened on the
* document — is waiting for this.
*/
onWorkspaceDocGuestRegistered((browserPageId) => {
const pendingResolves = pendingTabRegistrations.get(browserPageId)
pendingTabRegistrations.delete(browserPageId)
resolvePendingRegistrations(pendingResolves)
})
export function resolveTabRegistrationWaiters(browserPageId: string, worktreeId: string): void {
const pendingResolves = pendingTabRegistrations.get(browserPageId)
pendingTabRegistrations.delete(browserPageId)
+48 -6
View File
@@ -794,6 +794,8 @@ describe('registerBrowserHandlers', () => {
it('validates annotation viewport bridge requests before syncing to the guest', async () => {
registerBrowserHandlers()
const guest = { isDestroyed: () => false } as Electron.WebContents
getAuthorizedGuestMock.mockReturnValue(guest)
const syncHandler = handleMock.mock.calls.find(
([channel]) => channel === 'browser:setAnnotationViewportBridge'
@@ -818,12 +820,52 @@ describe('registerBrowserHandlers', () => {
)
expect(result).toBe(true)
expect(setAnnotationViewportBridgeMock).toHaveBeenCalledWith('page-1', {
emitViewport: false,
enabled: true,
markers: [],
token: 'annotationviewporttoken'
})
expect(setAnnotationViewportBridgeMock).toHaveBeenCalledWith(
'page-1',
{
emitViewport: false,
enabled: true,
markers: [],
token: 'annotationviewporttoken'
},
// Why a resolver and not the guest: the op is serialized per page, so it has to read the
// registry when it runs — a navigation while it waited may have swapped the contents.
// Which guest it then resolves is pinned in browser-manager-annotation-bridge.test.ts.
expect.any(Function)
)
})
// Why this is new: the channel used to hand a page id straight to the manager, so any trusted
// renderer could drive any page's guest. It now resolves through the same authority the grab
// channels use, which pins the request to the renderer that registered the page.
it('refuses an annotation viewport bridge request from a renderer that does not own the page', async () => {
registerBrowserHandlers()
getAuthorizedGuestMock.mockReturnValue(null)
const syncHandler = handleMock.mock.calls.find(
([channel]) => channel === 'browser:setAnnotationViewportBridge'
)?.[1] as (event: { sender: Electron.WebContents }, args: unknown) => Promise<boolean> | boolean
const result = await syncHandler(
{
sender: {
id: 91,
isDestroyed: () => false,
getType: () => 'window',
getURL: () => 'file:///renderer/index.html'
} as Electron.WebContents
},
{
browserPageId: 'page-1',
emitViewport: false,
enabled: true,
markers: [],
token: 'annotationviewporttoken'
}
)
expect(result).toBe(false)
expect(setAnnotationViewportBridgeMock).not.toHaveBeenCalled()
})
it('rejects invalid annotation viewport bridge requests', async () => {
+8
View File
@@ -2,6 +2,7 @@ import { ipcMain, webContents } from 'electron'
import { browserCertificateTrustController, browserManager } from '../browser/browser-manager'
import type { AgentBrowserBridge } from '../browser/agent-browser-bridge'
import { browserSessionRegistry } from '../browser/browser-session-registry'
import { isWorkspaceDocPageId } from '../browser/doc-preview-guest-policy'
import { isTrustedBrowserRenderer } from './browser-renderer-trust'
import {
isLiveBrowserWebContentsId,
@@ -160,6 +161,13 @@ export function registerBrowserHandlers(): void {
if (!isTrustedBrowserRenderer(event.sender)) {
return false
}
// Why the whole door and not just the manager call: a document page shares this renderer, and
// the grab disposal below drops the intent an in-flight preview grab compares by identity —
// that grab would then answer ok without ever arming. A document page withdraws by revoking
// its grant, so its id arriving here is misaddressed however it got here.
if (typeof args?.browserPageId !== 'string' || isWorkspaceDocPageId(args.browserPageId)) {
return false
}
// Why: notify bridge before unregistering so it can destroy the session
// process and proxy. Must happen before unregisterGuest clears the mapping.
const wcId = browserManager.getGuestWebContentsId(args.browserPageId)
+159
View File
@@ -0,0 +1,159 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
handlers: new Map<string, (event: unknown, ...args: unknown[]) => unknown>(),
listeners: new Map<string, (event: unknown, ...args: unknown[]) => unknown>(),
isTrustedBrowserRenderer: vi.fn(),
reportDocPreviewLinkClick: vi.fn(),
getGuestWebContentsId: vi.fn()
}))
vi.mock('electron', () => ({
ipcMain: {
handle: (channel: string, handler: (event: unknown, ...args: unknown[]) => unknown) => {
mocks.handlers.set(channel, handler)
},
on: (channel: string, listener: (event: unknown, ...args: unknown[]) => unknown) => {
mocks.listeners.set(channel, listener)
}
}
}))
vi.mock('./browser-renderer-trust', () => ({
isTrustedBrowserRenderer: mocks.isTrustedBrowserRenderer
}))
vi.mock('../browser/doc-preview-guest-policy', () => ({
reportDocPreviewLinkClick: mocks.reportDocPreviewLinkClick
}))
vi.mock('../browser/browser-manager', () => ({
browserManager: { getGuestWebContentsId: mocks.getGuestWebContentsId }
}))
import {
registerDocPreviewGrantHandlers,
type DocPreviewGrantRequest
} from './doc-preview-grant-ipc'
import {
getDocPreviewGrant,
revokeAllDocPreviewGrants
} from '../browser/doc-preview-grant-registry'
import {
DOC_PREVIEW_LINK_CLICK_CHANNEL,
DOC_PREVIEW_MINT_GRANT_CHANNEL,
DOC_PREVIEW_REVOKE_GRANT_CHANNEL,
parseDocPreviewUrl
} from '../../shared/doc-preview-scheme'
const REQUEST: DocPreviewGrantRequest = {
owner: { kind: 'ssh', connectionId: 'ssh-1' },
root: '/home/alice/docs',
entryRelativePath: 'index.html',
browserPageId: 'doc-page-1'
}
const sender = { id: 7 }
function mint(request: DocPreviewGrantRequest = REQUEST): { grantId: string; url: string } {
const handler = mocks.handlers.get(DOC_PREVIEW_MINT_GRANT_CHANNEL)
if (!handler) {
throw new Error('mint handler not registered')
}
return handler({ sender }, request) as { grantId: string; url: string }
}
function revoke(grantId: string): boolean {
const handler = mocks.handlers.get(DOC_PREVIEW_REVOKE_GRANT_CHANNEL)
if (!handler) {
throw new Error('revoke handler not registered')
}
return handler({ sender }, grantId) as boolean
}
function reportLinkClick(url: unknown): void {
const listener = mocks.listeners.get(DOC_PREVIEW_LINK_CLICK_CHANNEL)
if (!listener) {
throw new Error('link click listener not registered')
}
listener({ sender }, url)
}
beforeEach(() => {
mocks.handlers.clear()
mocks.listeners.clear()
vi.clearAllMocks()
revokeAllDocPreviewGrants()
mocks.isTrustedBrowserRenderer.mockReturnValue(true)
mocks.getGuestWebContentsId.mockReturnValue(null)
registerDocPreviewGrantHandlers()
})
describe('document preview grant handlers', () => {
it('mints a grant addressable by the URL it returns', () => {
const result = mint()
expect(parseDocPreviewUrl(result.url)).toEqual({
grantId: result.grantId,
relativePath: 'index.html'
})
expect(getDocPreviewGrant(result.grantId)?.root).toBe('/home/alice/docs')
expect(mocks.isTrustedBrowserRenderer).toHaveBeenCalledWith(sender)
})
it('revokes a grant it minted', () => {
const result = mint()
expect(revoke(result.grantId)).toBe(true)
expect(getDocPreviewGrant(result.grantId)).toBeNull()
})
it('rejects a request that names no root, entry document or page', () => {
expect(() => mint({ ...REQUEST, root: ' ' })).toThrow(/Invalid/)
expect(() => mint({ ...REQUEST, entryRelativePath: '' })).toThrow(/Invalid/)
expect(() => mint({ ...REQUEST, browserPageId: ' ' })).toThrow(/Invalid/)
})
// Why this is refused here and not left to registration: this is where a page becomes a document
// page, and the two halves of the page registry have to stay disjoint. A page already hosting a
// browsing guest would otherwise resolve in both, and the tool door prefers the document one.
it('refuses to make a page that already hosts a browsing guest into a document page', () => {
mocks.getGuestWebContentsId.mockReturnValue(42)
expect(() => mint()).toThrow(/browsing page/)
expect(mocks.getGuestWebContentsId).toHaveBeenCalledWith('doc-page-1')
})
// Why: this channel hands out filesystem-read authority, so an untrusted sender must leave with
// nothing rather than with a grant id the scheme handler would honor.
it('mints nothing for a sender that is not the trusted renderer', () => {
mocks.isTrustedBrowserRenderer.mockReturnValue(false)
expect(() => mint()).toThrow(/Untrusted/)
})
it('refuses to revoke on behalf of a sender that is not the trusted renderer', () => {
const result = mint()
mocks.isTrustedBrowserRenderer.mockReturnValue(false)
expect(revoke(result.grantId)).toBe(false)
expect(getDocPreviewGrant(result.grantId)).not.toBeNull()
})
// Why this channel skips the trusted-renderer check: its sender is a preview guest rendering a
// workspace document, which is the untrusted side by construction. The guest policy holds the
// gate, so all this listener owes is the sender and a string.
it('hands a reported link click to the guest policy with the sender that reported it', () => {
reportLinkClick('https://example.com/docs')
expect(mocks.reportDocPreviewLinkClick).toHaveBeenCalledExactlyOnceWith(
sender,
'https://example.com/docs'
)
expect(mocks.isTrustedBrowserRenderer).not.toHaveBeenCalled()
})
it('ignores a reported click that is not a string at all', () => {
reportLinkClick({ url: 'https://example.com/docs' })
reportLinkClick(undefined)
expect(mocks.reportDocPreviewLinkClick).not.toHaveBeenCalled()
})
})
+95
View File
@@ -0,0 +1,95 @@
import { ipcMain } from 'electron'
import {
buildDocPreviewUrl,
DOC_PREVIEW_LINK_CLICK_CHANNEL,
DOC_PREVIEW_MINT_GRANT_CHANNEL,
DOC_PREVIEW_REVOKE_GRANT_CHANNEL
} from '../../shared/doc-preview-scheme'
import { browserManager } from '../browser/browser-manager'
import { reportDocPreviewLinkClick } from '../browser/doc-preview-guest-policy'
import {
mintDocPreviewGrant,
revokeDocPreviewGrant,
type DocPreviewOwner
} from '../browser/doc-preview-grant-registry'
import { isTrustedBrowserRenderer } from './browser-renderer-trust'
export type DocPreviewGrantRequest = {
owner: DocPreviewOwner
/** Containing directory of the opened document, on the owning host. */
root: string
/** Opened document, relative to `root`. */
entryRelativePath: string
/** Browser page the reader is opening the document in; main registers the guest under it. */
browserPageId: string
}
export type DocPreviewGrantResult = { grantId: string; url: string }
function isValidGrantRequest(request: DocPreviewGrantRequest): boolean {
if (!request.root.trim() || !request.entryRelativePath.trim()) {
return false
}
if (typeof request.browserPageId !== 'string' || !request.browserPageId.trim()) {
return false
}
if (request.owner.kind === 'ssh') {
return Boolean(request.owner.connectionId.trim())
}
return Boolean(
request.owner.environmentId.trim() &&
request.owner.worktreeSelector.trim() &&
request.owner.worktreeRoot.trim()
)
}
/**
* Minting never widens what the renderer can already read: an SSH grant reads
* through the same provider as `fs:readFile`, and a runtime grant through the
* same worktree-scoped `files.read` RPC the renderer can call directly.
*/
export function registerDocPreviewGrantHandlers(): void {
ipcMain.handle(
DOC_PREVIEW_MINT_GRANT_CHANNEL,
(event, request: DocPreviewGrantRequest): DocPreviewGrantResult => {
// Why gate a channel guests cannot reach today: this one hands out filesystem-read
// authority, so it holds the same sender check its sibling browser channels do rather than
// relying on guests never gaining an ipcRenderer.
if (!isTrustedBrowserRenderer(event.sender)) {
throw new Error('Untrusted document preview grant request')
}
if (!isValidGrantRequest(request)) {
throw new Error('Invalid document preview grant request')
}
// Why the other half of the registry is consulted here: this is where a page first becomes a
// document page, and the two halves must stay disjoint. Naming a page that already hosts a
// browsing guest would make one id resolve in both.
if (browserManager.getGuestWebContentsId(request.browserPageId) !== null) {
throw new Error('Document preview grant names a browsing page')
}
const grant = mintDocPreviewGrant({
owner: request.owner,
root: request.root,
entryRelativePath: request.entryRelativePath,
browserPageId: request.browserPageId
})
return {
grantId: grant.id,
url: buildDocPreviewUrl(grant.id, grant.entryRelativePath)
}
}
)
ipcMain.handle(DOC_PREVIEW_REVOKE_GRANT_CHANNEL, (event, grantId: string): boolean =>
isTrustedBrowserRenderer(event.sender) ? revokeDocPreviewGrant(grantId) : false
)
// Why no trusted-renderer check here: the sender is a preview guest rendering a workspace
// document, which is exactly the untrusted side. `reportDocPreviewLinkClick` is the gate — a
// live bound grant, a focused guest, a web URL — and it drops everything else silently.
ipcMain.on(DOC_PREVIEW_LINK_CLICK_CHANNEL, (event, url: unknown) => {
if (typeof url === 'string') {
reportDocPreviewLinkClick(event.sender, url)
}
})
}
@@ -7,6 +7,7 @@ import { LocalPtyProvider } from '../providers/local-pty-provider'
import { __resetPersistedWindowsPathCacheForTests } from '../pty/windows-environment-path'
import { __setWindowsPathRegistryLoaderForTests } from '../pty/windows-path-registry-reader'
import { hasLiveClaudePtys, markClaudePtySpawned } from '../claude-accounts/live-pty-gate'
import { wslHookRelayManager } from '../agent-hooks/wsl-hook-relay-manager'
import { registerPtyHandlers, buildPtyHostEnv, clearProviderPtyState } from './pty'
vi.mock('electron', () => import('./pty-ipc-mock-registry').then((m) => m.electronModuleMock()))
@@ -57,6 +58,32 @@ describe('registerPtyHandlers', () => {
const { handlers, mainWindow, spawnAndGetEnv, withBundledCli } = setupPtyIpcSuite()
describe('spawn environment', () => {
it('passes the PTY-resolved Codex home to the WSL relay lane', () => {
const runtimeHome =
'\\\\wsl.localhost\\Ubuntu\\home\\jin\\.local\\share\\orca\\codex-runtime-home\\home'
const ensureForDistro = vi
.spyOn(wslHookRelayManager, 'ensureForDistro')
.mockImplementation(() => {})
try {
buildPtyHostEnv(
'pty-wsl',
{},
{
isPackaged: true,
userDataPath: '/tmp/orca-user-data',
selectedCodexHomePath: runtimeHome,
isWsl: true,
wslDistro: 'Ubuntu',
agentStatusHooksEnabled: true
}
)
expect(ensureForDistro).toHaveBeenCalledExactlyOnceWith('Ubuntu', runtimeHome)
} finally {
ensureForDistro.mockRestore()
}
})
it('refreshes the outer Windows PATH for a WSL spawn without forwarding it', async () => {
const originalPlatform = process.platform
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
+1 -1
View File
@@ -122,7 +122,7 @@ export function buildPtyHostEnv(
if (opts.isWsl === true) {
// Why: hook POSTs to 127.0.0.1 die inside WSL's NAT namespace; use the guest-resident relay's endpoint instead of the Windows one.
const distro = opts.wslDistro ?? null
wslHookRelayManager.ensureForDistro(distro)
wslHookRelayManager.ensureForDistro(distro, opts.selectedCodexHomePath)
const guestEndpoint = wslHookRelayManager.getGuestEndpointFilePath(distro)
if (guestEndpoint) {
baseEnv.ORCA_AGENT_HOOK_ENDPOINT = guestEndpoint
@@ -3,6 +3,7 @@ export const AGENT_HOOK_RUNTIME_ENV_KEYS = [
'ORCA_AGENT_HOOK_TOKEN',
'ORCA_AGENT_HOOK_ENV',
'ORCA_AGENT_HOOK_VERSION',
'ORCA_AGENT_HOOK_TRANSPORT',
'ORCA_AGENT_HOOK_ENDPOINT',
// Why: PR 2778 briefly exported this path; keep deleting stale inherited values so older PTYs can't leak the reverted path.
'ORCA_CLAUDE_AGENT_STATUS_SETTINGS'
@@ -0,0 +1,173 @@
import { execFileSync } from 'node:child_process'
import { randomUUID } from 'node:crypto'
import { chmodSync, mkdirSync, mkdtempSync, rmSync } from 'node:fs'
import { homedir, tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { runProcessSync } from '../shared/child-process/run-process'
import {
PRESS_AND_HOLD_KEY,
ensureMacPressAndHoldDefault,
interpretDefaultsRead,
readDomainPressAndHoldPreference,
writeDomainPressAndHoldPreference,
type PressAndHoldHost,
type PressAndHoldRecord
} from './macos-press-and-hold-default'
/**
* Runs against the real `/usr/bin/defaults` on a throwaway Orca-owned domain.
*
* The whole design rests on one claim the mocks cannot make: a domain that has never been written
* is distinguishable from one explicitly set to `false`. Electron's `systemPreferences` cannot tell
* them apart, so if `defaults` could not either, "only write when unset" would be unimplementable.
*
* Every domain used here is a throwaway UUID under Orca's own prefix, deleted along with its plist
* in `afterEach`; the real `com.stablyai.orca` domain is never read or written.
*/
// Why a real Orca-owned domain shape: the ownership guard rejects anything else, so a fake prefix
// would exercise a different branch than production.
const domains: string[] = []
/** Pinned against the real binary below, then reused by the platform-agnostic unit tests. */
const DEFAULTS_MISSING_EXIT_CODE = 1
function throwawayDomain(): string {
const domain = `com.stablyai.orca.defaults-domain-test.${randomUUID()}`
domains.push(domain)
return domain
}
function write(domain: string, value: 'true' | 'false'): void {
execFileSync('/usr/bin/defaults', ['write', domain, PRESS_AND_HOLD_KEY, '-bool', value], {
stdio: 'ignore'
})
}
function rawRead(domain: string): string {
return execFileSync('/usr/bin/defaults', ['read', domain, PRESS_AND_HOLD_KEY], {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'ignore']
}).trim()
}
function hostFor(domain: string, record: PressAndHoldRecord | null = null): PressAndHoldHost {
return {
platform: 'darwin',
resolveBundleIdentifier: () => domain,
readRecord: () => record,
writeRecord: () => undefined,
readDomainPreference: readDomainPressAndHoldPreference,
writeDomainPreference: writeDomainPressAndHoldPreference,
now: () => new Date().toISOString()
}
}
describe.skipIf(process.platform !== 'darwin')(
'press-and-hold against the real defaults binary',
() => {
afterEach(() => {
for (const domain of domains.splice(0)) {
try {
execFileSync('/usr/bin/defaults', ['delete', domain], { stdio: 'ignore' })
} catch {
// The domain may never have been created; nothing to clean up.
}
// Why the file too: `defaults delete` empties the domain but leaves its plist behind, so
// without this every run litters the developer's ~/Library/Preferences.
rmSync(join(homedir(), 'Library', 'Preferences', `${domain}.plist`), { force: true })
}
})
it('tells an unset key apart from an explicit false', () => {
const unset = throwawayDomain()
const explicitlyFalse = throwawayDomain()
write(explicitlyFalse, 'false')
expect(readDomainPressAndHoldPreference(unset)).toBe('unset')
expect(readDomainPressAndHoldPreference(explicitlyFalse)).toBe('set')
expect(rawRead(explicitlyFalse)).toBe('0')
})
it('reports an explicit true as set', () => {
const domain = throwawayDomain()
write(domain, 'true')
expect(readDomainPressAndHoldPreference(domain)).toBe('set')
expect(rawRead(domain)).toBe('1')
})
it('writes false into a previously unset domain', () => {
const domain = throwawayDomain()
expect(ensureMacPressAndHoldDefault(hostFor(domain))).toBe('applied')
expect(rawRead(domain)).toBe('0')
})
it('leaves an explicit value untouched, in either direction', () => {
for (const value of ['true', 'false'] as const) {
const domain = throwawayDomain()
write(domain, value)
expect(ensureMacPressAndHoldDefault(hostFor(domain))).toBe('kept-user-preference')
expect(rawRead(domain)).toBe(value === 'true' ? '1' : '0')
}
})
it('refuses to call a failed probe "unset"', () => {
// Real runs, not hand-built results: a binary that is not there must come back 'unknown'
// through the same path production takes, not be mistaken for "the key does not exist".
const spawnFailure = (): ReturnType<typeof runProcessSync> =>
runProcessSync({
program: '/usr/bin/defaults-does-not-exist',
args: ['read'],
stdio: ['ignore', 'ignore', 'ignore']
})
expect(spawnFailure).toThrow()
expect(interpretDefaultsRead(spawnFailure)).toBe('unknown')
const missingKey = runProcessSync({
program: '/usr/bin/defaults',
args: ['read', throwawayDomain(), PRESS_AND_HOLD_KEY],
stdio: ['ignore', 'ignore', 'ignore']
})
// The one exit code the whole design reads as unset; every other outcome is 'unknown'.
expect(missingKey.code).toBe(DEFAULTS_MISSING_EXIT_CODE)
expect(missingKey.timedOut).toBe(false)
expect(interpretDefaultsRead(() => missingKey)).toBe('unset')
})
// Skipped as root, where the read-only directory below would still be writable.
it.skipIf(process.getuid?.() === 0)('reports a write the binary refused', () => {
// Why a real refusal: `defaults write` exits non-zero instead of throwing, so a caller that
// only caught exceptions would record 'applied' for a value that never reached the plist.
const readOnly = join(mkdtempSync(join(tmpdir(), 'orca-press-hold-ro-')), 'locked')
mkdirSync(readOnly)
chmodSync(readOnly, 0o500)
try {
expect(writeDomainPressAndHoldPreference(join(readOnly, 'domain'), false)).toBe(false)
} finally {
chmodSync(readOnly, 0o700)
rmSync(readOnly, { recursive: true, force: true })
}
})
it('sees the key disappear again after the user deletes it', () => {
const domain = throwawayDomain()
write(domain, 'false')
execFileSync('/usr/bin/defaults', ['delete', domain, PRESS_AND_HOLD_KEY], { stdio: 'ignore' })
expect(readDomainPressAndHoldPreference(domain)).toBe('unset')
// A launch that already decided must not rewrite it, which is what makes the delete stick.
const decided: PressAndHoldRecord = {
version: 1,
decision: 'applied',
domain,
decidedAt: '2026-01-01T00:00:00.000Z'
}
expect(ensureMacPressAndHoldDefault(hostFor(domain, decided))).toBe('already-decided')
expect(readDomainPressAndHoldPreference(domain)).toBe('unset')
})
}
)
@@ -0,0 +1,286 @@
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { ProcessResult } from '../shared/child-process/run-process'
import {
ensureMacPressAndHoldDefault,
interpretDefaultsRead,
isOrcaPreferencesDomain,
readBundleIdentifierFromExecutablePath,
type PressAndHoldDecision,
type PressAndHoldHost,
type PressAndHoldRecord
} from './macos-press-and-hold-default'
const ORCA_DOMAIN = 'com.stablyai.orca'
type HostOverrides = Partial<PressAndHoldHost> & { record?: PressAndHoldRecord | null }
/** A macOS host whose domain has no explicit value — the state every affected user is in. */
function createHost(overrides: HostOverrides = {}): {
host: PressAndHoldHost
writes: { domain: string; value: boolean }[]
records: PressAndHoldRecord[]
} {
const writes: { domain: string; value: boolean }[] = []
const records: PressAndHoldRecord[] = []
let stored = overrides.record ?? null
const host: PressAndHoldHost = {
platform: 'darwin',
resolveBundleIdentifier: () => ORCA_DOMAIN,
readRecord: () => stored,
writeRecord: (record) => {
stored = record
records.push(record)
},
readDomainPreference: () => 'unset',
writeDomainPreference: (domain, value) => {
writes.push({ domain, value })
return true
},
now: () => '2026-08-20T00:00:00.000Z',
...overrides
}
return { host, writes, records }
}
function terminalRecord(decision: PressAndHoldDecision): PressAndHoldRecord {
return { version: 1, decision, domain: ORCA_DOMAIN, decidedAt: '2026-01-01T00:00:00.000Z' }
}
describe('ensureMacPressAndHoldDefault', () => {
it('writes the opt-out when the domain has no explicit value', () => {
const { host, writes, records } = createHost()
expect(ensureMacPressAndHoldDefault(host)).toBe('applied')
expect(writes).toEqual([{ domain: ORCA_DOMAIN, value: false }])
expect(records.at(-1)?.decision).toBe('applied')
})
describe('platform guard', () => {
for (const platform of ['win32', 'linux'] as const) {
it(`does nothing at all on ${platform}`, () => {
const probe = vi.fn(() => 'unset' as const)
const readRecord = vi.fn(() => null)
const { host, writes, records } = createHost({
platform,
readDomainPreference: probe,
readRecord
})
expect(ensureMacPressAndHoldDefault(host)).toBe('not-macos')
// Why assert the reads too: "inert" means no `defaults` subprocess and no userData I/O,
// not merely no write.
expect(probe).not.toHaveBeenCalled()
expect(readRecord).not.toHaveBeenCalled()
expect(writes).toEqual([])
expect(records).toEqual([])
})
}
})
describe('respects an explicit user setting', () => {
it('leaves the domain alone when the user already set a value', () => {
// One arm covers both `true` and `false`: the probe deliberately reports only presence, so
// whichever way the user's value points it is theirs. That the probe really does report
// presence for both is pinned against the real binary in the .defaults-domain test.
const { host, writes, records } = createHost({ readDomainPreference: () => 'set' })
expect(ensureMacPressAndHoldDefault(host)).toBe('kept-user-preference')
expect(writes).toEqual([])
expect(records.at(-1)?.decision).toBe('kept-user-preference')
})
it('never re-applies after a launch already decided', () => {
for (const decision of ['applied', 'kept-user-preference'] as const) {
const probe = vi.fn(() => 'unset' as const)
const { host, writes } = createHost({
record: terminalRecord(decision),
readDomainPreference: probe
})
expect(ensureMacPressAndHoldDefault(host)).toBe('already-decided')
expect(probe).not.toHaveBeenCalled()
expect(writes).toEqual([])
}
})
it('stays out of the way after the user deletes the key we wrote', () => {
// The documented way back to the accent picker is `defaults delete`, which returns the
// domain to "unset". Without the recorded decision the next launch would rewrite it.
const { host, writes } = createHost({
record: terminalRecord('applied'),
readDomainPreference: () => 'unset'
})
expect(ensureMacPressAndHoldDefault(host)).toBe('already-decided')
expect(writes).toEqual([])
})
})
describe('domains we do not own', () => {
it('skips a bare Electron bundle rather than writing into a shared domain', () => {
const probe = vi.fn(() => 'unset' as const)
const { host, writes, records } = createHost({
resolveBundleIdentifier: () => 'com.github.Electron',
readDomainPreference: probe
})
expect(ensureMacPressAndHoldDefault(host)).toBe('foreign-bundle')
expect(probe).not.toHaveBeenCalled()
expect(writes).toEqual([])
expect(records.at(-1)?.domain).toBe('com.github.Electron')
})
it('skips when the bundle identifier cannot be resolved', () => {
const { host, writes } = createHost({ resolveBundleIdentifier: () => null })
expect(ensureMacPressAndHoldDefault(host)).toBe('foreign-bundle')
expect(writes).toEqual([])
})
it('accepts Orca and its channel-scoped bundles, and nothing else', () => {
expect(isOrcaPreferencesDomain('com.stablyai.orca')).toBe(true)
expect(isOrcaPreferencesDomain('com.stablyai.orca.dev')).toBe(true)
expect(isOrcaPreferencesDomain('com.github.Electron')).toBe(false)
// Why: a prefix test without the dot would accept a lookalike bundle id.
expect(isOrcaPreferencesDomain('com.stablyai.orcafake')).toBe(false)
})
})
describe('failures leave the decision open', () => {
it('does not write when the probe cannot tell unset from user-set', () => {
const { host, writes, records } = createHost({ readDomainPreference: () => 'unknown' })
expect(ensureMacPressAndHoldDefault(host)).toBe('probe-failed')
expect(writes).toEqual([])
expect(records.at(-1)?.decision).toBe('probe-failed')
})
it('retries on the next launch after a failed write', () => {
const { host, records } = createHost({ writeDomainPreference: () => false })
expect(ensureMacPressAndHoldDefault(host)).toBe('write-failed')
const retry = createHost({ record: records.at(-1) })
expect(ensureMacPressAndHoldDefault(retry.host)).toBe('applied')
expect(retry.writes).toEqual([{ domain: ORCA_DOMAIN, value: false }])
})
})
it('does not rewrite an unchanged non-terminal record', () => {
const first = createHost({ resolveBundleIdentifier: () => 'com.github.Electron' })
expect(ensureMacPressAndHoldDefault(first.host)).toBe('foreign-bundle')
const second = createHost({
record: first.records.at(-1),
resolveBundleIdentifier: () => 'com.github.Electron'
})
expect(ensureMacPressAndHoldDefault(second.host)).toBe('foreign-bundle')
expect(second.records).toEqual([])
})
})
/**
* Why here and not only in the .defaults-domain file: that file is macOS-only and CI has no macOS
* runner, so this three-way rule would otherwise be enforced nowhere in CI. These cases need no
* `defaults` binary — the sibling file pins that the real binary produces them.
*/
describe('interpretDefaultsRead', () => {
function probe(overrides: Partial<ProcessResult>): () => ProcessResult {
return () => ({ code: 0, signal: null, stdout: '', stderr: '', timedOut: false, ...overrides })
}
it('reads a clean exit as an explicit value', () => {
expect(interpretDefaultsRead(probe({ code: 0 }))).toBe('set')
})
it('reads only "does not exist" as unset', () => {
expect(interpretDefaultsRead(probe({ code: 1 }))).toBe('unset')
})
describe('refuses to call a failed probe unset', () => {
// Each of these would otherwise overwrite a value the user deliberately set.
const failures: [string, () => ProcessResult][] = [
['killed before it exited', probe({ code: null, signal: 'SIGTERM' })],
['timed out', probe({ code: null, timedOut: true })],
[
'timed out after somehow reporting the missing-key code',
probe({ code: 1, timedOut: true })
],
['exited with some other error', probe({ code: 2 })],
['exited with a negative status', probe({ code: -1 })],
[
'never started at all',
() => {
throw Object.assign(new Error('spawn ENOENT'), { code: 'ENOENT' })
}
]
]
for (const [name, failing] of failures) {
it(name, () => {
expect(interpretDefaultsRead(failing)).toBe('unknown')
})
}
})
})
describe('readBundleIdentifierFromExecutablePath', () => {
const roots: string[] = []
afterEach(() => {
for (const root of roots.splice(0)) {
rmSync(root, { recursive: true, force: true })
}
})
function bundleWithPlist(body: string): string {
const root = mkdtempSync(join(tmpdir(), 'orca-press-hold-'))
roots.push(root)
mkdirSync(join(root, 'Orca.app', 'Contents', 'MacOS'), { recursive: true })
writeFileSync(join(root, 'Orca.app', 'Contents', 'Info.plist'), body)
return join(root, 'Orca.app', 'Contents', 'MacOS', 'Orca')
}
it('reads CFBundleIdentifier from the plist beside the executable', () => {
const exe = bundleWithPlist(
'<plist><dict>\n<key>CFBundleName</key>\n<string>Orca</string>\n' +
'<key>CFBundleIdentifier</key>\n\t<string>com.stablyai.orca</string>\n</dict></plist>'
)
expect(readBundleIdentifierFromExecutablePath(exe)).toBe('com.stablyai.orca')
})
it('returns null when the plist is missing or carries no identifier', () => {
expect(readBundleIdentifierFromExecutablePath('/nonexistent/App.app/Contents/MacOS/App')).toBe(
null
)
expect(readBundleIdentifierFromExecutablePath(bundleWithPlist('<plist><dict/></plist>'))).toBe(
null
)
expect(
readBundleIdentifierFromExecutablePath(
bundleWithPlist('<key>CFBundleIdentifier</key><string></string>')
)
).toBe(null)
})
})
describe('startup wiring', () => {
const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8')
it('runs before app.whenReady(), which is the last point AppKit could still see it', () => {
const callIndex = source.indexOf(
'applyMacPressAndHoldDefaultAtStartup(getCanonicalUserDataPath())'
)
const initDataPathIndex = source.indexOf('initDataPath()')
const readyIndex = source.indexOf('app.whenReady().then(')
expect(callIndex).toBeGreaterThanOrEqual(0)
expect(readyIndex).toBeGreaterThanOrEqual(0)
// Why after initDataPath: the record lives beside orca-data.json, and the canonical userData
// path is only captured there.
expect(callIndex).toBeGreaterThan(initDataPathIndex)
expect(callIndex).toBeLessThan(readyIndex)
})
})
+257
View File
@@ -0,0 +1,257 @@
import { mkdirSync, readFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { runProcessSync, type ProcessResult } from '../shared/child-process/run-process'
import { writeFileAtomically } from './codex-accounts/fs-utils'
/**
* Turns off the macOS accent picker for Orca's own preferences domain (#14746).
*
* macOS routes press-and-hold to the accent popup unless an app opts out via
* `ApplePressAndHoldEnabled`, so holding `j` in vim inserts one character instead of repeating.
* The key is unset by default, which is why every terminal-hosting Mac app ships this opt-out.
*
* Written once and never again: a user who wants the accent picker back sets
* `defaults write com.stablyai.orca ApplePressAndHoldEnabled -bool true` (or deletes the key), and
* the recorded decision below keeps a later launch from overwriting that choice.
*
* A fresh write is assumed to land for the *next* launch, not the current one: it goes out through
* a separate `defaults` process, so this app's own cached copy may not observe it. Whether AppKit
* re-reads sooner is untested, so the conservative assumption is the one encoded here.
*
* If this is ever reverted, delete the key as well — AppKit reads the plist, not this file, so
* removing the code alone leaves press-and-hold disabled forever for everyone who ran an affected
* build, with nothing left in the tree to explain it.
*
* The `defaults`-binary behaviour this relies on is pinned by the sibling test file, which runs
* only on a developer Mac: CI has no macOS test runner, so those tests never execute there.
*/
export const PRESS_AND_HOLD_KEY = 'ApplePressAndHoldEnabled'
export const PRESS_AND_HOLD_RECORD_FILE = 'macos-press-and-hold-default.json'
export const PRESS_AND_HOLD_RECORD_VERSION = 1
/** Why not `systemPreferences.getUserDefault`: it reads through the whole NSUserDefaults search
* list and is typed non-nullable, so an unset key and an explicit `false` both come back `false` —
* and the system default is unset, so it reports `false` on a Mac where press-and-hold is on.
* `defaults read <domain> <key>` is domain-scoped and exits 1 when the key is absent. */
const DEFAULTS_BINARY = '/usr/bin/defaults'
/** A hang cutoff, not a latency budget: the probe measures ~6ms, and cutting a slow-but-working
* `defaults` short costs the user the feature for that launch. The shared 30s default is what
* must not apply — this runs before `ready`, so a 30s block is a hung app. */
const DEFAULTS_TIMEOUT_MS = 5_000
/** Why: `defaults` exits 1 for "does not exist"; anything else means the probe itself failed. */
const DEFAULTS_MISSING_STATUS = 1
const ORCA_BUNDLE_ID = 'com.stablyai.orca'
export type PressAndHoldDecision =
/** Not macOS — nothing is read or written. */
| 'not-macos'
/** A previous launch already decided; the domain is never touched again. */
| 'already-decided'
/** The running bundle is not Orca's (e.g. a bare `Electron.app`), whose domain we do not own. */
| 'foreign-bundle'
/** `defaults read` could not answer, so we cannot tell an unset key from a user's choice. */
| 'probe-failed'
/** `defaults write` failed. */
| 'write-failed'
/** The domain already carries an explicit value; the user's choice stands. */
| 'kept-user-preference'
/** The key was unset and we wrote `false`. */
| 'applied'
/** Decisions about the domain itself. Anything else is a condition that can change by next launch. */
const TERMINAL_DECISIONS = new Set<PressAndHoldDecision>(['applied', 'kept-user-preference'])
export type PressAndHoldRecord = {
version: number
decision: PressAndHoldDecision
domain: string | null
decidedAt: string
}
export type PressAndHoldHost = {
platform: NodeJS.Platform
resolveBundleIdentifier: () => string | null
readRecord: () => PressAndHoldRecord | null
writeRecord: (record: PressAndHoldRecord) => void
readDomainPreference: (domain: string) => 'set' | 'unset' | 'unknown'
writeDomainPreference: (domain: string, value: boolean) => boolean
now: () => string
}
/** Only Orca's own bundle: an unpackaged run is `com.github.Electron`, shared with every other
* unpackaged Electron app on the machine. */
export function isOrcaPreferencesDomain(domain: string): boolean {
return domain === ORCA_BUNDLE_ID || domain.startsWith(`${ORCA_BUNDLE_ID}.`)
}
/** `<bundle>/Contents/MacOS/<exe>` → `<bundle>/Contents/Info.plist`. */
export function readBundleIdentifierFromExecutablePath(execPath: string): string | null {
try {
const plist = readFileSync(join(dirname(dirname(execPath)), 'Info.plist'), 'utf8')
const match = /<key>CFBundleIdentifier<\/key>\s*<string>([^<]*)<\/string>/.exec(plist)
const identifier = match?.[1]?.trim()
return identifier ? identifier : null
} catch {
return null
}
}
/**
* Why this distinction carries the whole design: only a real "does not exist" answer may be read as
* unset. A killed or timed-out probe never answered, and calling that unset would let a broken
* probe overwrite a value the user chose.
*/
export function interpretDefaultsRead(probe: () => ProcessResult): 'set' | 'unset' | 'unknown' {
let result: ProcessResult
try {
result = probe()
} catch {
// `runProcessSync` throws only when the child never started, which answers nothing at all.
return 'unknown'
}
// Why before the code: a timeout kills mid-run, so any exit it leaves behind is not an answer.
if (result.timedOut) {
return 'unknown'
}
if (result.code === 0) {
return 'set'
}
return result.code === DEFAULTS_MISSING_STATUS ? 'unset' : 'unknown'
}
export function readDomainPressAndHoldPreference(domain: string): 'set' | 'unset' | 'unknown' {
// Why no captured output: the exit code is the whole answer, and reading only presence is what
// makes an explicit `true` and an explicit `false` both count as the user's.
return interpretDefaultsRead(() =>
runProcessSync({
program: DEFAULTS_BINARY,
args: ['read', domain, PRESS_AND_HOLD_KEY],
timeoutMs: DEFAULTS_TIMEOUT_MS,
stdio: ['ignore', 'ignore', 'ignore']
})
)
}
export function writeDomainPressAndHoldPreference(domain: string, value: boolean): boolean {
try {
// Why the code check: a non-zero `defaults write` returns rather than throwing, so treating a
// completed run as success would record 'applied' for a write that never landed.
return (
runProcessSync({
program: DEFAULTS_BINARY,
args: ['write', domain, PRESS_AND_HOLD_KEY, '-bool', value ? 'true' : 'false'],
timeoutMs: DEFAULTS_TIMEOUT_MS,
stdio: ['ignore', 'ignore', 'ignore']
}).code === 0
)
} catch {
return false
}
}
export function pressAndHoldRecordPath(userDataPath: string): string {
return join(userDataPath, PRESS_AND_HOLD_RECORD_FILE)
}
function parseRecord(raw: string): PressAndHoldRecord | null {
const parsed = JSON.parse(raw) as Partial<PressAndHoldRecord>
if (parsed.version !== PRESS_AND_HOLD_RECORD_VERSION || typeof parsed.decision !== 'string') {
return null
}
return {
version: PRESS_AND_HOLD_RECORD_VERSION,
decision: parsed.decision as PressAndHoldDecision,
domain: typeof parsed.domain === 'string' ? parsed.domain : null,
decidedAt: typeof parsed.decidedAt === 'string' ? parsed.decidedAt : ''
}
}
/**
* Apply Orca's press-and-hold default at most once, leaving any explicit user value alone.
*
* Returns the decision so startup can log it; the same value is persisted for support triage.
*/
export function ensureMacPressAndHoldDefault(host: PressAndHoldHost): PressAndHoldDecision {
// Why first and without touching disk: Windows and Linux must do no work at all here.
if (host.platform !== 'darwin') {
return 'not-macos'
}
const previous = host.readRecord()
if (previous && TERMINAL_DECISIONS.has(previous.decision)) {
return 'already-decided'
}
const record = (decision: PressAndHoldDecision, domain: string | null): PressAndHoldDecision => {
// Why skip an unchanged rewrite: the non-terminal decisions are re-evaluated every launch.
if (!previous || previous.decision !== decision || previous.domain !== domain) {
host.writeRecord({
version: PRESS_AND_HOLD_RECORD_VERSION,
decision,
domain,
decidedAt: host.now()
})
}
return decision
}
const domain = host.resolveBundleIdentifier()
if (!domain || !isOrcaPreferencesDomain(domain)) {
return record('foreign-bundle', domain)
}
const existing = host.readDomainPreference(domain)
if (existing === 'unknown') {
return record('probe-failed', domain)
}
if (existing === 'set') {
return record('kept-user-preference', domain)
}
if (!host.writeDomainPreference(domain, false)) {
return record('write-failed', domain)
}
return record('applied', domain)
}
/** Why log: silently rewriting a macOS default for the user's login account should leave a trace,
* and the failure paths are otherwise invisible. The quiet decisions are the steady state. */
const REPORTED_DECISIONS: Partial<Record<PressAndHoldDecision, string>> = {
applied: `set ${PRESS_AND_HOLD_KEY}=false so held keys repeat; takes effect next launch`,
'probe-failed': `could not read ${PRESS_AND_HOLD_KEY}; leaving it alone`,
'write-failed': `could not write ${PRESS_AND_HOLD_KEY}; held keys will not repeat`
}
/** Wires {@link ensureMacPressAndHoldDefault} to the real bundle, `defaults`, and userData. */
export function applyMacPressAndHoldDefaultAtStartup(userDataPath: string): PressAndHoldDecision {
const recordPath = pressAndHoldRecordPath(userDataPath)
const decision = ensureMacPressAndHoldDefault({
platform: process.platform,
resolveBundleIdentifier: () => readBundleIdentifierFromExecutablePath(process.execPath),
readRecord: () => {
try {
return parseRecord(readFileSync(recordPath, 'utf8'))
} catch {
return null
}
},
writeRecord: (record) => {
try {
// Why: this runs before `ready`, and Electron has not necessarily created userData yet.
mkdirSync(userDataPath, { recursive: true })
writeFileAtomically(recordPath, `${JSON.stringify(record, null, 2)}\n`)
} catch {
// Best-effort: an unwritten record only costs one repeated probe next launch.
}
},
readDomainPreference: readDomainPressAndHoldPreference,
writeDomainPreference: writeDomainPressAndHoldPreference,
now: () => new Date().toISOString()
})
const reported = REPORTED_DECISIONS[decision]
if (reported) {
console.log(`[press-and-hold] ${reported}`)
}
return decision
}
@@ -3,8 +3,11 @@ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from '
import { homedir } from 'node:os'
import { dirname } from 'node:path'
import { getDefaultPersistedState, getDefaultWorkspaceSession } from '../../shared/constants'
import type { ExecutionHostId } from '../../shared/execution-host'
import { projectHostSetupProjectionFromRepos } from '../../shared/project-host-setup-projection'
import {
normalizeProjectHostSetupRows,
normalizeProjectRows
} from '../../shared/project-catalog-row-normalization'
import { carryProjectStateThroughIdentityChange } from '../../shared/project-identity-succession'
import type { PersistedState } from '../../shared/persisted-state-types'
import type { Project, ProjectHostSetup } from '../../shared/project-types'
@@ -16,16 +19,16 @@ import { getOrcaProfileDataFile } from './profile-index-store'
export type TransferProfileState = PersistedState
function isRecord(value: unknown): value is Record<string, unknown> {
function isRecord<T>(value: unknown): value is Record<string, T> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function arrayOrEmpty<T>(value: unknown): T[] {
return Array.isArray(value) ? (value as T[]) : []
return Array.isArray(value) ? value : []
}
function recordOrEmpty<T>(value: unknown): Record<string, T> {
return isRecord(value) ? (value as Record<string, T>) : {}
return isRecord<T>(value) ? value : {}
}
export function readProfileState(profileId: string, userDataPath: string): TransferProfileState {
@@ -34,13 +37,17 @@ export function readProfileState(profileId: string, userDataPath: string): Trans
if (!existsSync(dataFile)) {
return structuredClone(defaults)
}
const parsed = JSON.parse(readFileSync(dataFile, 'utf-8')) as Partial<PersistedState>
const parsed: Partial<PersistedState> = JSON.parse(readFileSync(dataFile, 'utf-8'))
return rebuildRepoBackedProjectState({
...defaults,
...parsed,
repos: arrayOrEmpty<Repo>(parsed.repos),
projects: arrayOrEmpty<Project>(parsed.projects),
projectHostSetups: arrayOrEmpty<ProjectHostSetup>(parsed.projectHostSetups),
// Why normalize: another profile's file is untrusted JSON, and a null repoId/path here would be
// carried straight into the importing app's state.
projects: normalizeProjectRows(arrayOrEmpty<Project>(parsed.projects)),
projectHostSetups: normalizeProjectHostSetupRows(
arrayOrEmpty<ProjectHostSetup>(parsed.projectHostSetups)
),
projectGroups: arrayOrEmpty(parsed.projectGroups),
folderWorkspaces: arrayOrEmpty(parsed.folderWorkspaces),
sparsePresetsByRepo: recordOrEmpty<SparsePreset[]>(parsed.sparsePresetsByRepo),
@@ -59,17 +66,15 @@ export function readProfileState(profileId: string, userDataPath: string): Trans
ui: isRecord(parsed.ui) ? { ...defaults.ui, ...parsed.ui } : defaults.ui,
githubCache: isRecord(parsed.githubCache)
? {
pr: recordOrEmpty((parsed.githubCache as PersistedState['githubCache']).pr),
issue: recordOrEmpty((parsed.githubCache as PersistedState['githubCache']).issue)
pr: recordOrEmpty(parsed.githubCache.pr),
issue: recordOrEmpty(parsed.githubCache.issue)
}
: defaults.githubCache,
workspaceSession: isRecord(parsed.workspaceSession)
? { ...getDefaultWorkspaceSession(), ...parsed.workspaceSession }
: defaults.workspaceSession,
workspaceSessionsByHostId: isRecord(parsed.workspaceSessionsByHostId)
? (parsed.workspaceSessionsByHostId as Partial<
Record<ExecutionHostId, WorkspaceSessionState>
>)
workspaceSessionsByHostId: isRecord<WorkspaceSessionState>(parsed.workspaceSessionsByHostId)
? parsed.workspaceSessionsByHostId
: {},
sshTargets: arrayOrEmpty(parsed.sshTargets),
sshRemotePtyLeases: arrayOrEmpty(parsed.sshRemotePtyLeases),
@@ -21,7 +21,8 @@ import { normalizeLoadedUiState } from './normalize-loaded-ui-state'
import {
normalizeLoadedAutomationRuns,
normalizeLoadedHostSessions,
normalizeLoadedLocalSession
normalizeLoadedLocalSession,
normalizeLoadedProjectCatalog
} from './normalize-loaded-state-collections'
import { normalizeRetiredNameRegistryMap } from './retired-name-registry-normalization'
@@ -33,6 +34,7 @@ export function normalizeLoadedProfileState(
): PersistedState {
const { defaults, migratedExternalVisibility, osc52ClipboardNoticePending } = terminal
const { normalizedOnboarding, normalizedProjectGroups, loadedCompactWorktreeCards } = profile
const projectCatalog = normalizeLoadedProjectCatalog(parsed, markNeedsSave)
return {
...defaults,
@@ -42,6 +44,9 @@ export function normalizeLoadedProfileState(
),
projectGroups: normalizedProjectGroups,
repos: migratedExternalVisibility.repos,
// Why: persisted catalog rows are untrusted JSON; consumers call string methods on fields the type says are strings.
projects: projectCatalog.projects,
projectHostSetups: projectCatalog.projectHostSetups,
folderWorkspaces: normalizeFolderWorkspaces(parsed.folderWorkspaces, normalizedProjectGroups),
folderWorkspaceDiffComments: normalizeFolderWorkspaceDiffComments(
parsed.folderWorkspaceDiffComments
@@ -0,0 +1,67 @@
import { describe, expect, it, vi } from 'vitest'
import type { PersistedState } from '../../../shared/persisted-state-types'
import type { Project, ProjectHostSetup } from '../../../shared/project-types'
import { normalizeLoadedProjectCatalog } from './normalize-loaded-state-collections'
// Why Reflect.set and not a cast: these rows deliberately violate their declared types, which is
// exactly what a stale on-disk catalog does.
function corrupt<T extends object>(row: T, overrides: Record<string, unknown>): T {
for (const [key, value] of Object.entries(overrides)) {
Reflect.set(row, key, value)
}
return row
}
function makeParsed(
setups: ProjectHostSetup[],
projects: Project[] = []
): Partial<Pick<PersistedState, 'projects' | 'projectHostSetups'>> {
return { projects, projectHostSetups: setups }
}
function makeBadSetup(): ProjectHostSetup {
const setup: ProjectHostSetup = {
id: 'setup-1',
projectId: 'project-1',
hostId: 'local',
repoId: 'repo-1',
path: '/Users/alice/orca',
displayName: 'orca',
setupState: 'ready',
setupMethod: 'legacy-repo',
createdAt: 1,
updatedAt: 1
}
return corrupt(setup, { repoId: null, path: null })
}
describe('normalizeLoadedProjectCatalog', () => {
it('repairs stored rows whose field types do not match the declared ones', () => {
const result = normalizeLoadedProjectCatalog(makeParsed([makeBadSetup()]), vi.fn())
expect(result.projectHostSetups[0]?.repoId).toBe('')
expect(result.projectHostSetups[0]?.path).toBe('')
})
// Why: without a save the bad rows stay on disk, get repaired again every launch, and this
// host keeps publishing them to paired clients. Marking dirty is the migration.
it('marks the profile dirty so the repair is persisted', () => {
const markNeedsSave = vi.fn()
normalizeLoadedProjectCatalog(makeParsed([makeBadSetup()]), markNeedsSave)
expect(markNeedsSave).toHaveBeenCalled()
})
it('leaves a conforming catalog untouched and does not schedule a save', () => {
const markNeedsSave = vi.fn()
const setups = [{ ...makeBadSetup(), repoId: 'repo-1', path: '/repo' }]
const parsed = makeParsed(setups)
const result = normalizeLoadedProjectCatalog(parsed, markNeedsSave)
expect(result.projectHostSetups).toBe(setups)
expect(markNeedsSave).not.toHaveBeenCalled()
})
it('tolerates missing or non-array collections', () => {
const result = normalizeLoadedProjectCatalog({}, vi.fn())
expect(result.projects).toEqual([])
expect(result.projectHostSetups).toEqual([])
})
})
@@ -1,4 +1,8 @@
import type { PersistedState } from '../../../shared/persisted-state-types'
import {
normalizeProjectHostSetupRows,
normalizeProjectRows
} from '../../../shared/project-catalog-row-normalization'
import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types'
import { parseWorkspaceSessionSalvaging } from '../../../shared/workspace-session-salvage'
import {
@@ -64,3 +68,21 @@ export function normalizeLoadedAutomationRuns(
}
return runs
}
/**
* Repairs project/setup rows whose stored field types do not match the declared ones — a null
* `repoId` or `path` written by an older build reaches every consumer that calls `.trim()` on it.
* Marking dirty is the migration: without a save the bad rows stay on disk and are repaired again
* every launch, and this host keeps publishing them to paired clients over the wire.
*/
export function normalizeLoadedProjectCatalog(
parsed: Partial<Pick<PersistedState, 'projects' | 'projectHostSetups'>>,
markNeedsSave: () => void
): Pick<PersistedState, 'projects' | 'projectHostSetups'> {
const projects = normalizeProjectRows(parsed.projects ?? [])
const projectHostSetups = normalizeProjectHostSetupRows(parsed.projectHostSetups ?? [])
if (projects !== parsed.projects || projectHostSetups !== parsed.projectHostSetups) {
markNeedsSave()
}
return { projects, projectHostSetups }
}
@@ -162,13 +162,13 @@ describe.skipIf(!bashAvailable)('Codex preflight under a profile-rewritten PATH'
expect(readFileSync(fixture.codexMarker, 'utf-8').trim()).toBe('--alias-flag --version')
})
// Why: pins the defect itself, so a regression back to an unqualified name fails here.
it('would run the impostor if the preflight carried an unqualified command name', () => {
// Why: an unqualified preflight is rejected so profile PATH entries cannot hijack it.
it('skips an unqualified preflight value while still launching codex', () => {
const fixture = buildFixture()
launchCodexThroughRcfile(fixture, 'orca')
expect(existsSync(fixture.hijackMarker)).toBe(true)
expect(existsSync(fixture.hijackMarker)).toBe(false)
expect(existsSync(fixture.intendedMarker)).toBe(false)
expect(existsSync(fixture.codexMarker)).toBe(true)
})
@@ -170,6 +170,30 @@ describe.skipIf(process.platform === 'win32')('Codex shell launch preflight', ()
)
})
it('does not trigger a preflight outside an Orca terminal', () => {
const root = mkdtempSync(join(tmpdir(), 'orca-codex-plain-shell-'))
roots.push(root)
const bin = join(root, 'bin')
const marker = join(root, 'preflight-ran')
mkdirSync(bin)
writeExecutable(join(bin, 'codex'), '#!/bin/sh\nprintf launched\n')
writeExecutable(join(bin, 'orca-test'), `#!/bin/sh\nprintf ran > ${JSON.stringify(marker)}\n`)
const output = execFileSync(
'/bin/bash',
['--noprofile', '--norc', '-c', `${getPosixCodexShellLaunchPreflight()}\ncodex`],
{
encoding: 'utf-8',
env: {
PATH: `${bin}${delimiter}${process.env.PATH ?? ''}`
}
}
)
expect(output.trim()).toBe('launched')
expect(existsSync(marker)).toBe(false)
})
// Why a loop over it.each: an early `return` reported green on hosts without the
// shell, so the zsh half silently never ran on Linux. skipIf reports it as a skip.
for (const [shell, strict] of [
@@ -319,6 +343,24 @@ describe('Codex shell launch preflight command', () => {
).toBe(launcherPath)
})
it('carries the packaged Windows launcher for WSLENV path translation', () => {
const { userDataPath, resourcesPath } = makeCliRoot()
const launcherPath = join(resourcesPath, 'bin', 'orca.exe')
writeExecutable(launcherPath, '#!/bin/sh\nexit 0\n')
expect(
resolveCodexShellLaunchPreflightCommand({
hooksEnabled: true,
isPackaged: true,
isWsl: true,
managedHomePath: '/home/jin/.local/share/orca/codex-runtime-home/home',
userDataPath,
resourcesPath,
platform: 'win32'
})
).toBe(launcherPath)
})
it('never returns an unqualified command name that a profile-rewritten PATH could hijack', () => {
const { userDataPath, resourcesPath } = makeCliRoot()
writeExecutable(join(resourcesPath, 'bin', 'orca'), '#!/bin/sh\nexit 0\n')
@@ -340,16 +382,12 @@ describe('Codex shell launch preflight command', () => {
it.each([
{ label: 'the launcher file is missing', create: null },
{ label: 'the launcher is not executable', create: 0o644 },
{ label: 'the launcher path is a directory', create: 'directory' as const }
])('skips the preflight when $label', (config) => {
const { userDataPath, resourcesPath } = makeCliRoot()
const launcherPath = join(resourcesPath, 'bin', 'orca')
if (config.create === 'directory') {
mkdirSync(launcherPath)
} else if (config.create !== null) {
writeFileSync(launcherPath, '#!/bin/sh\nexit 0\n')
chmodSync(launcherPath, config.create)
}
expect(
@@ -364,6 +402,27 @@ describe('Codex shell launch preflight command', () => {
).toBeNull()
})
it.skipIf(process.platform === 'win32')(
'skips the preflight when the launcher is not executable',
() => {
const { userDataPath, resourcesPath } = makeCliRoot()
const launcherPath = join(resourcesPath, 'bin', 'orca')
writeFileSync(launcherPath, '#!/bin/sh\nexit 0\n')
chmodSync(launcherPath, 0o644)
expect(
resolveCodexShellLaunchPreflightCommand({
hooksEnabled: true,
isPackaged: true,
managedHomePath: '/managed/home',
userDataPath,
resourcesPath,
platform: 'darwin'
})
).toBeNull()
}
)
it('skips the preflight when the packaged build exposes no resources root', () => {
const { userDataPath } = makeCliRoot()
@@ -381,7 +440,7 @@ describe('Codex shell launch preflight command', () => {
it.each([
{ hooksEnabled: false, isWsl: false, managedHomePath: '/managed/home' },
{ hooksEnabled: true, isWsl: true, managedHomePath: '/managed/home' },
{ hooksEnabled: true, isWsl: true, managedHomePath: '/managed/home', isPackaged: false },
{ hooksEnabled: true, isWsl: false, managedHomePath: null }
])('does not enable an unsupported preflight for %o', (options) => {
const { userDataPath, resourcesPath } = makeCliRoot()
@@ -390,7 +449,7 @@ describe('Codex shell launch preflight command', () => {
expect(
resolveCodexShellLaunchPreflightCommand({
...options,
isPackaged: true,
isPackaged: options.isPackaged ?? true,
userDataPath,
resourcesPath,
platform: 'darwin'
+11 -4
View File
@@ -29,7 +29,7 @@ export type CodexShellLaunchPreflightCommandOptions = {
export function resolveCodexShellLaunchPreflightCommand(
options: CodexShellLaunchPreflightCommandOptions
): string | null {
if (!options.hooksEnabled || options.isWsl || !options.managedHomePath) {
if (!options.hooksEnabled || !options.managedHomePath) {
return null
}
const platform = options.platform ?? process.platform
@@ -42,7 +42,14 @@ export function resolveCodexShellLaunchPreflightCommand(
...DEV_LAUNCHER_DIR,
platform === 'win32' ? `${DEV_COMMAND_NAME}.cmd` : DEV_COMMAND_NAME
)
return candidate && isExecutableFileOnDisk(candidate, platform) ? candidate : null
if (!candidate || !isExecutableFileOnDisk(candidate, platform)) {
return null
}
if (!options.isWsl) {
return candidate
}
// Why: WSLENV /p translates the verified Windows launcher with the distro's configured automount root.
return platform === 'win32' && options.isPackaged ? candidate : null
}
function isExecutableFileOnDisk(path: string, platform: NodeJS.Platform): boolean {
@@ -65,7 +72,7 @@ export function getPosixCodexShellLaunchPreflight(): string {
# Why || : twice — zsh alone aborts inside the substitution, but every shell's
# assignment adopts its exit status, so an absent codex trips set -e in bash too.
__orca_codex_binary="$(unalias codex 2>/dev/null || :; command -v codex 2>/dev/null || :)"
if [[ -n "\${ORCA_CODEX_LAUNCH_PREFLIGHT:-}" && -n "\${__orca_codex_binary:-}" && -x "\${__orca_codex_binary}" ]]; then
if [[ -n "\${ORCA_CODEX_LAUNCH_PREFLIGHT:-}" && -x "\${ORCA_CODEX_LAUNCH_PREFLIGHT}" && -n "\${__orca_codex_binary:-}" && -x "\${__orca_codex_binary}" ]]; then
# Why the function reserved word: it suppresses alias expansion of the name,
# which otherwise rewrites this header at parse time and aborts the whole file.
function codex {
@@ -78,7 +85,7 @@ unset __orca_codex_binary
}
export function getFishCodexShellLaunchPreflight(): string {
return `if test -n "$ORCA_CODEX_LAUNCH_PREFLIGHT"; and test (type -t codex 2>/dev/null) = file
return `if test -x "$ORCA_CODEX_LAUNCH_PREFLIGHT"; and test (type -t codex 2>/dev/null) = file
function codex
command "$ORCA_CODEX_LAUNCH_PREFLIGHT" agent hooks prepare-codex >/dev/null 2>&1; or true
command codex $argv
+3 -1
View File
@@ -73,6 +73,7 @@ describe('addOrcaWslInteropEnv', () => {
ORCA_AGENT_HOOK_TOKEN: 'token',
ORCA_AGENT_HOOK_ENV: 'dev',
ORCA_AGENT_HOOK_VERSION: '1',
ORCA_AGENT_HOOK_TRANSPORT: 'raw-json-v1',
ORCA_WSL_HOOK_INSTANCE: 'testinstance',
ORCA_ORCHESTRATION_COMPATIBILITY_HOST_KIND: 'wsl',
ORCA_ORCHESTRATION_COMPATIBILITY_HOST_ID: 'local',
@@ -84,7 +85,7 @@ describe('addOrcaWslInteropEnv', () => {
expect(env.WSLENV).toContain('ORCA_TERMINAL_HANDLE/u')
expect(env.WSLENV).toContain('ORCA_USER_DATA_PATH/p')
expect(env.WSLENV).toContain('ORCA_CLI_COMMAND/u')
expect(env.WSLENV).not.toContain('ORCA_CODEX_LAUNCH_PREFLIGHT')
expect(env.WSLENV).toContain('ORCA_CODEX_LAUNCH_PREFLIGHT/p')
expect(env.WSLENV).toContain('ORCA_OMP_STATUS_EXTENSION/p')
expect(env.WSLENV).not.toContain('ORCA_PRIME_AGENT_STATUS_EXTENSION')
expect(env.WSLENV).toContain('ORCA_PANE_KEY/u')
@@ -95,6 +96,7 @@ describe('addOrcaWslInteropEnv', () => {
expect(env.WSLENV).toContain('ORCA_AGENT_HOOK_TOKEN/u')
expect(env.WSLENV).toContain('ORCA_AGENT_HOOK_ENV/u')
expect(env.WSLENV).toContain('ORCA_AGENT_HOOK_VERSION/u')
expect(env.WSLENV).toContain('ORCA_AGENT_HOOK_TRANSPORT/u')
expect(env.WSLENV).toContain('ORCA_WSL_HOOK_INSTANCE/u')
expect(env.WSLENV).toContain('ORCA_ORCHESTRATION_COMPATIBILITY_HOST_KIND/u')
expect(env.WSLENV).toContain('ORCA_ORCHESTRATION_COMPATIBILITY_HOST_ID/u')
+2
View File
@@ -77,6 +77,7 @@ export function addOrcaWslInteropEnv(env: Record<string, string>): void {
// and it cannot derive the hash segment from ORCA_USER_DATA_PATH alone.
'ORCA_SHELL_READY_ROOT/p',
'ORCA_CLI_COMMAND/u',
'ORCA_CODEX_LAUNCH_PREFLIGHT/p',
'ORCA_PANE_KEY/u',
'ORCA_TAB_ID/u',
'ORCA_WORKTREE_ID/u',
@@ -90,6 +91,7 @@ export function addOrcaWslInteropEnv(env: Record<string, string>): void {
'ORCA_AGENT_HOOK_TOKEN/u',
'ORCA_AGENT_HOOK_ENV/u',
'ORCA_AGENT_HOOK_VERSION/u',
'ORCA_AGENT_HOOK_TRANSPORT/u',
`ORCA_AGENT_HOOK_ENDPOINT/${endpointFlag}`,
...opencodeOverlayEntries,
'ORCA_WSL_HOOK_RELAY_VERSION/u',
+1
View File
@@ -2845,6 +2845,7 @@ const AGENT_HOOK_RUNTIME_ENV_KEYS = [
'ORCA_AGENT_HOOK_TOKEN',
'ORCA_AGENT_HOOK_ENV',
'ORCA_AGENT_HOOK_VERSION',
'ORCA_AGENT_HOOK_TRANSPORT',
'ORCA_AGENT_HOOK_ENDPOINT'
] as const
@@ -0,0 +1,145 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { OrcaRuntimeService } from '../../orca-runtime'
import { isStreamingMethod, type RpcContext } from '../core'
const { installForRuntimeHomeSerializedMock, realpathMock } = vi.hoisted(() => ({
installForRuntimeHomeSerializedMock: vi.fn(),
realpathMock: vi.fn()
}))
vi.mock('../../../codex/hook-service', () => ({
codexHookService: { installForRuntimeHomeSerialized: installForRuntimeHomeSerializedMock }
}))
vi.mock('node:fs/promises', () => ({ realpath: realpathMock }))
import { AGENT_HOOK_METHODS } from './agent-hooks'
import {
_internals as managedWslHomeRegistryInternals,
recordManagedWslCodexHome
} from '../../../codex/managed-wsl-codex-home-registry'
const LINUX_HOME = '/home/jin/.local/share/orca/codex-runtime-home/home'
const RUNTIME_HOME =
'\\\\wsl.localhost\\Ubuntu-24.04\\home\\jin\\.local\\share\\orca\\codex-runtime-home\\home'
function prepareMethod() {
const method = AGENT_HOOK_METHODS.find(
(candidate) => candidate.name === 'agentHooks.prepareCodexForWslPane'
)
if (!method || isStreamingMethod(method)) {
throw new Error('Missing agentHooks.prepareCodexForWslPane request method')
}
return method
}
function runtimeWithSettings(enabled = true, disabledTuiAgents: string[] = []): OrcaRuntimeService {
return {
getClientSettings: vi.fn(() => ({
agentStatusHooksEnabled: enabled,
disabledTuiAgents
}))
} as unknown as OrcaRuntimeService
}
describe('agent hook RPC methods', () => {
beforeEach(() => {
installForRuntimeHomeSerializedMock.mockReset()
realpathMock.mockReset()
realpathMock.mockImplementation(async (path: string) => path)
managedWslHomeRegistryInternals.clearRecordedManagedWslCodexHomes()
recordManagedWslCodexHome('Ubuntu-24.04', RUNTIME_HOME)
})
it('installs the pane-selected WSL home once and returns its status', async () => {
const status = { agent: 'codex', state: 'installed' }
installForRuntimeHomeSerializedMock.mockResolvedValue(status)
const method = prepareMethod()
const params = method.params!.parse({
codexHome: LINUX_HOME,
orcaCodexHome: LINUX_HOME,
wslDistro: 'Ubuntu-24.04'
})
await expect(method.handler(params, { runtime: runtimeWithSettings() })).resolves.toBe(status)
expect(installForRuntimeHomeSerializedMock).toHaveBeenCalledExactlyOnceWith(RUNTIME_HOME, {
runtime: 'wsl',
wslDistro: 'Ubuntu-24.04'
})
})
it.each([
[false, []],
[true, ['codex']]
])('does not install when hooks are disabled (%s, %j)', async (enabled, disabledTuiAgents) => {
const method = prepareMethod()
const params = method.params!.parse({
codexHome: '/home/jin/.local/share/orca/codex-runtime-home/home',
orcaCodexHome: '/home/jin/.local/share/orca/codex-runtime-home/home',
wslDistro: 'Ubuntu-24.04'
})
await expect(
method.handler(params, { runtime: runtimeWithSettings(enabled, disabledTuiAgents) })
).resolves.toBeNull()
expect(installForRuntimeHomeSerializedMock).not.toHaveBeenCalled()
})
it.each(['runtime', 'mobile'] as const)('rejects non-local %s callers', async (clientKind) => {
const method = prepareMethod()
const params = method.params!.parse({
codexHome: '/home/jin/.local/share/orca/codex-runtime-home/home',
orcaCodexHome: '/home/jin/.local/share/orca/codex-runtime-home/home',
wslDistro: 'Ubuntu-24.04'
})
await expect(
method.handler(params, {
runtime: runtimeWithSettings(),
clientKind
} as RpcContext)
).rejects.toThrow(/only available to the local Orca CLI/)
expect(installForRuntimeHomeSerializedMock).not.toHaveBeenCalled()
})
it('propagates an attempted installer failure', async () => {
installForRuntimeHomeSerializedMock.mockRejectedValue(new Error('install failed'))
const method = prepareMethod()
const params = method.params!.parse({
codexHome: '/home/jin/.local/share/orca/codex-runtime-home/home',
orcaCodexHome: '/home/jin/.local/share/orca/codex-runtime-home/home',
wslDistro: 'Ubuntu-24.04'
})
await expect(method.handler(params, { runtime: runtimeWithSettings() })).rejects.toThrow(
'install failed'
)
expect(installForRuntimeHomeSerializedMock).toHaveBeenCalledOnce()
})
it('rejects a managed-looking home that resolves through a symlink', async () => {
realpathMock.mockResolvedValue(
'\\\\wsl.localhost\\Ubuntu-24.04\\home\\jin\\outside-managed-home'
)
const method = prepareMethod()
const params = method.params!.parse({
codexHome: LINUX_HOME,
orcaCodexHome: LINUX_HOME,
wslDistro: 'Ubuntu-24.04'
})
await expect(method.handler(params, { runtime: runtimeWithSettings() })).resolves.toBeNull()
expect(installForRuntimeHomeSerializedMock).not.toHaveBeenCalled()
})
it('rejects malformed distro names at the RPC schema', () => {
const method = prepareMethod()
expect(() =>
method.params!.parse({
codexHome: '/home/jin/.local/share/orca/codex-runtime-home/home',
orcaCodexHome: '/home/jin/.local/share/orca/codex-runtime-home/home',
wslDistro: 'Ubuntu\\..\\host'
})
).toThrow()
})
})
@@ -0,0 +1,38 @@
import { z } from 'zod'
import { prepareManagedWslCodexHomeBeforeShellLaunch } from '../../../codex/managed-wsl-home-shell-preflight'
import { defineMethod, type RpcMethod } from '../core'
const PrepareCodexForWslPaneParams = z
.object({
codexHome: z.string().max(4_096),
orcaCodexHome: z.string().max(4_096),
wslDistro: z
.string()
.trim()
.min(1)
.max(255)
.regex(/^[^\\/\r\n]+$/)
})
.strict()
export const AGENT_HOOK_METHODS: readonly RpcMethod[] = [
defineMethod({
name: 'agentHooks.prepareCodexForWslPane',
params: PrepareCodexForWslPaneParams,
handler: async (params, { runtime, clientKind }) => {
if (clientKind !== undefined) {
throw new Error('Codex hook preparation is only available to the local Orca CLI.')
}
const settings = runtime.getClientSettings()
return await prepareManagedWslCodexHomeBeforeShellLaunch({
env: {
CODEX_HOME: params.codexHome,
ORCA_CODEX_HOME: params.orcaCodexHome,
WSL_DISTRO_NAME: params.wslDistro
},
hooksEnabled:
settings.agentStatusHooksEnabled && !settings.disabledTuiAgents.includes('codex')
})
}
})
]
+2
View File
@@ -43,12 +43,14 @@ import { PAIRING_METHODS } from './pairing'
import { UPDATER_METHODS } from './updater'
import { AGENT_SESSION_METHODS } from './agent-session'
import { ARTIFACT_METHODS } from './artifacts'
import { AGENT_HOOK_METHODS } from './agent-hooks'
// Why: a flat manifest keeps registration order explicit and provides one
// grep-point for "what methods does the RPC server expose?" — useful when
// auditing the security boundary or wiring new CLI commands.
export const ALL_RPC_METHODS: readonly RpcAnyMethod[] = [
...STATUS_METHODS,
...AGENT_HOOK_METHODS,
...AI_VAULT_METHODS,
...ARTIFACT_METHODS,
...AUTOMATION_METHODS,
@@ -5,6 +5,7 @@ const mocks = vi.hoisted(() => ({
attachGuestPolicies: vi.fn(),
installNavigationPolicy: vi.fn(),
isAllowedPartition: vi.fn(),
attachRouteGuest: vi.fn(),
registerPluginGuard: vi.fn()
}))
@@ -20,20 +21,61 @@ vi.mock('../plugins/plugin-panel-navigation-guard', () => ({
vi.mock('./privileged-window-navigation', () => ({
installPrivilegedWindowNavigationPolicy: mocks.installNavigationPolicy
}))
vi.mock('../browser/browser-route-session-runtime', () => ({
browserRouteSessionRegistry: { isAllowedPartition: () => false },
browserRouteWebContentsRegistry: { attachGuest: mocks.attachRouteGuest }
}))
vi.mock('../browser/local-ssh-browser-partitions', () => ({
isLocalSshBrowserPartition: () => false,
enforceLocalSshWebRtcPolicyForGuest: vi.fn()
}))
vi.mock('../browser/doc-preview-protocol', () => ({
isDocPreviewSession: (candidate: unknown) => candidate === 'doc-preview-session'
}))
import { installMainWindowWebviewSecurity } from './main-window-webview-security'
import {
getDocPreviewGrant,
mintDocPreviewGrant,
revokeAllDocPreviewGrants
} from '../browser/doc-preview-grant-registry'
import {
publishDocPreviewFailure,
setDocPreviewFailureSink
} from '../browser/doc-preview-failure-notice'
import { buildDocPreviewUrl, DOC_PREVIEW_PARTITION } from '../../shared/doc-preview-scheme'
function installOnFakeWindow(): {
handlers: Record<string, (...args: never[]) => void>
webContents: { on: ReturnType<typeof vi.fn> }
} {
const handlers: Record<string, (...args: never[]) => void> = {}
const webContents = {
on: vi.fn((event: string, handler: (...args: never[]) => void) => {
handlers[event] = handler
})
}
installMainWindowWebviewSecurity({ webContents } as never)
return { handlers, webContents }
}
function mintPreviewGrant(): ReturnType<typeof mintDocPreviewGrant> {
return mintDocPreviewGrant({
owner: { kind: 'ssh', connectionId: 'ssh-1' },
root: '/home/alice/docs',
entryRelativePath: 'index.html',
browserPageId: 'page-1'
})
}
describe('main window webview security', () => {
beforeEach(() => vi.clearAllMocks())
beforeEach(() => {
vi.clearAllMocks()
revokeAllDocPreviewGrants()
})
it('fails closed before applying hardened guest preferences', () => {
const handlers: Record<string, (...args: never[]) => void> = {}
const webContents = {
on: vi.fn((event: string, handler: (...args: never[]) => void) => {
handlers[event] = handler
})
}
installMainWindowWebviewSecurity({ webContents } as never)
const { handlers, webContents } = installOnFakeWindow()
mocks.isAllowedPartition.mockReturnValue(false)
const preventDefault = vi.fn()
@@ -49,13 +91,7 @@ describe('main window webview security', () => {
})
it('removes renderer preload input and restores every hardened preference', () => {
const handlers: Record<string, (...args: never[]) => void> = {}
const webContents = {
on: vi.fn((event: string, handler: (...args: never[]) => void) => {
handlers[event] = handler
})
}
installMainWindowWebviewSecurity({ webContents } as never)
const { handlers } = installOnFakeWindow()
mocks.isAllowedPartition.mockReturnValue(true)
const params = { src: 'https://example.com', preload: 'attacker.js' }
const preferences: Record<string, unknown> = {
@@ -85,3 +121,198 @@ describe('main window webview security', () => {
expect(String(preferences.preload)).toMatch(/browser-window-close-preload\.js$/)
})
})
describe('orca-preview scheme admission', () => {
beforeEach(() => {
vi.clearAllMocks()
revokeAllDocPreviewGrants()
})
it('admits a preview URL only on the preview partition and only with a live grant', () => {
// Install first, as the window does: installation itself clears the registry.
const { handlers } = installOnFakeWindow()
const grant = mintPreviewGrant()
mocks.isAllowedPartition.mockReturnValue(false)
const preventDefault = vi.fn()
const preferences: Record<string, unknown> = {
partition: DOC_PREVIEW_PARTITION,
preload: 'attacker.js',
sandbox: false
}
handlers['will-attach-webview']?.(
{ preventDefault } as never,
preferences as never,
{ src: buildDocPreviewUrl(grant.id, 'index.html'), preload: 'attacker.js' } as never
)
expect(preventDefault).not.toHaveBeenCalled()
expect(preferences).toMatchObject({
partition: DOC_PREVIEW_PARTITION,
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
webSecurity: true
})
expect(preferences).not.toHaveProperty('preloadURL')
})
// Why both directions: the preview preload is the only script that can turn a press into a
// browser tab, so a browsing guest must never receive it — and a preview must never receive
// anything else, least of all a value the renderer supplied.
it('pins the preview preload onto a preview attach, replacing whatever the renderer asked for', () => {
const { handlers } = installOnFakeWindow()
const grant = mintPreviewGrant()
mocks.isAllowedPartition.mockReturnValue(false)
const params = { src: buildDocPreviewUrl(grant.id, 'index.html'), preload: 'attacker.js' }
const preferences: Record<string, unknown> = {
partition: DOC_PREVIEW_PARTITION,
preload: 'attacker.js'
}
handlers['will-attach-webview']?.(
{ preventDefault: vi.fn() } as never,
preferences as never,
params as never
)
expect(params).not.toHaveProperty('preload')
expect(String(preferences.preload)).toMatch(/doc-preview-link-preload\.js$/)
})
it('keeps the preview preload off a browsing attach', () => {
const { handlers } = installOnFakeWindow()
mocks.isAllowedPartition.mockReturnValue(true)
const preferences: Record<string, unknown> = { partition: 'persist:orca-browser' }
handlers['will-attach-webview']?.(
{ preventDefault: vi.fn() } as never,
preferences as never,
{ src: 'https://example.com' } as never
)
expect(String(preferences.preload)).toMatch(/browser-window-close-preload\.js$/)
})
it('denies a preview URL whose grant is unknown or revoked', () => {
const { handlers } = installOnFakeWindow()
mocks.isAllowedPartition.mockReturnValue(false)
const preventDefault = vi.fn()
handlers['will-attach-webview']?.(
{ preventDefault } as never,
{ partition: DOC_PREVIEW_PARTITION } as never,
{ src: `orca-preview://${'0'.repeat(32)}/index.html` } as never
)
expect(preventDefault).toHaveBeenCalledOnce()
})
it('denies a preview URL smuggled onto a browsing partition', () => {
const { handlers } = installOnFakeWindow()
const grant = mintPreviewGrant()
// Even an allowlisted browsing partition must not load the preview scheme.
mocks.isAllowedPartition.mockReturnValue(true)
const preventDefault = vi.fn()
handlers['will-attach-webview']?.(
{ preventDefault } as never,
{ partition: 'persist:orca-browser' } as never,
{ src: buildDocPreviewUrl(grant.id, 'index.html') } as never
)
expect(preventDefault).toHaveBeenCalledOnce()
})
it('denies a web URL on the preview partition', () => {
const { handlers } = installOnFakeWindow()
mocks.isAllowedPartition.mockReturnValue(false)
const preventDefault = vi.fn()
handlers['will-attach-webview']?.(
{ preventDefault } as never,
{ partition: DOC_PREVIEW_PARTITION } as never,
{ src: 'https://example.com' } as never
)
expect(preventDefault).toHaveBeenCalledOnce()
})
// Why the host is asserted and not just the profile: it is the renderer that minted the grant,
// and it is the only sink a link the reader presses can be reported to. A preview attached
// against another window's contents would report its clicks to a reader who is not there.
it('attaches a preview guest under the workspace-doc profile, hosted by this window', () => {
const { handlers, webContents } = installOnFakeWindow()
handlers['did-attach-webview']?.({} as never, { session: 'doc-preview-session' } as never)
expect(mocks.attachGuestPolicies).toHaveBeenCalledWith(
{ session: 'doc-preview-session' },
null,
{
profile: 'workspace-doc',
host: webContents
}
)
expect(mocks.attachRouteGuest).not.toHaveBeenCalled()
})
it('keeps browser guests on the browsing profile and its route registration', () => {
const { handlers } = installOnFakeWindow()
handlers['did-attach-webview']?.({} as never, { session: 'browser-session' } as never)
expect(mocks.attachGuestPolicies).toHaveBeenCalledOnce()
expect(mocks.attachGuestPolicies.mock.calls[0]?.[2]).toBeUndefined()
expect(mocks.attachRouteGuest).toHaveBeenCalledOnce()
})
// Why: every live preview belongs to this window, so its teardown is the one moment no grant can
// still have a reader — and the failure sink must stop pointing at dead WebContents.
it('drops the failure sink and every grant when the window contents are destroyed', () => {
const { handlers } = installOnFakeWindow()
const grant = mintPreviewGrant()
const send = vi.fn()
setDocPreviewFailureSink({ send })
expect(getDocPreviewGrant(grant.id)).not.toBeNull()
handlers['destroyed']?.()
publishDocPreviewFailure({
grantId: grant.id,
relativePath: 'index.html',
reason: 'unreadable'
})
expect(getDocPreviewGrant(grant.id)).toBeNull()
expect(send).not.toHaveBeenCalled()
})
// Why: the renderer is the only side that remembers which preview owns which grant, so a grant
// that outlives its renderer is a read authority nobody can release.
it('clears grants a previous renderer left behind when the window is created', () => {
const stranded = mintPreviewGrant()
installOnFakeWindow()
expect(getDocPreviewGrant(stranded.id)).toBeNull()
})
it('clears grants the renderer forgot across a reload', () => {
const { handlers } = installOnFakeWindow()
const grant = mintPreviewGrant()
handlers['did-start-navigation']?.({ isMainFrame: true, isSameDocument: false } as never)
expect(getDocPreviewGrant(grant.id)).toBeNull()
})
it('keeps grants across an in-document or subframe navigation, which keeps the renderer', () => {
const { handlers } = installOnFakeWindow()
const grant = mintPreviewGrant()
handlers['did-start-navigation']?.({ isMainFrame: true, isSameDocument: true } as never)
handlers['did-start-navigation']?.({ isMainFrame: false, isSameDocument: false } as never)
expect(getDocPreviewGrant(grant.id)).not.toBeNull()
})
})
@@ -13,16 +13,56 @@ import {
browserRouteWebContentsRegistry
} from '../browser/browser-route-session-runtime'
import { ORCA_BROWSER_BLANK_URL } from '../../shared/constants'
import { DOC_PREVIEW_PARTITION, parseDocPreviewUrl } from '../../shared/doc-preview-scheme'
import { setDocPreviewFailureSink } from '../browser/doc-preview-failure-notice'
import {
getDocPreviewGrant,
revokeAllDocPreviewGrants
} from '../browser/doc-preview-grant-registry'
import { isDocPreviewSession } from '../browser/doc-preview-protocol'
import { registerPluginPanelNavigationGuard } from '../plugins/plugin-panel-navigation-guard'
import { installPrivilegedWindowNavigationPolicy } from './privileged-window-navigation'
/**
* Why a separate admission rule: `normalizeBrowserNavigationUrl` answers only for
* http(s) and `file:`, so `orca-preview://` can only ever attach here — and only
* on the doc-preview partition, carrying a grant the main process minted for a
* deliberate user preview action. Web content has no way to reach either.
*/
function isAdmissibleDocPreviewAttach(partition: string, src: string): boolean {
if (partition !== DOC_PREVIEW_PARTITION) {
return false
}
const target = parseDocPreviewUrl(src)
return target !== null && getDocPreviewGrant(target.grantId) !== null
}
export function installMainWindowWebviewSecurity(mainWindow: BrowserWindow): void {
installPrivilegedWindowNavigationPolicy(mainWindow.webContents)
// Why here and on every fresh shell document: the renderer holds the only record of which
// preview owns which grant, and a reload throws that record away. Grants it can no longer
// release would stay live read authorities for the rest of the process.
revokeAllDocPreviewGrants()
mainWindow.webContents.on('did-start-navigation', (details) => {
if (details.isMainFrame && !details.isSameDocument) {
revokeAllDocPreviewGrants()
}
})
// Why these contents and not the window: every live preview is a guest of this WebContents, and
// it is also the failure sink itself — once it is destroyed no grant it minted has a reader left.
mainWindow.webContents.on('destroyed', () => {
setDocPreviewFailureSink(null)
revokeAllDocPreviewGrants()
})
// Why: containment must be listening before any plugin panel frame is created,
// so register it with the window's other navigation policy.
registerPluginPanelNavigationGuard(mainWindow.webContents)
const browserWindowClosePreload = join(__dirname, 'browser-window-close-preload.js')
// Why a preview gets a preload at all: it is our own editor surface, not a browsing guest. This
// one only decides what a click on a link means, and it is pinned here so no renderer-supplied
// value can reach a preview guest and no other attach path can acquire it.
const docPreviewLinkPreload = join(__dirname, 'doc-preview-link-preload.js')
mainWindow.webContents.on('will-attach-webview', (event, webPreferences, params) => {
const src = typeof params.src === 'string' ? params.src : ''
const normalizedSrc = normalizeBrowserNavigationUrl(src)
@@ -33,12 +73,14 @@ export function installMainWindowWebviewSecurity(mainWindow: BrowserWindow): voi
// so admission here can never race an unproxied session. They navigate like
// profile partitions — the renderer owns their URLs, no main-side grants.
const isLocalSshPartition = isLocalSshBrowserPartition(partition)
const isDocPreviewAttach = isAdmissibleDocPreviewAttach(partition, src)
// Why: fail closed — deny any src or partition not in the registry allowlist so a renderer bug can't smuggle preload/Node into an unprivileged guest.
if (
!normalizedSrc ||
(!isProfilePartition && !isRoutePartition && !isLocalSshPartition) ||
(isRoutePartition && normalizedSrc !== ORCA_BROWSER_BLANK_URL)
!isDocPreviewAttach &&
(!normalizedSrc ||
(!isProfilePartition && !isRoutePartition && !isLocalSshPartition) ||
(isRoutePartition && normalizedSrc !== ORCA_BROWSER_BLANK_URL))
) {
event.preventDefault()
return
@@ -46,7 +88,7 @@ export function installMainWindowWebviewSecurity(mainWindow: BrowserWindow): voi
delete params.preload
// Why: preload runs in the page's main world before inline scripts can call window.close().
webPreferences.preload = browserWindowClosePreload
webPreferences.preload = isDocPreviewAttach ? docPreviewLinkPreload : browserWindowClosePreload
// Why: older Electron builds expose preloadURL alongside preload; delete both so the guest can't inherit the main preload bridge.
delete (webPreferences as Record<string, unknown>).preloadURL
// Why delete something Electron does not set: 43 does not pass the embedder's
@@ -68,6 +110,17 @@ export function installMainWindowWebviewSecurity(mainWindow: BrowserWindow): voi
})
mainWindow.webContents.on('did-attach-webview', (_event, guest) => {
if (isDocPreviewSession(guest.session)) {
// Why: preview guests never join browser-tab routing, popups or anti-detection; the
// workspace-doc profile is what refuses all three. The attach is also the point a live window
// exists to receive read failures for that guest.
setDocPreviewFailureSink(mainWindow.webContents)
browserManager.attachGuestPolicies(guest, null, {
profile: 'workspace-doc',
host: mainWindow.webContents
})
return
}
// Why: attach guest popup/nav policy at creation; waiting for renderer registration races target=_blank/early redirects past it.
browserManager.attachGuestPolicies(guest)
// Why: route guests override the generic popup fallback and stay blank until exact main-owned registration.
+2
View File
@@ -22,6 +22,7 @@ import type { BrowserApi } from './api/browser-api'
import type { CliApi } from './api/cli-install-api'
import type { CrashReportsApi, FeedbackApi } from './api/crash-report-api'
import type { DashboardApi, TerminalPreviewApi } from './api/dashboard-api'
import type { DocPreviewApi } from './api/doc-preview-api'
import type { EmulatorApi } from './api/emulator-api'
import type { EphemeralVmApi } from './api/ephemeral-vm-api'
import type { ExportApi, FilesystemApi } from './api/filesystem-api'
@@ -125,6 +126,7 @@ export type PreloadApi = {
remoteWorkspace: WorkspaceSessionApi['remoteWorkspace']
updater: UpdaterApi
notebook: FilesystemApi['notebook']
docPreview: DocPreviewApi['docPreview']
stats: StatsApi
memory: MemoryApi
claudeUsage: ClaudeUsageApi
+29
View File
@@ -0,0 +1,29 @@
import type { DocPreviewFailure } from '../../shared/doc-preview-scheme'
export type DocPreviewGrantOwner =
| { kind: 'ssh'; connectionId: string }
| {
kind: 'runtime'
environmentId: string
worktreeSelector: string
worktreeRoot: string
}
export type DocPreviewGrantRequest = {
owner: DocPreviewGrantOwner
root: string
entryRelativePath: string
/** Browser page the document is being opened in; main registers its guest under this id. */
browserPageId: string
}
export type DocPreviewApi = {
docPreview: {
mintGrant: (request: DocPreviewGrantRequest) => Promise<{ grantId: string; url: string }>
revokeGrant: (grantId: string) => Promise<boolean>
/** External link the preview guest tried to open; the renderer turns it into a browser tab. */
onExternalLink: (callback: (payload: { url: string }) => void) => () => void
/** Why the guest is showing an error body instead of the document. */
onLoadFailure: (callback: (payload: DocPreviewFailure) => void) => () => void
}
}
@@ -0,0 +1,269 @@
// @vitest-environment happy-dom
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { DOC_PREVIEW_LINK_CLICK_CHANNEL } from '../shared/doc-preview-scheme'
import {
handleDocPreviewLinkAuxClick,
handleDocPreviewLinkClick,
installDocPreviewLinkInterception,
PRELOAD_DOC_PREVIEW_LINK_CLICK_CHANNEL
} from './doc-preview-link-interception'
const PREVIEW_GRANT_ID = 'a'.repeat(32)
let report: ReturnType<typeof vi.fn<(url: string) => void>>
/** The URL the guest is actually on, which is what decides whether an href is a pure fragment. */
function documentUrl(): string {
return window.location.href
}
function loadPreviewDocument(body: string): void {
document.body.innerHTML = body
report = vi.fn<(url: string) => void>()
}
/**
* An SVG anchor around a hit target, built rather than parsed: the href is an SVGAnimatedString
* carrying the raw attribute, which is what a plain string read misses and the environment does
* not model.
*/
function loadSvgAnchorDocument(href: string): void {
loadPreviewDocument('<div id="host"></div>')
const svgNamespace = 'http://www.w3.org/2000/svg'
const anchor = document.createElementNS(svgNamespace, 'a')
anchor.setAttribute('href', href)
Object.defineProperty(anchor, 'href', { value: { baseVal: href } })
const hit = document.createElementNS(svgNamespace, 'rect')
hit.setAttribute('id', 'svg-hit')
anchor.appendChild(hit)
const svg = document.createElementNS(svgNamespace, 'svg')
svg.appendChild(anchor)
document.getElementById('host')!.appendChild(svg)
}
/**
* Dispatches through the same capture-phase listener the preload installs, but registered for one
* press only — a document-level listener left behind would answer every later test's clicks too.
*/
function dispatch(selector: string, event: MouseEvent): MouseEvent {
const onClick = (candidate: Event): void => handleDocPreviewLinkClick(candidate, report)
const onAuxClick = (candidate: Event): void =>
handleDocPreviewLinkAuxClick(candidate as MouseEvent)
document.addEventListener('click', onClick, true)
document.addEventListener('auxclick', onAuxClick, true)
try {
document.querySelector(selector)!.dispatchEvent(event)
} finally {
document.removeEventListener('click', onClick, true)
document.removeEventListener('auxclick', onAuxClick, true)
}
return event
}
/** Chromium's own press. Nothing a document can dispatch carries this. */
function pressTrusted(selector: string, init: MouseEventInit = {}): MouseEvent {
const event = new MouseEvent('click', { bubbles: true, cancelable: true, ...init })
Object.defineProperty(event, 'isTrusted', { configurable: true, value: true })
return dispatch(selector, event)
}
beforeEach(() => {
// Why: the module decides "sibling document" and "pure fragment" against the URL the guest is
// really on, which in a preview is always the preview scheme.
;(window as never as { happyDOM: { setURL: (url: string) => void } }).happyDOM.setURL(
`orca-preview://${PREVIEW_GRANT_ID}/index.html`
)
vi.restoreAllMocks()
})
describe('doc preview link interception', () => {
it('sends on exactly the channel main listens on', () => {
expect(PRELOAD_DOC_PREVIEW_LINK_CLICK_CHANNEL).toBe(DOC_PREVIEW_LINK_CLICK_CHANNEL)
})
it('listens for both press kinds in the capture phase, so the document cannot consume them first', () => {
// Why not let it register: a document-level listener would survive into every later press here.
const addEventListener = vi.spyOn(document, 'addEventListener').mockImplementation(() => {})
installDocPreviewLinkInterception(vi.fn())
expect(addEventListener.mock.calls.map(([type, , options]) => [type, options])).toEqual([
['click', true],
['auxclick', true]
])
})
// Why the headline: this is the whole point of the design. A document that can read its grant
// must not be able to hand it to a browser tab by synthesizing the reader's click.
it('routes nothing for a click the document dispatched itself', () => {
loadPreviewDocument('<a id="external" href="https://attacker.test/?d=secret">go</a>')
const scripted = new MouseEvent('click', { bubbles: true, cancelable: true })
// Why set it: this is what Chromium stamps on anything a document dispatches, and the test
// environment leaves the property off entirely.
Object.defineProperty(scripted, 'isTrusted', { configurable: true, value: false })
const event = dispatch('#external', scripted)
expect(event.isTrusted).toBe(false)
expect(report).not.toHaveBeenCalled()
expect(event.defaultPrevented).toBe(false)
})
it('reports a trusted press on an external anchor exactly once and stops the navigation', () => {
loadPreviewDocument('<a id="external" href="https://example.com/docs">go</a>')
const event = pressTrusted('#external')
expect(report).toHaveBeenCalledExactlyOnceWith('https://example.com/docs')
expect(event.defaultPrevented).toBe(true)
})
it('reports the anchor even when the press landed on what it wraps', () => {
loadPreviewDocument('<a href="https://example.com/docs"><span id="inner">go</span></a>')
pressTrusted('#inner')
expect(report).toHaveBeenCalledExactlyOnceWith('https://example.com/docs')
})
// Why: an SVG anchor's href is an SVGAnimatedString, so reading it as a string finds nothing and
// the link would fall through to the guest's navigation guard and die there.
it('reports an SVG anchor by its animated href', () => {
loadSvgAnchorDocument('https://example.com/chart')
pressTrusted('#svg-hit')
expect(report).toHaveBeenCalledExactlyOnceWith('https://example.com/chart')
})
// Why these three: baseVal is the raw attribute, so an unresolved read sends a relative sibling
// out to a browser tab, drops SVG fragment links on the floor, and turns a rooted path into a
// file URL the guest refuses — each one a divergence from the identical HTML anchor.
it('leaves a relative SVG link to the guest, the same as the HTML anchor beside it', () => {
loadSvgAnchorDocument('guide.html')
const event = pressTrusted('#svg-hit')
expect(report).not.toHaveBeenCalled()
expect(event.defaultPrevented).toBe(false)
})
it('scrolls to an SVG fragment target instead of routing it', () => {
loadSvgAnchorDocument('#section-2')
const target = document.createElement('h2')
target.id = 'section-2'
document.body.appendChild(target)
const scrollIntoView = vi.spyOn(target, 'scrollIntoView')
const event = pressTrusted('#svg-hit')
expect(scrollIntoView).toHaveBeenCalledOnce()
expect(report).not.toHaveBeenCalled()
expect(event.defaultPrevented).toBe(true)
})
it('scrolls to the top for a bare # on an SVG anchor', () => {
loadSvgAnchorDocument('#')
const scrollTo = vi.spyOn(window, 'scrollTo').mockImplementation(() => {})
pressTrusted('#svg-hit')
expect(scrollTo).toHaveBeenCalledOnce()
expect(report).not.toHaveBeenCalled()
})
it('keeps a rooted SVG link inside the preview rather than reading it as a filesystem path', () => {
loadSvgAnchorDocument('/assets/a.html')
const event = pressTrusted('#svg-hit')
expect(report).not.toHaveBeenCalled()
expect(event.defaultPrevented).toBe(false)
})
it('leaves a sibling preview document to the guest, which its policy already permits', () => {
loadPreviewDocument(
`<a id="sibling" href="orca-preview://${PREVIEW_GRANT_ID}/guide.html">guide</a>`
)
const event = pressTrusted('#sibling')
expect(report).not.toHaveBeenCalled()
expect(event.defaultPrevented).toBe(false)
})
it('scrolls to a fragment target in the document instead of routing it', () => {
loadPreviewDocument(
`<a id="jump" href="${documentUrl()}#section">jump</a><h2 id="section">s</h2>`
)
const scrollIntoView = vi.spyOn(document.getElementById('section')!, 'scrollIntoView')
const event = pressTrusted('#jump')
expect(scrollIntoView).toHaveBeenCalledOnce()
expect(report).not.toHaveBeenCalled()
expect(event.defaultPrevented).toBe(true)
})
it('finds a fragment target whose id the href percent-encoded', () => {
loadPreviewDocument(`<a id="jump" href="${documentUrl()}#a%20b">jump</a><h2 id="a b">s</h2>`)
const scrollIntoView = vi.spyOn(document.getElementById('a b')!, 'scrollIntoView')
pressTrusted('#jump')
expect(scrollIntoView).toHaveBeenCalledOnce()
expect(report).not.toHaveBeenCalled()
})
it('scrolls to the top for a bare # and for #top with nothing carrying that id', () => {
loadPreviewDocument(
`<a id="hash" href="#">top</a><a id="named" href="${documentUrl()}#top">top</a>`
)
const scrollTo = vi.spyOn(window, 'scrollTo').mockImplementation(() => {})
pressTrusted('#hash')
pressTrusted('#named')
expect(scrollTo).toHaveBeenCalledTimes(2)
expect(report).not.toHaveBeenCalled()
})
it('routes a fragment href that belongs to another document', () => {
loadPreviewDocument('<a id="other" href="https://example.com/page#section">go</a>')
pressTrusted('#other')
expect(report).toHaveBeenCalledExactlyOnceWith('https://example.com/page#section')
})
it('opens nothing for a middle click on a link', () => {
loadPreviewDocument('<a id="external" href="https://example.com/docs">go</a>')
const event = new MouseEvent('auxclick', { bubbles: true, button: 1, cancelable: true })
Object.defineProperty(event, 'isTrusted', { configurable: true, value: true })
dispatch('#external', event)
expect(report).not.toHaveBeenCalled()
expect(event.defaultPrevented).toBe(true)
})
it('leaves a middle click the document dispatched itself alone', () => {
loadPreviewDocument('<a id="external" href="https://example.com/docs">go</a>')
const event = new MouseEvent('auxclick', { bubbles: true, button: 1, cancelable: true })
Object.defineProperty(event, 'isTrusted', { configurable: true, value: false })
dispatch('#external', event)
expect(event.defaultPrevented).toBe(false)
})
it('ignores a press that is not on a link at all', () => {
loadPreviewDocument('<p id="text">nothing here</p>')
const event = pressTrusted('#text')
expect(report).not.toHaveBeenCalled()
expect(event.defaultPrevented).toBe(false)
})
})
@@ -0,0 +1,152 @@
/**
* Decides what a click inside a preview guest means. This is the only way a URL leaves the
* preview, so it answers for a click the reader really made: navigation the document starts by
* itself never reaches here, and the guest's navigation policy refuses it outright.
*
* Runs in the guest's isolated world and exposes nothing to page script — the document cannot see
* these listeners, remove them, or call what they call.
*/
export type DocPreviewExternalLinkReporter = (url: string) => void
/**
* Why the channel name is written out rather than imported: this module is bundled into a
* sandboxed preload, whose `require` resolves only 'electron', so an import of shared code emits a
* chunk require the guest cannot load. The test pins it to the constant main listens on.
*/
export const PRELOAD_DOC_PREVIEW_LINK_CLICK_CHANNEL = 'docPreview:linkClick'
/** Why not `instanceof HTMLAnchorElement`: an SVG `<a>` is an anchor too, and carries an SVGAnimatedString href. */
function readAnchorHref(node: EventTarget): string | null {
const element = node as { tagName?: unknown; href?: unknown }
if (typeof element.tagName !== 'string' || element.tagName.toUpperCase() !== 'A') {
return null
}
if (typeof element.href === 'string') {
return element.href.length > 0 ? element.href : null
}
const baseVal = (element.href as { baseVal?: unknown } | null | undefined)?.baseVal
if (typeof baseVal !== 'string' || baseVal.length === 0) {
return null
}
// Why resolved here: baseVal is the raw attribute, unlike an HTML anchor's absolute href, so
// every branch below would read an SVG link differently from the identical HTML one.
try {
return new URL(baseVal, window.location.href).toString()
} catch {
return null
}
}
/** Why the composed path and not `event.target`: the press lands on whatever the anchor wraps, shadow roots included. */
function findClickedAnchor(event: Event): { element: EventTarget; href: string } | null {
for (const node of event.composedPath()) {
const href = readAnchorHref(node)
if (href !== null) {
return { element: node, href }
}
}
return null
}
function scrollToDocumentTop(): void {
window.scrollTo(0, 0)
}
function readAttribute(element: EventTarget, name: string): string | null {
const candidate = element as { getAttribute?: (attribute: string) => string | null }
return typeof candidate.getAttribute === 'function' ? candidate.getAttribute(name) : null
}
function scrollToFragment(fragment: string): void {
let target = document.getElementById(fragment)
if (!target) {
try {
target = document.getElementById(decodeURIComponent(fragment))
} catch {
target = null
}
}
if (target) {
target.scrollIntoView()
return
}
// Why: `#top` names the top of the document even when nothing carries that id.
if (fragment === 'top') {
scrollToDocumentTop()
}
}
/** True when the href only moves within the document already on screen, which this handles itself. */
function handleInDocumentFragment(element: EventTarget, href: string): boolean {
if (readAttribute(element, 'href') === '#') {
scrollToDocumentTop()
return true
}
const hashIndex = href.indexOf('#')
if (hashIndex === -1) {
return false
}
const currentHref = window.location.href
const currentHashIndex = currentHref.indexOf('#')
const currentBase = currentHashIndex === -1 ? currentHref : currentHref.slice(0, currentHashIndex)
if (href.slice(0, hashIndex) !== currentBase) {
return false
}
scrollToFragment(href.slice(hashIndex + 1))
return true
}
function isSameSchemeAsDocument(href: string): boolean {
try {
return new URL(href).protocol === window.location.protocol
} catch {
return false
}
}
export function handleDocPreviewLinkClick(
event: Event,
report: DocPreviewExternalLinkReporter
): void {
// Why first: a click the page dispatched is the document asking to leave, not the reader.
if (!event.isTrusted) {
return
}
const anchor = findClickedAnchor(event)
if (!anchor) {
return
}
if (handleInDocumentFragment(anchor.element, anchor.href)) {
event.preventDefault()
return
}
// Why left alone: a link served the way this document was is a sibling preview document, which
// the guest policy already answers for — it navigates natively and the preview keeps its history.
if (isSameSchemeAsDocument(anchor.href)) {
return
}
event.preventDefault()
report(anchor.href)
}
export function handleDocPreviewLinkAuxClick(event: MouseEvent): void {
if (!event.isTrusted || event.button !== 1) {
return
}
if (!findClickedAnchor(event)) {
return
}
// Why swallowed rather than routed: a middle click asks for a background tab, and honouring it
// would let one press the reader barely registered open a browser tab.
event.preventDefault()
}
export function installDocPreviewLinkInterception(report: DocPreviewExternalLinkReporter): void {
// Why capture: the document's own handlers must not be able to consume the press first.
document.addEventListener('click', (event) => handleDocPreviewLinkClick(event, report), true)
document.addEventListener(
'auxclick',
(event) => handleDocPreviewLinkAuxClick(event as MouseEvent),
true
)
}
+15
View File
@@ -0,0 +1,15 @@
import {
installDocPreviewLinkInterception,
PRELOAD_DOC_PREVIEW_LINK_CLICK_CHANNEL
} from './doc-preview-link-interception'
// Why: raw require keeps the sandboxed preload standalone in the main-process CJS build.
const { ipcRenderer } = require('electron') as {
ipcRenderer: { send: (channel: string, ...args: unknown[]) => void }
}
// Why no contextBridge: the document must not be able to call this. The listeners live in the
// isolated world, and main still refuses any report that does not come from a focused preview guest.
installDocPreviewLinkInterception((url) => {
ipcRenderer.send(PRELOAD_DOC_PREVIEW_LINK_CLICK_CHANNEL, url)
})
+27
View File
@@ -8,6 +8,14 @@ import type {
SkillDeleteRequest,
SkillDeleteResult
} from '../shared/skill-delete-contract'
import {
DOC_PREVIEW_EXTERNAL_LINK_CHANNEL,
DOC_PREVIEW_LOAD_FAILURE_CHANNEL,
DOC_PREVIEW_MINT_GRANT_CHANNEL,
DOC_PREVIEW_REVOKE_GRANT_CHANNEL,
type DocPreviewFailure
} from '../shared/doc-preview-scheme'
import type { DocPreviewGrantRequest } from './api/doc-preview-api'
import type { AppIdentity } from '../shared/app-identity'
import type { MacCapturedDigitRowChord } from '../shared/macos-symbolic-hotkeys'
import type { ComputerAwakeStatus } from '../shared/computer-awake-mode'
@@ -3322,6 +3330,25 @@ const api = {
}
} satisfies PreloadApi['updater'],
docPreview: {
mintGrant: (request: DocPreviewGrantRequest): Promise<{ grantId: string; url: string }> =>
ipcRenderer.invoke(DOC_PREVIEW_MINT_GRANT_CHANNEL, request),
revokeGrant: (grantId: string): Promise<boolean> =>
ipcRenderer.invoke(DOC_PREVIEW_REVOKE_GRANT_CHANNEL, grantId),
onExternalLink: (callback: (payload: { url: string }) => void): (() => void) => {
const listener = (_event: Electron.IpcRendererEvent, payload: { url: string }): void =>
callback(payload)
ipcRenderer.on(DOC_PREVIEW_EXTERNAL_LINK_CHANNEL, listener)
return () => ipcRenderer.removeListener(DOC_PREVIEW_EXTERNAL_LINK_CHANNEL, listener)
},
onLoadFailure: (callback: (payload: DocPreviewFailure) => void): (() => void) => {
const listener = (_event: Electron.IpcRendererEvent, payload: DocPreviewFailure): void =>
callback(payload)
ipcRenderer.on(DOC_PREVIEW_LOAD_FAILURE_CHANNEL, listener)
return () => ipcRenderer.removeListener(DOC_PREVIEW_LOAD_FAILURE_CHANNEL, listener)
}
},
notebook: {
runPythonCell: (args: {
filePath: string
+6 -2
View File
@@ -4,7 +4,10 @@
import { basename, dirname, join } from 'node:path'
import { homedir } from 'node:os'
import { ORCA_HOOK_PROTOCOL_VERSION } from '../shared/agent-hook-types'
import {
ORCA_HOOK_PROTOCOL_VERSION,
ORCA_HOOK_RAW_JSON_TRANSPORT
} from '../shared/agent-hook-types'
// Why: relay's userData equivalent under $HOME so each user on a shared dev box gets their own 0o700 dir.
const RELAY_HOOKS_DIR_NAME = '.orca-relay'
@@ -49,7 +52,8 @@ export function buildRelayHookPtyEnv(coordinates: {
ORCA_AGENT_HOOK_PORT: String(coordinates.port),
ORCA_AGENT_HOOK_TOKEN: coordinates.token,
ORCA_AGENT_HOOK_ENV: coordinates.env,
ORCA_AGENT_HOOK_VERSION: ORCA_HOOK_PROTOCOL_VERSION
ORCA_AGENT_HOOK_VERSION: ORCA_HOOK_PROTOCOL_VERSION,
ORCA_AGENT_HOOK_TRANSPORT: ORCA_HOOK_RAW_JSON_TRANSPORT
}
if (coordinates.endpointFileWritten) {
env.ORCA_AGENT_HOOK_ENDPOINT = coordinates.endpointFilePath
+30 -10
View File
@@ -8,6 +8,7 @@ import { endpointDirForRelaySocket } from './agent-hook-endpoint-coordinates'
import type { AgentHookRelayEnvelope } from '../shared/agent-hook-relay'
import { makePaneKey } from '../shared/stable-pane-id'
import * as agentHookListener from '../shared/agent-hook-listener/grok-result-discovery'
import { HOOK_REQUEST_MAX_BYTES } from '../shared/agent-hook-listener/request-body'
const LEAF_ID = '11111111-1111-4111-8111-111111111111'
const PANE_KEY = makePaneKey('tab-1', LEAF_ID)
@@ -42,7 +43,7 @@ describe('RelayAgentHookServer', () => {
expect(endpointDir).not.toContain('\\\\.\\pipe')
})
it('forwards a parsed Claude UserPromptSubmit POST as a normalized envelope', async () => {
it('forwards a raw Claude JSON POST with base64 metadata headers', async () => {
const forward = vi.fn<(envelope: AgentHookRelayEnvelope) => void>()
const server = new RelayAgentHookServer({ endpointDir: dir, forward })
await server.start()
@@ -52,16 +53,13 @@ describe('RelayAgentHookServer', () => {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Orca-Agent-Hook-Token': token
'X-Orca-Agent-Hook-Token': token,
'X-Orca-Agent-Hook-Meta-Encoding': 'base64',
'X-Orca-Agent-Hook-Meta': Buffer.from(
[PANE_KEY, 'tab-1', '', 'wt-1', 'remote', '1'].join('\x1f')
).toString('base64')
},
body: JSON.stringify({
paneKey: PANE_KEY,
tabId: 'tab-1',
worktreeId: 'wt-1',
env: 'remote',
version: '1',
payload: { hook_event_name: 'UserPromptSubmit', prompt: 'hi' }
})
body: JSON.stringify({ hook_event_name: 'UserPromptSubmit', prompt: 'hi' })
})
expect(res.status).toBe(204)
expect(forward).toHaveBeenCalledTimes(1)
@@ -330,6 +328,27 @@ describe('RelayAgentHookServer', () => {
}
})
it('rejects unknown hook paths before parsing the request body', async () => {
const forward = vi.fn()
const server = new RelayAgentHookServer({ endpointDir: dir, forward })
await server.start()
try {
const { port, token } = server.getCoordinates()
const res = await fetch(`http://127.0.0.1:${port}/hook/unknown`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Orca-Agent-Hook-Token': token
},
body: JSON.stringify({ value: 'x'.repeat(HOOK_REQUEST_MAX_BYTES + 1) })
})
expect(res.status).toBe(404)
expect(forward).not.toHaveBeenCalled()
} finally {
server.stop()
}
})
it('replays cached payloads on demand', async () => {
const forward = vi.fn<(envelope: AgentHookRelayEnvelope) => void>()
const server = new RelayAgentHookServer({ endpointDir: dir, forward })
@@ -492,6 +511,7 @@ describe('RelayAgentHookServer', () => {
expect(env.ORCA_AGENT_HOOK_TOKEN).toBeTruthy()
expect(env.ORCA_AGENT_HOOK_ENV).toBe('remote')
expect(env.ORCA_AGENT_HOOK_VERSION).toBe('1')
expect(env.ORCA_AGENT_HOOK_TRANSPORT).toBe('raw-json-v1')
expect(env.ORCA_AGENT_HOOK_ENDPOINT).toBeTruthy()
} finally {
server.stop()
+14 -8
View File
@@ -2,7 +2,10 @@ import { createServer, type IncomingMessage, type ServerResponse } from 'node:ht
import { randomUUID } from 'node:crypto'
import { join } from 'node:path'
import { ORCA_HOOK_PROTOCOL_VERSION } from '../shared/agent-hook-types'
import {
ORCA_HOOK_PROTOCOL_VERSION,
ORCA_HOOK_RAW_JSON_TRANSPORT
} from '../shared/agent-hook-types'
import {
clearAllListenerCaches,
clearPaneCacheState,
@@ -15,6 +18,7 @@ import {
} from '../shared/agent-hook-listener/endpoint-publication'
import { HOOK_REQUEST_SLOWLORIS_MS } from '../shared/agent-hook-listener/listener-limits'
import { normalizeHookPayload } from '../shared/agent-hook-listener'
import { mergeAgentHookRequestHeaders } from '../shared/agent-hook-listener/hook-envelope'
import { readRequestBody } from '../shared/agent-hook-listener/request-body'
import { resolveHookSource } from '../shared/agent-hook-listener/source-routing'
import type { AgentHookEventPayload } from '../shared/agent-hook-listener/listener-event'
@@ -174,7 +178,8 @@ export class RelayAgentHookServer {
port: this.port,
token: this.token,
env: this.env,
version: ORCA_HOOK_PROTOCOL_VERSION
version: ORCA_HOOK_PROTOCOL_VERSION,
transport: ORCA_HOOK_RAW_JSON_TRANSPORT
})
return this.endpointFileWritten
}
@@ -252,7 +257,6 @@ export class RelayAgentHookServer {
req.destroy()
})
try {
const body = await readRequestBody(req)
const pathname = new URL(req.url ?? '/', 'http://127.0.0.1').pathname
const source = resolveHookSource(pathname)
if (!source) {
@@ -260,16 +264,18 @@ export class RelayAgentHookServer {
res.end()
return
}
const event = normalizeHookPayload(this.state, source, body, this.env, {
const body = await readRequestBody(req)
const hookBody = mergeAgentHookRequestHeaders(body, req.headers)
const event = normalizeHookPayload(this.state, source, hookBody, this.env, {
deferCompactOwnershipToClient: true
})
if (event) {
// TODO: once normalizeHookPayload returns validated env/version, drop bodyEnv/bodyVersion and source them from the listener result.
const env = hookBodyEnv(body)
const version = hookBodyVersion(body)
const env = hookBodyEnv(hookBody)
const version = hookBodyVersion(hookBody)
this.applyEvent(event, source, env, version)
this.retryScheduler.scheduleAssistantMessageRetry(source, body, event, env, version)
this.retryScheduler.scheduleCodexSubagentPoll(source, body, event, env, version)
this.retryScheduler.scheduleAssistantMessageRetry(source, hookBody, event, env, version)
this.retryScheduler.scheduleCodexSubagentPoll(source, hookBody, event, env, version)
}
res.writeHead(204)
res.end()
@@ -21,6 +21,7 @@ import { ClientHostedBrowserUnavailableNotice } from './client-hosted-browser-un
import { useRestoredClientHostedRecoveryWindow } from './restored-client-hosted-recovery-window'
import BrowserFind from './assemble-chrome/BrowserFind'
import { BrowserNavigationControlRow } from './assemble-chrome/browser-navigation-control-row'
import BrowserAddressBar from './assemble-chrome/BrowserAddressBar'
import { BrowserPageContextMenu } from './assemble-chrome/browser-page-context-menu'
import { useBrowserPageChromeFocus } from './assemble-chrome/use-browser-page-chrome-focus'
import { useBrowserAddressBarEditSession } from './assemble-chrome/use-browser-address-bar-edit-session'
@@ -165,7 +166,7 @@ export function ClientHostedBrowserPagePane({
const navigateToUrl = useCallback(
(value: string) => {
const submission = resolveBrowserAddressBarSubmission(value)
const submission = resolveBrowserAddressBarSubmission(value, { allowFileUrls: false })
if (submission.status === 'invalid') {
onUpdatePageState(browserTab.id, { loadError: submission.loadError })
return
@@ -367,18 +368,23 @@ export function ClientHostedBrowserPagePane({
reload: () => reload.runReloadTrigger('button'),
navigate: navigateToUrl
}}
addressBarValue={addressBarValue}
onAddressBarChange={setAddressBarValue}
onSubmitAddressBar={() => navigateToUrl(addressBarValue)}
addressBarInputRef={addressBarInputRef}
addressBarEditSession={addressBarEditSession}
reloadLabel={reload.reloadButtonLabel}
addressBarLeadingIcon={
<RemoteRuntimeEgressIndicator
runtimeEnvironmentId={runtimeEnvironmentId}
presentation="client-hosted"
addressSlot={
<BrowserAddressBar
value={addressBarValue}
onChange={setAddressBarValue}
onSubmit={() => navigateToUrl(addressBarValue)}
onNavigate={navigateToUrl}
inputRef={addressBarInputRef}
editSession={addressBarEditSession}
leadingIcon={
<RemoteRuntimeEgressIndicator
runtimeEnvironmentId={runtimeEnvironmentId}
presentation="client-hosted"
/>
}
/>
}
reloadLabel={reload.reloadButtonLabel}
/>
</div>
<div ref={viewportRef} className="relative min-h-0 flex-1 overflow-hidden bg-background">
@@ -0,0 +1,189 @@
import type { MutableRefObject, RefObject } from 'react'
import { Copy, Image } from 'lucide-react'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { translate } from '@/i18n/i18n'
import { MarkupOverlay } from './MarkupOverlay'
import type { MarkupModeController } from './useMarkupMode'
import type { GrabModeHook } from './useGrabMode'
import {
getBrowserOverlayAnchor,
type BrowserOverlayViewport
} from '../describe-page/browser-annotation-geometry'
import { BrowserPageAnnotationTray } from './browser-page-annotation-tray'
import { BrowserPageGrabToast } from './browser-page-grab-toast'
import { PendingBrowserAnnotationCard } from './pending-browser-annotation-card'
import type { useBrowserPageAnnotationSend } from './use-browser-page-annotation-send'
import type { useBrowserPageGrabAnnotations } from './use-browser-page-grab-annotations'
/**
* Everything the annotate and markup tools paint over a guest: the draw surface, the pending
* comment card, the annotation tray, the right-click grab menu and the inline confirmation toast.
*
* Shared because these overlays are the other half of the toolbar's tool cluster — a surface that
* offers the tools but not these would arm a picker whose result the reader could never see.
*/
export function BrowserGuestAnnotateOverlays({
markup,
grab,
annotationSend,
grabAnnotations,
containerRef,
webviewRef,
browserOverlayViewport,
worktreeId
}: {
markup: MarkupModeController
grab: GrabModeHook
annotationSend: ReturnType<typeof useBrowserPageAnnotationSend>
grabAnnotations: ReturnType<typeof useBrowserPageGrabAnnotations>
containerRef: RefObject<HTMLDivElement | null>
webviewRef: MutableRefObject<Electron.WebviewTag | null>
browserOverlayViewport: BrowserOverlayViewport
worktreeId: string
}): React.JSX.Element {
const {
pendingAnnotationPayload,
handleAddBrowserAnnotation,
handleCancelPendingBrowserAnnotation,
grabIntent,
grabMenuActionTakenRef,
handleGrabCopy,
handleGrabCopyScreenshot,
grabToast,
grabToastTimerRef,
dismissGrabToast,
setGrabToast
} = grabAnnotations
const {
browserAnnotations,
browserAnnotationTrayOpen,
annotationTraySendOpen,
handleAnnotationTraySendOpenChange,
activeGroupId,
browserAnnotationsPrompt,
handleBrowserAnnotationsSentToAgent,
handleCopyBrowserAnnotations,
browserAnnotationsCopied,
handleClearBrowserAnnotations,
handleDeleteBrowserAnnotation
} = annotationSend
return (
<>
{markup.isActive && markup.baseImage ? (
<MarkupOverlay
baseImage={markup.baseImage}
busy={markup.state === 'composing'}
onComplete={(input) => void markup.complete(input)}
onCancel={markup.cancel}
/>
) : null}
{pendingAnnotationPayload ? (
<PendingBrowserAnnotationCard
payload={pendingAnnotationPayload}
anchor={getBrowserOverlayAnchor(
pendingAnnotationPayload,
containerRef.current,
webviewRef.current,
browserOverlayViewport
)}
portalContainer={containerRef.current}
onAdd={handleAddBrowserAnnotation}
onCancel={handleCancelPendingBrowserAnnotation}
/>
) : null}
{browserAnnotations.length > 0 && browserAnnotationTrayOpen ? (
<BrowserPageAnnotationTray
browserAnnotations={browserAnnotations}
annotationTraySendOpen={annotationTraySendOpen}
handleAnnotationTraySendOpenChange={handleAnnotationTraySendOpenChange}
worktreeId={worktreeId}
activeGroupId={activeGroupId}
browserAnnotationsPrompt={browserAnnotationsPrompt}
handleBrowserAnnotationsSentToAgent={handleBrowserAnnotationsSentToAgent}
handleCopyBrowserAnnotations={handleCopyBrowserAnnotations}
browserAnnotationsCopied={browserAnnotationsCopied}
handleClearBrowserAnnotations={handleClearBrowserAnnotations}
handleDeleteBrowserAnnotation={handleDeleteBrowserAnnotation}
/>
) : null}
{/* Right-click context dropdown, positioned at the grabbed element's center. */}
<DropdownMenu
open={grab.state === 'confirming' && grab.contextMenu && grabIntent === 'copy'}
onOpenChange={(open) => {
if (!open && grab.state === 'confirming') {
// Why: skip rearm if a menu action already handled it — see grabMenuActionTakenRef.
if (grabMenuActionTakenRef.current) {
grabMenuActionTakenRef.current = false
return
}
grab.rearm()
}
}}
>
<DropdownMenuTrigger asChild>
<button
aria-hidden
tabIndex={-1}
className="pointer-events-none absolute size-px opacity-0"
style={(() => {
if (!grab.payload) {
return { left: 0, top: 0 }
}
const rect = grab.payload.target.rectViewport
const webview = webviewRef.current
const webviewRect = webview?.getBoundingClientRect()
const cRect = containerRef.current?.getBoundingClientRect()
const offsetX = (webviewRect?.left ?? 0) - (cRect?.left ?? 0)
const offsetY = (webviewRect?.top ?? 0) - (cRect?.top ?? 0)
return {
left: offsetX + rect.x + rect.width / 2,
top: offsetY + rect.y + rect.height / 2
}
})()}
/>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" sideOffset={4}>
<DropdownMenuItem onSelect={handleGrabCopy}>
<Copy className="size-3.5" />
{translate('auto.components.browser.pane.BrowserPane.c2ef0359b9', 'Copy Contents')}
<DropdownMenuShortcut>C</DropdownMenuShortcut>
</DropdownMenuItem>
{grab.payload?.screenshot?.dataUrl?.startsWith('data:image/png;base64,') ? (
<DropdownMenuItem onSelect={handleGrabCopyScreenshot}>
<Image className="size-3.5" />
{translate('auto.components.browser.pane.BrowserPane.1ded0d3168', 'Copy Screenshot')}
<DropdownMenuShortcut>S</DropdownMenuShortcut>
</DropdownMenuItem>
) : null}
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={() => {
grabMenuActionTakenRef.current = true
grab.cancel()
}}
>
{translate('auto.components.browser.pane.BrowserPane.fa6ea61de3', 'Cancel')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{/* Inline toast bubble; flips above the element when near the viewport bottom so it doesn't occlude it. */}
{grabToast ? (
<BrowserPageGrabToast
grabToast={grabToast}
grabToastTimerRef={grabToastTimerRef}
dismissGrabToast={dismissGrabToast}
setGrabToast={setGrabToast}
/>
) : null}
</>
)
}
@@ -12,7 +12,7 @@ export function runBrowserGrabActionShortcut({
grabIntent,
grab,
grabPayloadRef,
browserTabIdRef,
toolTargetIdRef,
recordFeatureInteraction,
showGrabToast
}: {
@@ -20,7 +20,7 @@ export function runBrowserGrabActionShortcut({
grabIntent: GrabIntent
grab: GrabModeHook
grabPayloadRef: MutableRefObject<BrowserGrabPayload | null>
browserTabIdRef: MutableRefObject<string>
toolTargetIdRef: MutableRefObject<string>
recordFeatureInteraction: (feature: 'browser-grab') => void | Promise<void>
showGrabToast: (
message: string,
@@ -70,7 +70,7 @@ export function runBrowserGrabActionShortcut({
let result: Awaited<ReturnType<typeof window.api.browser.extractHoverPayload>>
try {
result = await window.api.browser.extractHoverPayload({
browserPageId: browserTabIdRef.current
browserPageId: toolTargetIdRef.current
})
} catch {
// Why: the guest can be destroyed or the IPC channel torn down mid-shortcut; surface it like a miss instead of an unhandled rejection.
@@ -86,7 +86,7 @@ export function runBrowserGrabActionShortcut({
if (key === 's') {
try {
const ssResult = await window.api.browser.captureSelectionScreenshot({
browserPageId: browserTabIdRef.current,
browserPageId: toolTargetIdRef.current,
rect: payload.target.rectViewport
})
if (ssResult.ok) {
@@ -0,0 +1,43 @@
import type {
BrowserGrabPayload,
BrowserPageAnnotation
} from '../../../../../shared/browser-grab-types'
/**
* Push the current annotation set into the guest, where badges render in-page so they track scroll
* without a message per frame. Shared by every surface that annotates a guest — the payload is
* derived only from the annotations themselves, so the two surfaces cannot disagree about it.
*/
export function syncGuestAnnotationViewportBridge({
toolTargetId,
annotations,
pendingPayload,
surfaceActive,
token
}: {
toolTargetId: string
annotations: BrowserPageAnnotation[]
pendingPayload: BrowserGrabPayload | null
surfaceActive: boolean
token: string
}): void {
// Why: existing badges render in-guest for smooth scroll; only the pending dialog needs viewport messages.
const markers = annotations.map((annotation, index) => ({
id: annotation.id,
index,
isFixed: annotation.payload.target.isFixed === true,
rectPage: annotation.payload.target.rectPage,
rectViewport: annotation.payload.target.rectViewport
}))
void window.api.browser
.setAnnotationViewportBridge({
browserPageId: toolTargetId,
emitViewport: pendingPayload !== null,
enabled: surfaceActive && (pendingPayload !== null || markers.length > 0),
markers,
token
})
.catch(() => {
// The viewport bridge is visual-only; stale markers beat breaking the surface on a destroyed guest.
})
}
@@ -43,6 +43,7 @@ const annotationAddedGrabToastMessage = (): string =>
export function useBrowserPageGrabAnnotations({
browserTabId,
toolTargetId = browserTabId,
isActive,
grab,
containerRef,
@@ -51,7 +52,14 @@ export function useBrowserPageGrabAnnotations({
browserAnnotationsLength,
setBrowserAnnotationTrayOpen
}: {
/** Scopes the stored annotations. Stable for the life of the surface. */
browserTabId: string
/**
* The id main resolves to a guest. Defaults to the annotation scope, which is the same string
* for a browser page — a preview re-mints this on recovery, and its annotations must not be
* orphaned when it does.
*/
toolTargetId?: string
isActive: boolean
grab: GrabModeHook
containerRef: MutableRefObject<HTMLDivElement | null>
@@ -75,7 +83,7 @@ export function useBrowserPageGrabAnnotations({
handleCancelPendingBrowserAnnotation: () => void
handleGrabActionShortcut: (key: 'c' | 's') => void
} {
const browserTabIdRef = useRef(browserTabId)
const toolTargetIdRef = useRef(toolTargetId)
const grabToastTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined)
const [grabIntent, setGrabIntent] = useState<GrabIntent>('copy')
const grabIntentRef = useRef(grabIntent)
@@ -88,12 +96,12 @@ export function useBrowserPageGrabAnnotations({
const grabPayloadRef = useRef(grab.payload)
useLayoutEffect(() => {
browserTabIdRef.current = browserTabId
toolTargetIdRef.current = toolTargetId
grabIntentRef.current = grabIntent
pendingAnnotationPayloadRef.current = pendingAnnotationPayload
grabRef.current = grab
grabPayloadRef.current = grab.payload
}, [browserTabId, grab, grabIntent, pendingAnnotationPayload])
}, [grab, grabIntent, pendingAnnotationPayload, toolTargetId])
// Why: Radix fires onOpenChange(false) before onSelect, so this flag lets onOpenChange skip the rearm that would clear the payload first.
const grabMenuActionTakenRef = useRef(false)
const recordFeatureInteraction = useAppStore((s) => s.recordFeatureInteraction)
@@ -225,7 +233,7 @@ export function useBrowserPageGrabAnnotations({
grabIntent,
grab,
grabPayloadRef,
browserTabIdRef,
toolTargetIdRef,
recordFeatureInteraction,
showGrabToast
})

Some files were not shown because too many files have changed in this diff Show More