mirror of
https://github.com/stablyai/orca.git
synced 2026-10-25 08:04:05 +00:00
Merge remote-tracking branch 'origin/main' into brennanb2025/chat-ui-supported-agents
This commit is contained in:
@@ -103,6 +103,7 @@ docs/**
|
||||
!docs/reference/git-compatibility.md
|
||||
!docs/reference/headless-linux-server.md
|
||||
!docs/reference/linux-glibc-compatibility.md
|
||||
!docs/reference/macos-press-and-hold.md
|
||||
!docs/reference/orcad-operations.md
|
||||
!docs/reference/relay-grace-time-reconfiguration.md
|
||||
!docs/reference/windows-process-enumeration.md
|
||||
|
||||
+505
-110
File diff suppressed because one or more lines are too long
@@ -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",
|
||||
|
||||
@@ -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 |
@@ -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.
|
||||
@@ -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'),
|
||||
|
||||
@@ -92,6 +92,7 @@ describe('agent hooks CLI handler', () => {
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs()
|
||||
vi.restoreAllMocks()
|
||||
rmSync(userDataPath, { recursive: true, force: true })
|
||||
})
|
||||
@@ -139,6 +140,33 @@ describe('agent hooks CLI handler', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('forwards WSL pane routing to the runtime exactly once without using the host installer', async () => {
|
||||
const home = '/home/jin/.local/share/orca/codex-runtime-home/home'
|
||||
vi.stubEnv('CODEX_HOME', home)
|
||||
vi.stubEnv('ORCA_CODEX_HOME', home)
|
||||
vi.stubEnv('WSL_DISTRO_NAME', 'Ubuntu-24.04')
|
||||
callMock.mockResolvedValue({ result: { state: 'installed' } })
|
||||
|
||||
await main(['agent', 'hooks', 'prepare-codex'], userDataPath)
|
||||
|
||||
expect(callMock).toHaveBeenCalledExactlyOnceWith(
|
||||
'agentHooks.prepareCodexForWslPane',
|
||||
{ codexHome: home, orcaCodexHome: home, wslDistro: 'Ubuntu-24.04' },
|
||||
{ timeoutMs: 50_000 }
|
||||
)
|
||||
expect(prepareManagedCodexHomeBeforeShellLaunchMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('fails open when WSL runtime preparation is unavailable', async () => {
|
||||
vi.stubEnv('CODEX_HOME', '/home/jin/.local/share/orca/codex-runtime-home/home')
|
||||
vi.stubEnv('ORCA_CODEX_HOME', '/home/jin/.local/share/orca/codex-runtime-home/home')
|
||||
vi.stubEnv('WSL_DISTRO_NAME', 'Ubuntu')
|
||||
callMock.mockRejectedValue(new Error('method_not_found'))
|
||||
|
||||
await expect(main(['agent', 'hooks', 'prepare-codex'], userDataPath)).resolves.toBeUndefined()
|
||||
expect(prepareManagedCodexHomeBeforeShellLaunchMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('honors Codex-specific disablement when the runtime is unavailable', async () => {
|
||||
const state = getDefaultPersistedState(userDataPath)
|
||||
state.settings.disabledTuiAgents = ['codex']
|
||||
|
||||
@@ -28,6 +28,9 @@ type AgentHookCommandResult = {
|
||||
statuses: AgentHookInstallStatus[]
|
||||
}
|
||||
|
||||
// Covers managed-home verification, WSL identity, trust grant, and bounded app-server reap.
|
||||
const WSL_CODEX_PREPARE_TIMEOUT_MS = 50_000
|
||||
|
||||
function getDataPath(): string {
|
||||
const userDataPath = getDefaultUserDataPath()
|
||||
const indexPath = join(userDataPath, 'orca-profile-index.json')
|
||||
@@ -207,6 +210,22 @@ async function setAgentHooksEnabled(
|
||||
|
||||
export const AGENT_HOOK_HANDLERS: Record<string, CommandHandler> = {
|
||||
'agent hooks prepare-codex': async ({ client }) => {
|
||||
if (process.env.WSL_DISTRO_NAME?.trim()) {
|
||||
try {
|
||||
await client.call(
|
||||
'agentHooks.prepareCodexForWslPane',
|
||||
{
|
||||
codexHome: process.env.CODEX_HOME ?? '',
|
||||
orcaCodexHome: process.env.ORCA_CODEX_HOME ?? '',
|
||||
wslDistro: process.env.WSL_DISTRO_NAME
|
||||
},
|
||||
{ timeoutMs: WSL_CODEX_PREPARE_TIMEOUT_MS }
|
||||
)
|
||||
} catch {
|
||||
// Best effort: old or unavailable runtimes must not block Codex launch.
|
||||
}
|
||||
return
|
||||
}
|
||||
const settings = await readHookSettings(client)
|
||||
await prepareManagedCodexHomeBeforeShellLaunch({
|
||||
userDataPath: getDefaultUserDataPath(),
|
||||
|
||||
@@ -72,7 +72,7 @@ fi
|
||||
# Why || : twice — zsh alone aborts inside the substitution, but every shell's
|
||||
# assignment adopts its exit status, so an absent codex trips set -e in bash too.
|
||||
__orca_codex_binary="$(unalias codex 2>/dev/null || :; command -v codex 2>/dev/null || :)"
|
||||
if [[ -n "${ORCA_CODEX_LAUNCH_PREFLIGHT:-}" && -n "${__orca_codex_binary:-}" && -x "${__orca_codex_binary}" ]]; then
|
||||
if [[ -n "${ORCA_CODEX_LAUNCH_PREFLIGHT:-}" && -x "${ORCA_CODEX_LAUNCH_PREFLIGHT}" && -n "${__orca_codex_binary:-}" && -x "${__orca_codex_binary}" ]]; then
|
||||
# Why the function reserved word: it suppresses alias expansion of the name,
|
||||
# which otherwise rewrites this header at parse time and aborts the whole file.
|
||||
function codex {
|
||||
|
||||
@@ -2,7 +2,7 @@ function __orca_shell_ready_marker --on-event fish_prompt
|
||||
builtin printf "\033]777;orca-shell-ready\007"
|
||||
functions -e __orca_shell_ready_marker
|
||||
end
|
||||
if test -n "$ORCA_CODEX_LAUNCH_PREFLIGHT"; and test (type -t codex 2>/dev/null) = file
|
||||
if test -x "$ORCA_CODEX_LAUNCH_PREFLIGHT"; and test (type -t codex 2>/dev/null) = file
|
||||
function codex
|
||||
command "$ORCA_CODEX_LAUNCH_PREFLIGHT" agent hooks prepare-codex >/dev/null 2>&1; or true
|
||||
command codex $argv
|
||||
|
||||
@@ -110,7 +110,7 @@ __orca_deferred_init() {
|
||||
# Why || : twice — zsh alone aborts inside the substitution, but every shell's
|
||||
# assignment adopts its exit status, so an absent codex trips set -e in bash too.
|
||||
__orca_codex_binary="$(unalias codex 2>/dev/null || :; command -v codex 2>/dev/null || :)"
|
||||
if [[ -n "${ORCA_CODEX_LAUNCH_PREFLIGHT:-}" && -n "${__orca_codex_binary:-}" && -x "${__orca_codex_binary}" ]]; then
|
||||
if [[ -n "${ORCA_CODEX_LAUNCH_PREFLIGHT:-}" && -x "${ORCA_CODEX_LAUNCH_PREFLIGHT}" && -n "${__orca_codex_binary:-}" && -x "${__orca_codex_binary}" ]]; then
|
||||
# Why the function reserved word: it suppresses alias expansion of the name,
|
||||
# which otherwise rewrites this header at parse time and aborts the whole file.
|
||||
function codex {
|
||||
|
||||
@@ -75,7 +75,7 @@ fi
|
||||
# Why || : twice — zsh alone aborts inside the substitution, but every shell's
|
||||
# assignment adopts its exit status, so an absent codex trips set -e in bash too.
|
||||
__orca_codex_binary="$(unalias codex 2>/dev/null || :; command -v codex 2>/dev/null || :)"
|
||||
if [[ -n "${ORCA_CODEX_LAUNCH_PREFLIGHT:-}" && -n "${__orca_codex_binary:-}" && -x "${__orca_codex_binary}" ]]; then
|
||||
if [[ -n "${ORCA_CODEX_LAUNCH_PREFLIGHT:-}" && -x "${ORCA_CODEX_LAUNCH_PREFLIGHT}" && -n "${__orca_codex_binary:-}" && -x "${__orca_codex_binary}" ]]; then
|
||||
# Why the function reserved word: it suppresses alias expansion of the name,
|
||||
# which otherwise rewrites this header at parse time and aborts the whole file.
|
||||
function codex {
|
||||
|
||||
@@ -2,7 +2,7 @@ function __orca_shell_ready_marker --on-event fish_prompt
|
||||
builtin printf "\033]777;orca-shell-ready\007"
|
||||
functions -e __orca_shell_ready_marker
|
||||
end
|
||||
if test -n "$ORCA_CODEX_LAUNCH_PREFLIGHT"; and test (type -t codex 2>/dev/null) = file
|
||||
if test -x "$ORCA_CODEX_LAUNCH_PREFLIGHT"; and test (type -t codex 2>/dev/null) = file
|
||||
function codex
|
||||
command "$ORCA_CODEX_LAUNCH_PREFLIGHT" agent hooks prepare-codex >/dev/null 2>&1; or true
|
||||
command codex $argv
|
||||
|
||||
@@ -110,7 +110,7 @@ __orca_deferred_init() {
|
||||
# Why || : twice — zsh alone aborts inside the substitution, but every shell's
|
||||
# assignment adopts its exit status, so an absent codex trips set -e in bash too.
|
||||
__orca_codex_binary="$(unalias codex 2>/dev/null || :; command -v codex 2>/dev/null || :)"
|
||||
if [[ -n "${ORCA_CODEX_LAUNCH_PREFLIGHT:-}" && -n "${__orca_codex_binary:-}" && -x "${__orca_codex_binary}" ]]; then
|
||||
if [[ -n "${ORCA_CODEX_LAUNCH_PREFLIGHT:-}" && -x "${ORCA_CODEX_LAUNCH_PREFLIGHT}" && -n "${__orca_codex_binary:-}" && -x "${__orca_codex_binary}" ]]; then
|
||||
# Why the function reserved word: it suppresses alias expansion of the name,
|
||||
# which otherwise rewrites this header at parse time and aborts the whole file.
|
||||
function codex {
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { AgentHookSource } from '../../shared/agent-hook-relay'
|
||||
import { ORCA_HOOK_RAW_JSON_TRANSPORT } from '../../shared/agent-hook-types'
|
||||
|
||||
export function buildPosixAgentHookPostCommand(
|
||||
source: AgentHookSource,
|
||||
options: { curlCommand?: string; indent?: string } = {}
|
||||
): string[] {
|
||||
const curlCommand = options.curlCommand ?? 'curl'
|
||||
const indent = options.indent ?? ' '
|
||||
return [
|
||||
`if [ "\${ORCA_AGENT_HOOK_TRANSPORT:-}" = "${ORCA_HOOK_RAW_JSON_TRANSPORT}" ] && command -v base64 >/dev/null 2>&1 && command -v tr >/dev/null 2>&1; then`,
|
||||
` orca_hook_metadata=$(printf '%s\\037%s\\037%s\\037%s\\037%s\\037%s' "$ORCA_PANE_KEY" "$ORCA_TAB_ID" "$ORCA_AGENT_LAUNCH_TOKEN" "$ORCA_WORKTREE_ID" "$ORCA_AGENT_HOOK_ENV" "$ORCA_AGENT_HOOK_VERSION" | base64 | tr -d '\\n') && \\`,
|
||||
` [ -n "$orca_hook_metadata" ] && \\`,
|
||||
` printf '%s' "$payload" | ${curlCommand} -sS -X POST "http://127.0.0.1:\${ORCA_AGENT_HOOK_PORT}/hook/${source}" \\`,
|
||||
` ${indent}--connect-timeout "\${connect_timeout:-0.5}" --max-time "\${max_time:-1.5}" \\`,
|
||||
` ${indent}--noproxy "127.0.0.1" \\`,
|
||||
` ${indent}-H "Content-Type: application/json" \\`,
|
||||
` ${indent}-H "X-Orca-Agent-Hook-Token: \${ORCA_AGENT_HOOK_TOKEN}" \\`,
|
||||
` ${indent}-H "X-Orca-Agent-Hook-Meta-Encoding: base64" \\`,
|
||||
` ${indent}-H "X-Orca-Agent-Hook-Meta: \${orca_hook_metadata}" \\`,
|
||||
` ${indent}--data-binary @-`,
|
||||
'else',
|
||||
` printf '%s' "$payload" | ${curlCommand} -sS -X POST "http://127.0.0.1:\${ORCA_AGENT_HOOK_PORT}/hook/${source}" \\`,
|
||||
` ${indent}--connect-timeout "\${connect_timeout:-0.5}" --max-time "\${max_time:-1.5}" \\`,
|
||||
` ${indent}--noproxy "127.0.0.1" \\`,
|
||||
` ${indent}-H "Content-Type: application/x-www-form-urlencoded" \\`,
|
||||
` ${indent}-H "X-Orca-Agent-Hook-Token: \${ORCA_AGENT_HOOK_TOKEN}" \\`,
|
||||
` ${indent}--data-urlencode "paneKey=\${ORCA_PANE_KEY}" \\`,
|
||||
` ${indent}--data-urlencode "tabId=\${ORCA_TAB_ID}" \\`,
|
||||
` ${indent}--data-urlencode "launchToken=\${ORCA_AGENT_LAUNCH_TOKEN}" \\`,
|
||||
` ${indent}--data-urlencode "worktreeId=\${ORCA_WORKTREE_ID}" \\`,
|
||||
` ${indent}--data-urlencode "env=\${ORCA_AGENT_HOOK_ENV}" \\`,
|
||||
` ${indent}--data-urlencode "version=\${ORCA_AGENT_HOOK_VERSION}" \\`,
|
||||
` ${indent}--data-urlencode "payload@-"`,
|
||||
'fi'
|
||||
]
|
||||
}
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
writeHooksJson,
|
||||
type HooksConfig
|
||||
} from './installer-utils'
|
||||
import { buildPosixAgentHookPostCommand } from './hook-post-command'
|
||||
import { POSIX_HOOK_STDIN_DRAIN_COMMAND } from './hook-stdin-contract'
|
||||
import { wrapRuntimeHomeHookCommand } from './runtime-home-hook-command'
|
||||
|
||||
@@ -882,6 +883,26 @@ describe('buildWindowsAgentHookPostCommand', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildPosixAgentHookPostCommand', () => {
|
||||
it('uses raw JSON only when the listener advertises support', () => {
|
||||
const command = buildPosixAgentHookPostCommand('claude').join('\n')
|
||||
|
||||
expect(command).toContain('ORCA_AGENT_HOOK_TRANSPORT:-}')
|
||||
expect(command).toContain('raw-json-v1')
|
||||
expect(command).toContain('command -v base64')
|
||||
expect(command).toContain('command -v tr')
|
||||
expect(command).toContain('Content-Type: application/json')
|
||||
expect(command).toContain('X-Orca-Agent-Hook-Meta-Encoding: base64')
|
||||
expect(command).toContain('X-Orca-Agent-Hook-Meta: ${orca_hook_metadata}')
|
||||
expect(command).toContain("printf '%s\\037%s\\037%s\\037%s\\037%s\\037%s'")
|
||||
expect(command).toContain('$ORCA_PANE_KEY')
|
||||
expect(command).toContain('$ORCA_WORKTREE_ID')
|
||||
expect(command).toContain('--data-binary @-')
|
||||
expect(command).toContain('Content-Type: application/x-www-form-urlencoded')
|
||||
expect(command).toContain('--data-urlencode "payload@-"')
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildWindowsAgentHookCurlPostCommand', () => {
|
||||
it('posts form fields via curl.exe and reads the payload from stdin', () => {
|
||||
const command = buildWindowsAgentHookCurlPostCommand('codex')
|
||||
|
||||
@@ -284,7 +284,7 @@ describe('remote hook service installers', () => {
|
||||
hooks: Record<string, { hooks: { command: string }[] }[]>
|
||||
}
|
||||
expect(hooks.hooks.Stop?.[0]?.hooks?.[0]?.command).toContain(
|
||||
'/home/dev/.orca/agent-hooks/codex-hook.sh'
|
||||
'/home/dev/.local/share/orca/codex-runtime-home/home/.orca/agent-hooks/codex-hook.sh'
|
||||
)
|
||||
expect(fs.files.get(`${runtimeHome}/config.toml`)).toContain(
|
||||
`${runtimeHome}/hooks.json:stop:0:0`
|
||||
|
||||
@@ -66,6 +66,7 @@ describe('Endpoint file lifecycle', () => {
|
||||
expect(contents).toContain(`${prefix}ORCA_AGENT_HOOK_TOKEN=${expectedToken}`)
|
||||
expect(contents).toContain(`${prefix}ORCA_AGENT_HOOK_ENV=development`)
|
||||
expect(contents).toContain(`${prefix}ORCA_AGENT_HOOK_VERSION=1`)
|
||||
expect(contents).toContain(`${prefix}ORCA_AGENT_HOOK_TRANSPORT=raw-json-v1`)
|
||||
} finally {
|
||||
server.stop()
|
||||
}
|
||||
|
||||
@@ -44,6 +44,38 @@ async function postClaudeHook(
|
||||
}
|
||||
|
||||
describe('AgentHookServer listener replay', () => {
|
||||
it('accepts raw JSON hook bodies with base64 metadata headers', async () => {
|
||||
const server = new AgentHookServer()
|
||||
await server.start({ env: 'production' })
|
||||
try {
|
||||
const env = server.buildPtyEnv()
|
||||
const response = await fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/claude`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN,
|
||||
'X-Orca-Agent-Hook-Meta-Encoding': 'base64',
|
||||
'X-Orca-Agent-Hook-Meta': Buffer.from(
|
||||
[PANE, 'tab-1', '', 'wt-1', 'production', ''].join('\x1f')
|
||||
).toString('base64')
|
||||
},
|
||||
body: JSON.stringify({ hook_event_name: 'UserPromptSubmit', prompt: 'raw JSON' })
|
||||
})
|
||||
|
||||
expect(response.status).toBe(204)
|
||||
expect(server.getStatusSnapshot()).toEqual([
|
||||
expect.objectContaining({
|
||||
paneKey: PANE,
|
||||
worktreeId: 'wt-1',
|
||||
state: 'working',
|
||||
prompt: 'raw JSON'
|
||||
})
|
||||
])
|
||||
} finally {
|
||||
server.stop()
|
||||
}
|
||||
})
|
||||
|
||||
it('caches and notifies status/main/plugin before retry scheduling and HTTP response', async () => {
|
||||
const server = new AgentHookServer()
|
||||
await server.start({ env: 'production' })
|
||||
|
||||
@@ -8,7 +8,10 @@ import { join } from 'node:path'
|
||||
import { track } from '../telemetry/client'
|
||||
import { getCohortAtEmit } from '../telemetry/cohort-classifier'
|
||||
import { AGENT_KIND_VALUES, type AgentKind } from '../../shared/telemetry-events'
|
||||
import { ORCA_HOOK_PROTOCOL_VERSION } from '../../shared/agent-hook-types'
|
||||
import {
|
||||
ORCA_HOOK_PROTOCOL_VERSION,
|
||||
ORCA_HOOK_RAW_JSON_TRANSPORT
|
||||
} from '../../shared/agent-hook-types'
|
||||
import {
|
||||
clearAllListenerCaches,
|
||||
clearPaneCacheState,
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -12,19 +12,23 @@ import {
|
||||
type ManagedHookDetectionSettings
|
||||
} from './managed-hook-detection-commands'
|
||||
import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer'
|
||||
import { wslCodexRuntimeHomeForGuestHome } from '../pty/codex-home-wsl-env'
|
||||
import { WSL_HOOK_FS_METHODS, type WslFsResult } from '../../shared/wsl-hook-relay-contract'
|
||||
import type { AgentHookInstallStatus } from '../../shared/agent-hook-types'
|
||||
|
||||
/** Run the shared remote hook installers against a WSL guest over the relay's fs bridge. */
|
||||
export async function installWslGuestHooks(options: {
|
||||
mux: SshChannelMultiplexer
|
||||
guestHome: string
|
||||
codexHomePath: string | null
|
||||
distro: string
|
||||
installHooks: typeof installRemoteManagedAgentHooks
|
||||
settings: ManagedHookDetectionSettings
|
||||
warn: (message: string) => void
|
||||
/** Canonical runtime-host installer for redirected Codex homes. */
|
||||
installCodex: (runtimeHomePath: string, distro: string) => Promise<AgentHookInstallStatus | null>
|
||||
}): Promise<void> {
|
||||
const { mux, guestHome, distro, installHooks, settings, warn } = options
|
||||
const { mux, guestHome, codexHomePath, distro, installHooks, settings, warn, installCodex } =
|
||||
options
|
||||
let agents
|
||||
try {
|
||||
const detected = (await mux.request('preflight.detectAgents', {
|
||||
@@ -42,12 +46,25 @@ export async function installWslGuestHooks(options: {
|
||||
if (agents.length === 0) {
|
||||
return
|
||||
}
|
||||
if (agents.includes('codex') && codexHomePath) {
|
||||
try {
|
||||
const status = await installCodex(codexHomePath, distro)
|
||||
if (status?.state === 'error') {
|
||||
warn(
|
||||
`[agent-hooks] WSL Codex hook install for '${distro}' failed: ${status.detail ?? 'unknown error'}`
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
warn(
|
||||
`[agent-hooks] WSL Codex hook install for '${distro}' failed: ${error instanceof Error ? error.message : String(error)}`
|
||||
)
|
||||
}
|
||||
}
|
||||
// Codex is redirected into the runtime home and must use the canonical
|
||||
// runtime-host writer above; the relay adapter owns all other agents.
|
||||
const remoteAgents = agents.filter((agent) => agent !== 'codex')
|
||||
const results = await installHooks(createWslHookSftpAdapter(mux), guestHome, {
|
||||
agents,
|
||||
// WSL Codex launches use Orca's managed runtime CODEX_HOME, not ~/.codex.
|
||||
// Keep relay hooks in that active home so status callbacks are received.
|
||||
codexHomeDir: wslCodexRuntimeHomeForGuestHome(guestHome),
|
||||
deferTrustUntilConfigToml: true
|
||||
agents: remoteAgents
|
||||
})
|
||||
const failed = results.filter((r) => r.state === 'error').length
|
||||
if (failed > 0) {
|
||||
|
||||
@@ -9,6 +9,8 @@ import { agentHookServer } from './server'
|
||||
import type { ManagedHookDetectionSettings } from './managed-hook-detection-commands'
|
||||
import { installRemoteManagedAgentHooks } from './remote-managed-hook-installers'
|
||||
import { getOpenCodePluginSource } from '../opencode/hook-service'
|
||||
import { codexHookService } from '../codex/hook-service'
|
||||
import type { AgentHookInstallStatus } from '../../shared/agent-hook-types'
|
||||
import type { PluginSources } from '../../relay/plugin-overlay'
|
||||
import {
|
||||
isWslDistroRunning,
|
||||
@@ -61,6 +63,7 @@ export type WslHookRelayManagerDeps = {
|
||||
waitForSentinel: typeof waitForWslRelaySentinel
|
||||
ingest: (envelope: Record<string, unknown>, connectionId: string) => void
|
||||
installHooks: typeof installRemoteManagedAgentHooks
|
||||
installCodex: (runtimeHomePath: string, distro: string) => Promise<AgentHookInstallStatus | null>
|
||||
managedHookSettings: () => ManagedHookDetectionSettings
|
||||
/** Plugin source strings shipped to the guest relay so an Orca update needn't redeploy the relay bundle. */
|
||||
pluginSources: () => PluginSources
|
||||
@@ -102,6 +105,11 @@ export const defaultWslHookRelayDeps: WslHookRelayManagerDeps = {
|
||||
connectionId
|
||||
),
|
||||
installHooks: installRemoteManagedAgentHooks,
|
||||
installCodex: (runtimeHomePath, distro) =>
|
||||
codexHookService.installForRuntimeHomeSerialized(runtimeHomePath, {
|
||||
runtime: 'wsl',
|
||||
wslDistro: distro
|
||||
}),
|
||||
managedHookSettings: () => null,
|
||||
// Why: only OpenCode is in scope for WSL now; the payload shape stays identical to SSH so Pi/OMP are additive later.
|
||||
pluginSources: () => ({ opencodePluginSource: getOpenCodePluginSource() }),
|
||||
|
||||
@@ -6,13 +6,14 @@ import type { ManagedHookDetectionSettings } from './managed-hook-detection-comm
|
||||
import type { installRemoteManagedAgentHooks } from './remote-managed-hook-installers'
|
||||
import { requestGuestOpenCodeOverlayDir } from './wsl-guest-plugin-install'
|
||||
import { installWslGuestHooks } from './wsl-hook-fs-adapter'
|
||||
import { REINSTALL_MIN_INTERVAL_MS } from './wsl-hook-relay-deps'
|
||||
import { REINSTALL_MIN_INTERVAL_MS, type WslHookRelayManagerDeps } from './wsl-hook-relay-deps'
|
||||
import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer'
|
||||
import type { PluginSources } from '../../relay/plugin-overlay'
|
||||
|
||||
/** Structural slice of WslHookRelayManagerDeps — only what an install pass uses. */
|
||||
type GuestInstallDeps = {
|
||||
installHooks: typeof installRemoteManagedAgentHooks
|
||||
installCodex: WslHookRelayManagerDeps['installCodex']
|
||||
managedHookSettings: () => ManagedHookDetectionSettings
|
||||
pluginSources: () => PluginSources
|
||||
warn: (message: string) => void
|
||||
@@ -23,6 +24,7 @@ type GuestInstallState = {
|
||||
distro: string
|
||||
mux?: SshChannelMultiplexer
|
||||
guestHome?: string
|
||||
codexHomePath?: string
|
||||
opencodeOverlayDir?: string
|
||||
lastInstallAt?: number
|
||||
}
|
||||
@@ -37,8 +39,10 @@ export async function runWslRelayGuestInstall(
|
||||
await installWslGuestHooks({
|
||||
mux,
|
||||
guestHome,
|
||||
codexHomePath: state.codexHomePath ?? null,
|
||||
distro: state.distro,
|
||||
installHooks: deps.installHooks,
|
||||
installCodex: deps.installCodex,
|
||||
settings: deps.managedHookSettings(),
|
||||
warn: deps.warn
|
||||
})
|
||||
|
||||
@@ -13,7 +13,8 @@ import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { AgentHookServer } from './server'
|
||||
import { WslHookRelayManager } from './wsl-hook-relay-manager'
|
||||
import { wslCodexRuntimeHomeForGuestHome } from '../pty/codex-home-wsl-env'
|
||||
import { createManagedHookLocalFilesystem } from './managed-hook-local-filesystem'
|
||||
import { codexHookService } from '../codex/hook-service'
|
||||
|
||||
const BUNDLE_DIR = join(process.cwd(), 'out', 'relay', 'wsl')
|
||||
const BUNDLE_JS = join(BUNDLE_DIR, 'wsl-agent-hook-relay.js')
|
||||
@@ -75,6 +76,7 @@ describe.skipIf(process.platform === 'win32')(
|
||||
const server = orcaServer
|
||||
|
||||
const warns: string[] = []
|
||||
const codexHome = join(fakeHome, '.local', 'share', 'orca', 'codex-runtime-home', 'home')
|
||||
manager = new WslHookRelayManager({
|
||||
platform: () => 'win32',
|
||||
remoteHooksEnabled: () => true,
|
||||
@@ -102,6 +104,11 @@ describe.skipIf(process.platform === 'win32')(
|
||||
envelope as Parameters<AgentHookServer['ingestRemote']>[0],
|
||||
connectionId
|
||||
),
|
||||
installCodex: (runtimeHomePath) =>
|
||||
codexHookService.installRemote(createManagedHookLocalFilesystem(), fakeHome, {
|
||||
codexHomeDir: runtimeHomePath,
|
||||
deferTrustUntilConfigToml: true
|
||||
}),
|
||||
managedHookSettings: () => ({
|
||||
agentCmdOverrides: {
|
||||
claude: process.execPath,
|
||||
@@ -112,15 +119,17 @@ describe.skipIf(process.platform === 'win32')(
|
||||
transientRetryDelayMs: 1
|
||||
})
|
||||
|
||||
manager.ensureForDistro('LiveDistro')
|
||||
manager.ensureForDistro('LiveDistro', codexHome)
|
||||
|
||||
// Waiting on Codex's artifact (not Claude's, which is written first) keeps the
|
||||
// assertions behind the still-running 14-agent installer loop.
|
||||
const codexHome = wslCodexRuntimeHomeForGuestHome(fakeHome)
|
||||
await vi.waitFor(() => expect(existsSync(join(codexHome, 'hooks.json'))).toBe(true), {
|
||||
timeout: 15_000
|
||||
})
|
||||
expect(existsSync(join(fakeHome, '.claude', 'settings.json'))).toBe(true)
|
||||
await vi.waitFor(
|
||||
() => expect(existsSync(join(fakeHome, '.claude', 'settings.json'))).toBe(true),
|
||||
{ timeout: 15_000 }
|
||||
)
|
||||
const claudeScript = readFileSync(
|
||||
join(fakeHome, '.orca', 'agent-hooks', 'claude-hook.sh'),
|
||||
'utf8'
|
||||
|
||||
@@ -147,6 +147,8 @@ describe('WslHookRelayManager', () => {
|
||||
// hosts — installHooks is mocked here, so the fs bridge only ever serves
|
||||
// the wslfs.home request and never touches the real filesystem.
|
||||
const home = '/home/wsl-test-user'
|
||||
const codexHome =
|
||||
'\\\\wsl.localhost\\Ubuntu\\home\\wsl-test-user\\.local\\share\\orca\\codex-runtime-home\\home'
|
||||
const opencodeOverlayDir = `${home}/.orca-relay/opencode-overlays/deadbeefcafe`
|
||||
let harnesses: GuestHarness[]
|
||||
|
||||
@@ -229,6 +231,13 @@ describe('WslHookRelayManager', () => {
|
||||
waitForSentinel: vi.fn(async () => guestTransport()),
|
||||
ingest: vi.fn(),
|
||||
installHooks: vi.fn(async () => []),
|
||||
installCodex: vi.fn(async () => ({
|
||||
agent: 'codex' as const,
|
||||
state: 'installed' as const,
|
||||
configPath: `${home}/.local/share/orca/codex-runtime-home/home/hooks.json`,
|
||||
managedHooksPresent: true,
|
||||
detail: null
|
||||
})),
|
||||
managedHookSettings: () => null,
|
||||
pluginSources: () => ({ opencodePluginSource: '// opencode plugin source' }),
|
||||
warn: vi.fn(),
|
||||
@@ -240,14 +249,14 @@ describe('WslHookRelayManager', () => {
|
||||
|
||||
it('starts one relay per distro, installs hooks, exposes the guest endpoint path, and forwards envelopes', async () => {
|
||||
const { manager, deps } = createManager({})
|
||||
manager.ensureForDistro('Ubuntu')
|
||||
manager.ensureForDistro('Ubuntu')
|
||||
manager.ensureForDistro('Ubuntu', codexHome)
|
||||
manager.ensureForDistro('Ubuntu', codexHome)
|
||||
await vi.waitFor(() => expect(deps.installHooks).toHaveBeenCalledTimes(1))
|
||||
expect(deps.spawnRelay).toHaveBeenCalledTimes(1)
|
||||
expect(deps.installCodex).toHaveBeenCalledWith(codexHome, 'Ubuntu')
|
||||
// Codex is owned by the canonical runtime-host writer, not the relay adapter.
|
||||
expect(deps.installHooks).toHaveBeenCalledWith(expect.anything(), home, {
|
||||
agents: ['codex'],
|
||||
codexHomeDir: `${home}/.local/share/orca/codex-runtime-home/home`,
|
||||
deferTrustUntilConfigToml: true
|
||||
agents: []
|
||||
})
|
||||
|
||||
expect(manager.getGuestEndpointFilePath('Ubuntu')).toBe(
|
||||
@@ -272,9 +281,23 @@ describe('WslHookRelayManager', () => {
|
||||
manager.disposeAll()
|
||||
})
|
||||
|
||||
it('reinstalls into a newly resolved runtime home without restarting the relay', async () => {
|
||||
const { manager, deps } = createManager({})
|
||||
manager.ensureForDistro('Ubuntu', codexHome)
|
||||
await vi.waitFor(() => expect(deps.installCodex).toHaveBeenCalledTimes(1))
|
||||
const nextHome = codexHome.replace('codex-runtime-home', 'codex-accounts\\account-2')
|
||||
|
||||
manager.ensureForDistro('ubuntu', nextHome)
|
||||
await vi.waitFor(() => expect(deps.installCodex).toHaveBeenCalledTimes(2))
|
||||
|
||||
expect(deps.installCodex).toHaveBeenLastCalledWith(nextHome, 'Ubuntu')
|
||||
expect(deps.spawnRelay).toHaveBeenCalledTimes(1)
|
||||
manager.disposeAll()
|
||||
})
|
||||
|
||||
it('ships the OpenCode plugin to the guest and exposes the overlay dir', async () => {
|
||||
const { manager } = createManager({})
|
||||
manager.ensureForDistro('Ubuntu')
|
||||
manager.ensureForDistro('Ubuntu', codexHome)
|
||||
await vi.waitFor(() => expect(manager.getOpenCodeOverlayDir('Ubuntu')).toBe(opencodeOverlayDir))
|
||||
manager.disposeAll()
|
||||
})
|
||||
@@ -397,7 +420,7 @@ describe('WslHookRelayManager', () => {
|
||||
it('stops live relays and refuses to revive them once agent status hooks are switched off', async () => {
|
||||
const settings = { agentStatusHooksEnabled: true }
|
||||
const { manager, deps } = createManager({ managedHookSettings: () => settings })
|
||||
manager.ensureForDistro('Ubuntu')
|
||||
manager.ensureForDistro('Ubuntu', codexHome)
|
||||
await vi.waitFor(() => expect(deps.installHooks).toHaveBeenCalledTimes(1))
|
||||
|
||||
settings.agentStatusHooksEnabled = false
|
||||
@@ -414,6 +437,8 @@ describe('WslHookRelayManager', () => {
|
||||
settings.agentStatusHooksEnabled = true
|
||||
manager.resumeStoppedRelays()
|
||||
await vi.waitFor(() => expect(deps.spawnRelay).toHaveBeenCalledTimes(2))
|
||||
await vi.waitFor(() => expect(deps.installCodex).toHaveBeenCalledTimes(2))
|
||||
expect(deps.installCodex).toHaveBeenLastCalledWith(codexHome, 'Ubuntu')
|
||||
manager.disposeAll()
|
||||
})
|
||||
|
||||
|
||||
@@ -29,6 +29,10 @@ import {
|
||||
WSL_HOOK_FS_METHODS,
|
||||
wslHookRelayEndpointFilePath
|
||||
} from '../../shared/wsl-hook-relay-contract'
|
||||
import {
|
||||
recordManagedWslCodexHome,
|
||||
wslRuntimeHomePathsEqual
|
||||
} from '../codex/managed-wsl-codex-home-registry'
|
||||
|
||||
type DistroState = {
|
||||
/** Original casing for wsl.exe argv and breadcrumbs; map keys are lowercased. */
|
||||
@@ -37,6 +41,7 @@ type DistroState = {
|
||||
child?: ChildProcessWithoutNullStreams
|
||||
mux?: SshChannelMultiplexer
|
||||
guestHome?: string
|
||||
codexHomePath?: string
|
||||
guestEndpointFilePath?: string
|
||||
opencodeOverlayDir?: string
|
||||
failures: number
|
||||
@@ -52,7 +57,7 @@ export class WslHookRelayManager {
|
||||
private recovery: WslRelayRecovery
|
||||
private states = new Map<string, DistroState>()
|
||||
/** Distros a hooks-off teardown stopped, so re-enabling can put them back. */
|
||||
private stoppedByHooksOff = new Set<string>()
|
||||
private stoppedByHooksOff = new Map<string, string | undefined>()
|
||||
private defaultDistro: string | null = null
|
||||
private disposed = false
|
||||
private warnedBundleMissing = false
|
||||
@@ -64,7 +69,7 @@ export class WslHookRelayManager {
|
||||
warn: (message) => this.deps.warn(message),
|
||||
isDisposed: () => this.disposed,
|
||||
isCurrent: (state) => this.states.get(wslHookRelayStateKey(state.distro)) === state,
|
||||
restart: (distro) => this.ensureForDistro(distro),
|
||||
restart: (distro) => this.ensureForDistro(distro, this.stateFor(distro)?.codexHomePath),
|
||||
dropState: (state) => {
|
||||
// Why: identity-guarded — a fresh ensure() may own this key by now;
|
||||
// deleting by key alone would orphan its live relay child.
|
||||
@@ -81,11 +86,11 @@ export class WslHookRelayManager {
|
||||
}
|
||||
|
||||
/** Fire-and-forget from every WSL PTY spawn-env build; errors breadcrumb. */
|
||||
ensureForDistro(distro: string | null): void {
|
||||
ensureForDistro(distro: string | null, codexHomePath?: string | null): void {
|
||||
if (this.disposed || !isWslHookRelayAllowed(this.deps)) {
|
||||
return
|
||||
}
|
||||
void this.ensureInternal(distro).catch((err) => {
|
||||
void this.ensureInternal(distro, codexHomePath ?? undefined).catch((err) => {
|
||||
const detail = err instanceof Error ? err.message : String(err)
|
||||
this.deps.warn(`[agent-hooks] WSL hook relay ensure failed: ${detail}`)
|
||||
})
|
||||
@@ -118,7 +123,7 @@ export class WslHookRelayManager {
|
||||
state.mux?.dispose()
|
||||
state.child?.kill()
|
||||
if (!permanent) {
|
||||
this.stoppedByHooksOff.add(state.distro)
|
||||
this.stoppedByHooksOff.set(state.distro, state.codexHomePath)
|
||||
}
|
||||
}
|
||||
this.states.clear()
|
||||
@@ -129,26 +134,39 @@ export class WslHookRelayManager {
|
||||
resumeStoppedRelays(): void {
|
||||
const distros = [...this.stoppedByHooksOff]
|
||||
this.stoppedByHooksOff.clear()
|
||||
for (const distro of distros) {
|
||||
for (const [distro, codexHomePath] of distros) {
|
||||
void this.deps
|
||||
.isDistroRunning(distro)
|
||||
.then((running) => {
|
||||
if (running) {
|
||||
this.ensureForDistro(distro)
|
||||
this.ensureForDistro(distro, codexHomePath)
|
||||
}
|
||||
})
|
||||
.catch(() => undefined)
|
||||
}
|
||||
}
|
||||
|
||||
private async ensureInternal(requestedDistro: string | null): Promise<void> {
|
||||
private async ensureInternal(
|
||||
requestedDistro: string | null,
|
||||
requestedCodexHomePath?: string
|
||||
): Promise<void> {
|
||||
const distro = requestedDistro ?? (await this.resolveDefaultDistro())
|
||||
if (!distro || this.disposed) {
|
||||
return
|
||||
}
|
||||
const key = wslHookRelayStateKey(distro)
|
||||
const existing = this.states.get(key)
|
||||
if (requestedCodexHomePath) {
|
||||
recordManagedWslCodexHome(distro, requestedCodexHomePath)
|
||||
}
|
||||
if (existing) {
|
||||
if (
|
||||
requestedCodexHomePath &&
|
||||
!wslRuntimeHomePathsEqual(existing.codexHomePath, requestedCodexHomePath)
|
||||
) {
|
||||
existing.codexHomePath = requestedCodexHomePath
|
||||
existing.lastInstallAt = 0
|
||||
}
|
||||
if (existing.phase === 'running') {
|
||||
void maybeRerunWslRelayGuestInstall(this.deps, existing)
|
||||
return
|
||||
@@ -184,6 +202,7 @@ export class WslHookRelayManager {
|
||||
// Why: instance-keyed and on the distro's persistent fs, so it outlives a relay
|
||||
// crash — dropping it would blank status on panes spawned mid-relaunch.
|
||||
opencodeOverlayDir: existing?.opencodeOverlayDir,
|
||||
codexHomePath: requestedCodexHomePath ?? existing?.codexHomePath,
|
||||
cooldownUntil: 0
|
||||
}
|
||||
this.states.set(key, state)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -539,12 +539,14 @@ describe('ClaudeHookService.installRemote', () => {
|
||||
expect(script!.indexOf('printf "{}\\n"')).toBe(
|
||||
script!.indexOf('#!/bin/sh') + '#!/bin/sh\n'.length
|
||||
)
|
||||
// Why: payload is piped to curl via stdin (`payload@-`) so it never lands
|
||||
// on the curl command line (EDR oversized-command-line false positive),
|
||||
// matching the Windows curl.exe hook post.
|
||||
// Why: payload stays on stdin, while metadata headers avoid URL-encoded IDS signatures.
|
||||
expect(script).toContain('printf \'%s\' "$payload" | curl')
|
||||
expect(script).toContain('-H "Content-Type: application/json"')
|
||||
expect(script).toContain('orca_hook_metadata=$(printf')
|
||||
expect(script).toContain('unset ORCA_AGENT_HOOK_TRANSPORT')
|
||||
expect(script).toContain('-H "X-Orca-Agent-Hook-Meta: ${orca_hook_metadata}"')
|
||||
expect(script).toContain('--data-binary @-')
|
||||
expect(script).toContain('--data-urlencode "payload@-"')
|
||||
expect(script).not.toContain('--data-urlencode "payload=${payload}"')
|
||||
expect(fs.modes.get('/home/dev/.orca/agent-hooks/claude-hook.sh')).toBe(0o755)
|
||||
// Why: no remote statusLine — this path serves SSH remotes and WSL guests, whose relay
|
||||
// listener doesn't route /statusline/claude and whose accounts aren't attributable locally.
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
writeManagedScript,
|
||||
type HooksConfig
|
||||
} from '../agent-hooks/installer-utils'
|
||||
import { buildPosixAgentHookPostCommand } from '../agent-hooks/hook-post-command'
|
||||
import {
|
||||
readHooksJsonRemote,
|
||||
writeHooksJsonRemote,
|
||||
@@ -114,25 +115,17 @@ function getManagedScript(
|
||||
// Why: refresh endpoint coordinates for PTYs surviving an Orca restart.
|
||||
// Why: suppress parse errors so they neither leak nor trip outer set -e.
|
||||
'if [ -n "$ORCA_AGENT_HOOK_ENDPOINT" ] && [ -r "$ORCA_AGENT_HOOK_ENDPOINT" ]; then',
|
||||
' unset ORCA_AGENT_HOOK_TRANSPORT',
|
||||
' . "$ORCA_AGENT_HOOK_ENDPOINT" 2>/dev/null || :',
|
||||
'fi',
|
||||
'if [ -z "$ORCA_AGENT_HOOK_PORT" ] || [ -z "$ORCA_AGENT_HOOK_TOKEN" ] || [ -z "$ORCA_PANE_KEY" ]; then',
|
||||
' spool_hook_event',
|
||||
' exit 0',
|
||||
'fi',
|
||||
// Why: post form fields because path-bearing payloads are unsafe in hand-built JSON.
|
||||
// Why: pipe payload to curl stdin to keep large output off the command line.
|
||||
'printf \'%s\' "$payload" | curl -sS -X POST "http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/claude" \\',
|
||||
' --connect-timeout 0.5 --max-time 1.5 \\',
|
||||
' -H "Content-Type: application/x-www-form-urlencoded" \\',
|
||||
' -H "X-Orca-Agent-Hook-Token: ${ORCA_AGENT_HOOK_TOKEN}" \\',
|
||||
' --data-urlencode "paneKey=${ORCA_PANE_KEY}" \\',
|
||||
' --data-urlencode "tabId=${ORCA_TAB_ID}" \\',
|
||||
' --data-urlencode "launchToken=${ORCA_AGENT_LAUNCH_TOKEN}" \\',
|
||||
' --data-urlencode "worktreeId=${ORCA_WORKTREE_ID}" \\',
|
||||
' --data-urlencode "env=${ORCA_AGENT_HOOK_ENV}" \\',
|
||||
' --data-urlencode "version=${ORCA_AGENT_HOOK_VERSION}" \\',
|
||||
' --data-urlencode "payload@-" >/dev/null 2>&1 || spool_hook_event',
|
||||
// Why: keep full hook JSON off the command line and avoid IDS-friendly URL-encoded paths.
|
||||
...buildPosixAgentHookPostCommand('claude').map((line, index, lines) =>
|
||||
index === lines.length - 1 ? `${line} >/dev/null 2>&1 || spool_hook_event` : line
|
||||
),
|
||||
'exit 0',
|
||||
''
|
||||
].join('\n')
|
||||
|
||||
@@ -108,4 +108,36 @@ describe('runExclusivelyForCodexTrustConfig', () => {
|
||||
await queued
|
||||
expect(secondStarted).toBe(true)
|
||||
})
|
||||
|
||||
it('coalesces WSL UNC aliases without folding the case-sensitive Linux path', async () => {
|
||||
const aliasGate = deferred()
|
||||
let aliasStarted = false
|
||||
const blockedAlias = runExclusivelyForCodexTrustConfig(
|
||||
String.raw`\\wsl.localhost\Ubuntu\home\Alice\.codex\config.toml`,
|
||||
() => aliasGate.promise
|
||||
)
|
||||
const queuedAlias = runExclusivelyForCodexTrustConfig(
|
||||
String.raw`\\wsl$\ubuntu\home\Alice\.codex\config.toml`,
|
||||
() => {
|
||||
aliasStarted = true
|
||||
return Promise.resolve()
|
||||
}
|
||||
)
|
||||
await Promise.resolve()
|
||||
expect(aliasStarted).toBe(false)
|
||||
|
||||
let distinctStarted = false
|
||||
await runExclusivelyForCodexTrustConfig(
|
||||
String.raw`\\wsl.localhost\Ubuntu\home\alice\.codex\config.toml`,
|
||||
async () => {
|
||||
distinctStarted = true
|
||||
}
|
||||
)
|
||||
expect(distinctStarted).toBe(true)
|
||||
|
||||
aliasGate.resolve()
|
||||
await blockedAlias
|
||||
await queuedAlias
|
||||
expect(aliasStarted).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
type CodexTrustEntry
|
||||
} from './config-toml-trust'
|
||||
import {
|
||||
CodexHookService,
|
||||
_internals,
|
||||
createCodexWslRuntimeHookInstallPlan,
|
||||
type CodexWslRuntimeHookInstallPlan
|
||||
@@ -82,7 +83,67 @@ function expectedManagedCommand(scriptPath: string): string {
|
||||
}
|
||||
|
||||
describe('Codex WSL runtime hook install', () => {
|
||||
it('plans WSL hook files with Linux command and trust paths', async () => {
|
||||
it('coalesces aliases of one runtime home without blocking independent homes', async () => {
|
||||
const service = new CodexHookService()
|
||||
const releases: (() => void)[] = []
|
||||
const started: string[] = []
|
||||
vi.spyOn(service, 'installForRuntimeHome').mockImplementation(async (runtimeHomePath) => {
|
||||
started.push(runtimeHomePath!)
|
||||
await new Promise<void>((resolve) => releases.push(resolve))
|
||||
return null
|
||||
})
|
||||
const firstHome = '\\\\wsl$\\Ubuntu\\home\\Alice\\.local\\share\\orca\\codex-runtime-home\\home'
|
||||
const alias = firstHome.replace('\\\\wsl$', '\\\\wsl.localhost')
|
||||
const independent = firstHome.replace('\\Alice\\', '\\Bob\\')
|
||||
|
||||
const first = service.installForRuntimeHomeSerialized(firstHome)
|
||||
const second = service.installForRuntimeHomeSerialized(alias)
|
||||
const third = service.installForRuntimeHomeSerialized(independent)
|
||||
await vi.waitFor(() => expect(started).toEqual([firstHome, independent]))
|
||||
expect(second).toBe(first)
|
||||
|
||||
releases.splice(0).forEach((release) => release())
|
||||
await Promise.all([first, second, third])
|
||||
expect(started).toEqual([firstHome, independent])
|
||||
})
|
||||
|
||||
it('does not coalesce one drive-backed home across WSL distros', async () => {
|
||||
const service = new CodexHookService()
|
||||
const started: string[] = []
|
||||
vi.spyOn(service, 'installForRuntimeHome').mockImplementation(async (_runtimeHome, target) => {
|
||||
started.push(target?.wslDistro ?? '')
|
||||
return null
|
||||
})
|
||||
const home = 'D:\\wsl-home\\.local\\share\\orca\\codex-runtime-home\\home'
|
||||
|
||||
await Promise.all([
|
||||
service.installForRuntimeHomeSerialized(home, { runtime: 'wsl', wslDistro: 'Ubuntu' }),
|
||||
service.installForRuntimeHomeSerialized(home, { runtime: 'wsl', wslDistro: 'Debian' })
|
||||
])
|
||||
|
||||
expect(started).toEqual(['Ubuntu', 'Debian'])
|
||||
})
|
||||
|
||||
it('keeps case-distinct Linux runtime homes on separate queues', async () => {
|
||||
const service = new CodexHookService()
|
||||
const started: string[] = []
|
||||
vi.spyOn(service, 'installForRuntimeHome').mockImplementation(async (runtimeHomePath) => {
|
||||
started.push(runtimeHomePath!)
|
||||
return null
|
||||
})
|
||||
const upper =
|
||||
'\\\\wsl.localhost\\Ubuntu\\home\\Alice\\.local\\share\\orca\\codex-runtime-home\\home'
|
||||
const lower = upper.replace('\\Alice\\', '\\alice\\')
|
||||
|
||||
await Promise.all([
|
||||
service.installForRuntimeHomeSerialized(upper),
|
||||
service.installForRuntimeHomeSerialized(lower)
|
||||
])
|
||||
|
||||
expect(started).toEqual([upper, lower])
|
||||
})
|
||||
|
||||
it('plans WSL hook files with Linux command and trust paths', () => {
|
||||
const runtimeHome =
|
||||
'\\\\wsl.localhost\\Ubuntu\\home\\alice\\.local\\share\\orca\\codex-runtime-home\\home'
|
||||
|
||||
@@ -275,11 +336,15 @@ describe('Codex WSL runtime hook install', () => {
|
||||
it('generates a POSIX hook that bridges WSL loopback failures through Windows curl', async () => {
|
||||
const script = _internals.getManagedScript('posix')
|
||||
expect(script).toContain('load_hook_endpoint()')
|
||||
expect(script).toContain('unset ORCA_AGENT_HOOK_TRANSPORT')
|
||||
expect(script).toContain('"set ORCA_AGENT_HOOK_TOKEN="*)')
|
||||
expect(script).toContain('post_codex_hook()')
|
||||
expect(script).toContain('is_wsl_runtime()')
|
||||
expect(script).toContain('WSL_DISTRO_NAME')
|
||||
expect(script).toContain('windows_curl=$(command -v curl.exe 2>/dev/null || true)')
|
||||
expect(script).toContain('-H "Content-Type: application/json"')
|
||||
expect(script).toContain('-H "X-Orca-Agent-Hook-Meta-Encoding: base64"')
|
||||
expect(script).toContain('--data-binary @-')
|
||||
expect(script).toContain('--data-urlencode "payload@-"')
|
||||
expect(script).toContain('if post_codex_hook curl >/dev/null 2>&1; then')
|
||||
expect(script).toContain('post_codex_hook "$windows_curl" 3 5 >/dev/null 2>&1 || true')
|
||||
@@ -302,6 +367,7 @@ describe('Codex WSL runtime hook install', () => {
|
||||
'set ORCA_AGENT_HOOK_TOKEN=fresh-token',
|
||||
'set ORCA_AGENT_HOOK_ENV=development',
|
||||
'set ORCA_AGENT_HOOK_VERSION=1',
|
||||
'set ORCA_AGENT_HOOK_TRANSPORT=raw-json-v1',
|
||||
''
|
||||
].join('\r\n'),
|
||||
'utf-8'
|
||||
@@ -333,6 +399,7 @@ describe('Codex WSL runtime hook install', () => {
|
||||
const posted = readFileSync(capturePath, 'utf-8')
|
||||
expect(posted).toContain('http://127.0.0.1:43210/hook/codex')
|
||||
expect(posted).toContain('X-Orca-Agent-Hook-Token: fresh-token')
|
||||
expect(posted).toContain('Content-Type: application/json')
|
||||
expect(posted).not.toContain('stale-token')
|
||||
}
|
||||
)
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
writeManagedScript,
|
||||
type HookDefinition
|
||||
} from '../agent-hooks/installer-utils'
|
||||
import { buildPosixAgentHookPostCommand } from '../agent-hooks/hook-post-command'
|
||||
import { refreshManagedScriptIfPresent } from '../agent-hooks/managed-hook-script-refresh'
|
||||
import { resolveHooksJsonWritePath } from '../agent-hooks/hook-config-write-path'
|
||||
import { writeFileAtomically } from '../codex-accounts/fs-utils'
|
||||
@@ -823,6 +824,7 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string {
|
||||
// Why: sourcing refreshes PORT/TOKEN/ENV/VERSION from the current Orca so a surviving PTY keeps reporting after a restart (see claude/hook-service.ts).
|
||||
'load_hook_endpoint() {',
|
||||
' endpoint_path="$1"',
|
||||
' unset ORCA_AGENT_HOOK_TRANSPORT',
|
||||
' case "$endpoint_path" in',
|
||||
' *.cmd)',
|
||||
// Why: Windows passes endpoint.cmd into WSL via WSLENV; parse only Orca's known assignments since cmd.exe `set` lines aren't shell syntax.
|
||||
@@ -834,6 +836,7 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string {
|
||||
' "set ORCA_AGENT_HOOK_TOKEN="*) ORCA_AGENT_HOOK_TOKEN=${endpoint_line#*=} ;;',
|
||||
' "set ORCA_AGENT_HOOK_ENV="*) ORCA_AGENT_HOOK_ENV=${endpoint_line#*=} ;;',
|
||||
' "set ORCA_AGENT_HOOK_VERSION="*) ORCA_AGENT_HOOK_VERSION=${endpoint_line#*=} ;;',
|
||||
' "set ORCA_AGENT_HOOK_TRANSPORT="*) ORCA_AGENT_HOOK_TRANSPORT=${endpoint_line#*=} ;;',
|
||||
' esac',
|
||||
' done < "$endpoint_path"',
|
||||
' ;;',
|
||||
@@ -853,20 +856,11 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string {
|
||||
' curl_bin="$1"',
|
||||
' connect_timeout="${2:-0.5}"',
|
||||
' max_time="${3:-1.5}"',
|
||||
// Why: worktreeId embeds a path, so hand-building JSON in shell is unsafe with quotes/newlines; post raw payload plus metadata as form fields instead.
|
||||
// Why: pipe payload to curl's stdin (`payload@-`) not an inline arg, so tens-of-KB tool output stays off the command line (EDR false positives).
|
||||
' printf \'%s\' "$payload" | "$curl_bin" -sS -X POST "http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/codex" \\',
|
||||
' --connect-timeout "$connect_timeout" --max-time "$max_time" \\',
|
||||
' --noproxy "127.0.0.1" \\',
|
||||
' -H "Content-Type: application/x-www-form-urlencoded" \\',
|
||||
' -H "X-Orca-Agent-Hook-Token: ${ORCA_AGENT_HOOK_TOKEN}" \\',
|
||||
' --data-urlencode "paneKey=${ORCA_PANE_KEY}" \\',
|
||||
' --data-urlencode "tabId=${ORCA_TAB_ID}" \\',
|
||||
' --data-urlencode "launchToken=${ORCA_AGENT_LAUNCH_TOKEN}" \\',
|
||||
' --data-urlencode "worktreeId=${ORCA_WORKTREE_ID}" \\',
|
||||
' --data-urlencode "env=${ORCA_AGENT_HOOK_ENV}" \\',
|
||||
' --data-urlencode "version=${ORCA_AGENT_HOOK_VERSION}" \\',
|
||||
' --data-urlencode "payload@-"',
|
||||
// Why: keep full hook JSON off the command line and avoid URL-encoding paths/commands into IDS-friendly traversal signatures.
|
||||
...buildPosixAgentHookPostCommand('codex', {
|
||||
curlCommand: '"$curl_bin"',
|
||||
indent: ' '
|
||||
}).map((line) => ` ${line}`),
|
||||
'}',
|
||||
'is_wsl_runtime() {',
|
||||
' [ -n "$WSL_DISTRO_NAME" ] && return 0',
|
||||
@@ -1081,6 +1075,7 @@ export class CodexHookService {
|
||||
}
|
||||
|
||||
private readonly wslReconciliationGeneration = new Map<string, number>()
|
||||
private readonly wslInstallsInFlight = new Map<string, Promise<AgentHookInstallStatus | null>>()
|
||||
|
||||
private supersedeWslReconciliation(runtimeHomePath: string | null | undefined): number {
|
||||
if (!runtimeHomePath) {
|
||||
@@ -1177,6 +1172,30 @@ export class CodexHookService {
|
||||
}
|
||||
}
|
||||
|
||||
installForRuntimeHomeSerialized(
|
||||
runtimeHomePath: string | null | undefined,
|
||||
target?: CodexWslRuntimeHookTarget
|
||||
): Promise<AgentHookInstallStatus | null> {
|
||||
if (!runtimeHomePath) {
|
||||
return Promise.resolve(null)
|
||||
}
|
||||
const targetKey = target?.runtime === 'wsl' ? target.wslDistro?.trim().toLowerCase() : ''
|
||||
const key = `${getWslReconciliationKey(runtimeHomePath)}\0${targetKey ?? ''}`
|
||||
const active = this.wslInstallsInFlight.get(key)
|
||||
if (active) {
|
||||
return active
|
||||
}
|
||||
const install = this.installForRuntimeHome(runtimeHomePath, target)
|
||||
this.wslInstallsInFlight.set(key, install)
|
||||
const clear = (): void => {
|
||||
if (this.wslInstallsInFlight.get(key) === install) {
|
||||
this.wslInstallsInFlight.delete(key)
|
||||
}
|
||||
}
|
||||
void install.then(clear, clear)
|
||||
return install
|
||||
}
|
||||
|
||||
refreshRuntimeUserHooksForRuntimeHome(
|
||||
runtimeHomePath: string | null | undefined,
|
||||
target?: CodexWslRuntimeHookTarget
|
||||
@@ -1492,7 +1511,13 @@ export class CodexHookService {
|
||||
options?.codexHomeDir?.replace(/\/$/, '') ?? `${remoteHome.replace(/\/$/, '')}/.codex`
|
||||
const remoteConfigPath = `${codexHomeBase}/hooks.json`
|
||||
const remoteTomlPath = `${codexHomeBase}/config.toml`
|
||||
const remoteScriptPath = `${remoteHome.replace(/\/$/, '')}/.orca/agent-hooks/codex-hook.sh`
|
||||
// Redirected WSL homes must use the same script location and command shape
|
||||
// as the runtime installer; two representations of one hooks.json race
|
||||
// into stale trust keys. Plain SSH keeps its guest-home script contract.
|
||||
const redirectedCodexHome = options?.codexHomeDir?.replace(/\/$/, '')
|
||||
const remoteScriptPath = redirectedCodexHome
|
||||
? `${redirectedCodexHome}/.orca/agent-hooks/codex-hook.sh`
|
||||
: `${remoteHome.replace(/\/$/, '')}/.orca/agent-hooks/codex-hook.sh`
|
||||
try {
|
||||
const config = await readHooksJsonRemote(sftp, remoteConfigPath)
|
||||
if (!config) {
|
||||
@@ -1505,7 +1530,9 @@ export class CodexHookService {
|
||||
}
|
||||
}
|
||||
|
||||
const command = wrapPosixHookCommand(remoteScriptPath)
|
||||
const command = redirectedCodexHome
|
||||
? wrapReadablePosixHookCommand(remoteScriptPath)
|
||||
: wrapPosixHookCommand(remoteScriptPath)
|
||||
const nextHooks = { ...config.hooks }
|
||||
const managedEvents = new Set<string>(CODEX_EVENTS)
|
||||
const isManagedCommand = createManagedCommandMatcher('codex-hook.sh')
|
||||
@@ -1529,11 +1556,13 @@ export class CodexHookService {
|
||||
const definition: HookDefinition = {
|
||||
hooks: [buildManagedCommandHook(command)]
|
||||
}
|
||||
nextHooks[eventName] = [...cleaned, definition]
|
||||
nextHooks[eventName] = redirectedCodexHome
|
||||
? [definition, ...cleaned]
|
||||
: [...cleaned, definition]
|
||||
trustEntries.push({
|
||||
sourcePath: remoteConfigPath,
|
||||
eventLabel: CODEX_EVENT_LABEL[eventName],
|
||||
groupIndex: cleaned.length,
|
||||
groupIndex: redirectedCodexHome ? 0 : cleaned.length,
|
||||
handlerIndex: 0,
|
||||
command,
|
||||
timeoutSec: MANAGED_HOOK_TIMEOUT_SECONDS
|
||||
|
||||
@@ -2,10 +2,25 @@ import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { wslRealpathMock } = vi.hoisted(() => ({
|
||||
wslRealpathMock: vi.fn(async (path: string) => path)
|
||||
}))
|
||||
|
||||
vi.mock('node:fs/promises', () => ({ realpath: wslRealpathMock }))
|
||||
|
||||
import {
|
||||
prepareManagedCodexHomeBeforeShellLaunch,
|
||||
resolveManagedCodexShellPreflightHome
|
||||
} from './managed-home-shell-preflight'
|
||||
import {
|
||||
prepareManagedWslCodexHomeBeforeShellLaunch,
|
||||
resolveManagedWslCodexShellPreflightTarget
|
||||
} from './managed-wsl-home-shell-preflight'
|
||||
import {
|
||||
_internals as managedWslHomeRegistryInternals,
|
||||
recordManagedWslCodexHome
|
||||
} from './managed-wsl-codex-home-registry'
|
||||
|
||||
const roots: string[] = []
|
||||
|
||||
@@ -16,6 +31,8 @@ function makeRoot(): string {
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
wslRealpathMock.mockClear()
|
||||
managedWslHomeRegistryInternals.clearRecordedManagedWslCodexHomes()
|
||||
for (const root of roots.splice(0)) {
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
@@ -160,3 +177,123 @@ describe('managed Codex shell preflight', () => {
|
||||
).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('managed WSL Codex shell preflight', () => {
|
||||
const home = '/home/jin/.local/share/orca/codex-runtime-home/home'
|
||||
const runtimeHome =
|
||||
'\\\\wsl.localhost\\Ubuntu-24.04\\home\\jin\\.local\\share\\orca\\codex-runtime-home\\home'
|
||||
const env = {
|
||||
CODEX_HOME: home,
|
||||
ORCA_CODEX_HOME: home,
|
||||
WSL_DISTRO_NAME: 'Ubuntu-24.04'
|
||||
}
|
||||
|
||||
it('targets the pane-selected managed home through its UNC twin', () => {
|
||||
recordManagedWslCodexHome('Ubuntu-24.04', runtimeHome)
|
||||
expect(resolveManagedWslCodexShellPreflightTarget(env)).toEqual({
|
||||
runtimeHomePath: runtimeHome,
|
||||
wslDistro: 'Ubuntu-24.04'
|
||||
})
|
||||
})
|
||||
|
||||
it('reconstructs a validated managed home when a restarted runtime has no record', () => {
|
||||
expect(resolveManagedWslCodexShellPreflightTarget(env)).toEqual({
|
||||
runtimeHomePath: runtimeHome,
|
||||
wslDistro: 'Ubuntu-24.04'
|
||||
})
|
||||
})
|
||||
|
||||
it('installs once through the WSL runtime-home lane while hooks are enabled', async () => {
|
||||
recordManagedWslCodexHome('Ubuntu-24.04', runtimeHome)
|
||||
const status = {
|
||||
agent: 'codex' as const,
|
||||
state: 'installed' as const,
|
||||
configPath: 'config.toml',
|
||||
managedHooksPresent: true,
|
||||
detail: null
|
||||
}
|
||||
const install = vi.fn(() => status)
|
||||
|
||||
await expect(
|
||||
prepareManagedWslCodexHomeBeforeShellLaunch({ env, hooksEnabled: true, install })
|
||||
).resolves.toBe(status)
|
||||
expect(install).toHaveBeenCalledExactlyOnceWith(runtimeHome, {
|
||||
runtime: 'wsl',
|
||||
wslDistro: 'Ubuntu-24.04'
|
||||
})
|
||||
|
||||
await expect(
|
||||
prepareManagedWslCodexHomeBeforeShellLaunch({ env, hooksEnabled: false, install })
|
||||
).resolves.toBeNull()
|
||||
expect(install).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it.each([
|
||||
[
|
||||
'a user home',
|
||||
{ ...env, CODEX_HOME: '/home/jin/.codex', ORCA_CODEX_HOME: '/home/jin/.codex' }
|
||||
],
|
||||
['unequal routing markers', { ...env, ORCA_CODEX_HOME: `${home}-other` }],
|
||||
[
|
||||
'a parent traversal',
|
||||
{
|
||||
...env,
|
||||
CODEX_HOME: `/home/jin/../jin${home.slice('/home/jin'.length)}`,
|
||||
ORCA_CODEX_HOME: `/home/jin/../jin${home.slice('/home/jin'.length)}`
|
||||
}
|
||||
],
|
||||
[
|
||||
'a dot segment',
|
||||
{
|
||||
...env,
|
||||
CODEX_HOME: `/home/./jin${home.slice('/home/jin'.length)}`,
|
||||
ORCA_CODEX_HOME: `/home/./jin${home.slice('/home/jin'.length)}`
|
||||
}
|
||||
],
|
||||
[
|
||||
'a host path',
|
||||
{ ...env, CODEX_HOME: 'C:\\Users\\jin\\.codex', ORCA_CODEX_HOME: 'C:\\Users\\jin\\.codex' }
|
||||
],
|
||||
['a missing distro', { ...env, WSL_DISTRO_NAME: '' }],
|
||||
['a distro path escape', { ...env, WSL_DISTRO_NAME: 'Ubuntu\\..\\host' }],
|
||||
[
|
||||
'a repeated separator',
|
||||
{
|
||||
...env,
|
||||
CODEX_HOME: home.replace('/jin/', '/jin//'),
|
||||
ORCA_CODEX_HOME: home.replace('/jin/', '/jin//')
|
||||
}
|
||||
]
|
||||
])('rejects %s', (_label, candidate) => {
|
||||
recordManagedWslCodexHome('Ubuntu-24.04', runtimeHome)
|
||||
expect(resolveManagedWslCodexShellPreflightTarget(candidate)).toBeNull()
|
||||
})
|
||||
|
||||
it('preserves a recorded runtime spelling for a managed account home', () => {
|
||||
const directHome = '/home/jin/.local/share/orca/codex-accounts/account-1/home'
|
||||
const directRuntimeHome =
|
||||
'\\\\wsl$\\Ubuntu-24.04\\home\\jin\\.local\\share\\orca\\codex-accounts\\account-1\\home'
|
||||
recordManagedWslCodexHome('Ubuntu-24.04', directRuntimeHome)
|
||||
|
||||
expect(
|
||||
resolveManagedWslCodexShellPreflightTarget({
|
||||
CODEX_HOME: directHome,
|
||||
ORCA_CODEX_HOME: directHome,
|
||||
WSL_DISTRO_NAME: 'ubuntu-24.04'
|
||||
})
|
||||
).toEqual({ runtimeHomePath: directRuntimeHome, wslDistro: 'ubuntu-24.04' })
|
||||
})
|
||||
|
||||
it('never authorizes a WSL system home even when it was offered by a PTY lane', () => {
|
||||
const systemHome = '/home/jin/.codex'
|
||||
recordManagedWslCodexHome('Ubuntu-24.04', '\\\\wsl.localhost\\Ubuntu-24.04\\home\\jin\\.codex')
|
||||
|
||||
expect(
|
||||
resolveManagedWslCodexShellPreflightTarget({
|
||||
CODEX_HOME: systemHome,
|
||||
ORCA_CODEX_HOME: systemHome,
|
||||
WSL_DISTRO_NAME: 'Ubuntu-24.04'
|
||||
})
|
||||
).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import { win32 as pathWin32 } from 'node:path'
|
||||
import {
|
||||
foldWslUncPathCaseInsensitiveParts,
|
||||
parseWslUncPath,
|
||||
toWindowsWslPath
|
||||
} from '../../shared/wsl-paths'
|
||||
|
||||
type ManagedWslCodexHome = {
|
||||
runtimeHomePath: string
|
||||
linuxHomePath: string
|
||||
}
|
||||
|
||||
const managedHomesByDistro = new Map<string, Map<string, ManagedWslCodexHome>>()
|
||||
|
||||
function distroKey(distro: string): string {
|
||||
return distro.trim().toLowerCase()
|
||||
}
|
||||
|
||||
export function isAbsolutePosixPathWithoutDotSegments(value: string): boolean {
|
||||
const segments = value.split('/').slice(1)
|
||||
return (
|
||||
value.startsWith('/') &&
|
||||
!value.startsWith('//') &&
|
||||
!value.includes('\\') &&
|
||||
segments.every((segment) => segment && segment !== '.' && segment !== '..')
|
||||
)
|
||||
}
|
||||
|
||||
function isOrcaManagedWslCodexHome(linuxHomePath: string): boolean {
|
||||
const segments = linuxHomePath.split('/').filter(Boolean)
|
||||
const orcaIndex = segments.findIndex(
|
||||
(segment, index) =>
|
||||
segment === 'orca' && segments[index - 1] === 'share' && segments[index - 2] === '.local'
|
||||
)
|
||||
if (orcaIndex === -1) {
|
||||
return false
|
||||
}
|
||||
const tail = segments.slice(orcaIndex + 1)
|
||||
return (
|
||||
(tail.length === 2 && tail[0] === 'codex-runtime-home' && tail[1] === 'home') ||
|
||||
(tail.length === 3 && tail[0] === 'codex-accounts' && Boolean(tail[1]) && tail[2] === 'home')
|
||||
)
|
||||
}
|
||||
|
||||
function linuxHomeForRuntimePath(runtimeHomePath: string, distro: string): string | null {
|
||||
const wsl = parseWslUncPath(runtimeHomePath)
|
||||
if (wsl) {
|
||||
return wsl.distro.toLowerCase() === distroKey(distro) ? wsl.linuxPath : null
|
||||
}
|
||||
const drive = runtimeHomePath.match(/^([A-Za-z]):[/\\](.*)$/)
|
||||
return drive ? `/mnt/${drive[1].toLowerCase()}/${drive[2].replace(/\\/g, '/')}` : null
|
||||
}
|
||||
|
||||
export function recordManagedWslCodexHome(distro: string, runtimeHomePath: string): void {
|
||||
const normalizedDistro = distro.trim()
|
||||
const linuxHomePath = linuxHomeForRuntimePath(runtimeHomePath, normalizedDistro)
|
||||
if (
|
||||
!normalizedDistro ||
|
||||
/[\\/\r\n]/.test(normalizedDistro) ||
|
||||
!linuxHomePath ||
|
||||
!isAbsolutePosixPathWithoutDotSegments(linuxHomePath) ||
|
||||
!isOrcaManagedWslCodexHome(linuxHomePath)
|
||||
) {
|
||||
return
|
||||
}
|
||||
const homes = managedHomesByDistro.get(distroKey(normalizedDistro)) ?? new Map()
|
||||
homes.set(linuxHomePath, { runtimeHomePath, linuxHomePath })
|
||||
managedHomesByDistro.set(distroKey(normalizedDistro), homes)
|
||||
}
|
||||
|
||||
export function resolveRecordedManagedWslCodexHome(
|
||||
distro: string,
|
||||
linuxHomePath: string
|
||||
): string | null {
|
||||
if (
|
||||
!distro.trim() ||
|
||||
/[\\/\r\n]/.test(distro) ||
|
||||
!isAbsolutePosixPathWithoutDotSegments(linuxHomePath)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
return managedHomesByDistro.get(distroKey(distro))?.get(linuxHomePath)?.runtimeHomePath ?? null
|
||||
}
|
||||
|
||||
export function resolveManagedWslCodexHome(distro: string, linuxHomePath: string): string | null {
|
||||
if (
|
||||
!distro.trim() ||
|
||||
/[\\/\r\n]/.test(distro) ||
|
||||
!isAbsolutePosixPathWithoutDotSegments(linuxHomePath) ||
|
||||
!isOrcaManagedWslCodexHome(linuxHomePath)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
resolveRecordedManagedWslCodexHome(distro, linuxHomePath) ??
|
||||
toWindowsWslPath(linuxHomePath, distro)
|
||||
)
|
||||
}
|
||||
|
||||
export function wslRuntimeHomePathsEqual(left: string | undefined, right: string): boolean {
|
||||
if (!left) {
|
||||
return false
|
||||
}
|
||||
const leftWsl = foldWslUncPathCaseInsensitiveParts(left)
|
||||
const rightWsl = foldWslUncPathCaseInsensitiveParts(right)
|
||||
if (leftWsl || rightWsl) {
|
||||
return leftWsl !== null && leftWsl === rightWsl
|
||||
}
|
||||
return pathWin32.normalize(left).toLowerCase() === pathWin32.normalize(right).toLowerCase()
|
||||
}
|
||||
|
||||
export const _internals = {
|
||||
clearRecordedManagedWslCodexHomes: () => managedHomesByDistro.clear(),
|
||||
isOrcaManagedWslCodexHome
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { realpath } from 'node:fs/promises'
|
||||
import type { AgentHookInstallStatus } from '../../shared/agent-hook-types'
|
||||
import { withTimeout } from '../../shared/promise-timeout-fallback'
|
||||
import type { CodexWslRuntimeHookTarget } from './codex-wsl-hook-install-plan'
|
||||
import { codexHookService } from './hook-service'
|
||||
import {
|
||||
isAbsolutePosixPathWithoutDotSegments,
|
||||
resolveManagedWslCodexHome,
|
||||
wslRuntimeHomePathsEqual
|
||||
} from './managed-wsl-codex-home-registry'
|
||||
|
||||
type WslShellPreflightEnvironment = {
|
||||
CODEX_HOME?: string
|
||||
ORCA_CODEX_HOME?: string
|
||||
WSL_DISTRO_NAME?: string
|
||||
}
|
||||
|
||||
const WSL_MANAGED_HOME_REALPATH_TIMEOUT_MS = 5_000
|
||||
|
||||
export type ManagedWslCodexShellPreflightTarget = {
|
||||
runtimeHomePath: string
|
||||
wslDistro: string
|
||||
}
|
||||
|
||||
export function resolveManagedWslCodexShellPreflightTarget(
|
||||
env: WslShellPreflightEnvironment
|
||||
): ManagedWslCodexShellPreflightTarget | null {
|
||||
const codexHome = env.CODEX_HOME?.trim()
|
||||
const orcaCodexHome = env.ORCA_CODEX_HOME?.trim()
|
||||
const wslDistro = env.WSL_DISTRO_NAME?.trim()
|
||||
if (
|
||||
!codexHome ||
|
||||
codexHome !== orcaCodexHome ||
|
||||
!wslDistro ||
|
||||
/[\\/\r\n]/.test(wslDistro) ||
|
||||
!isAbsolutePosixPathWithoutDotSegments(codexHome)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
const runtimeHomePath = resolveManagedWslCodexHome(wslDistro, codexHome)
|
||||
return runtimeHomePath ? { runtimeHomePath, wslDistro } : null
|
||||
}
|
||||
|
||||
export async function prepareManagedWslCodexHomeBeforeShellLaunch(args: {
|
||||
env: WslShellPreflightEnvironment
|
||||
hooksEnabled: boolean
|
||||
install?: (
|
||||
runtimeHomePath: string,
|
||||
target: CodexWslRuntimeHookTarget
|
||||
) => AgentHookInstallStatus | Promise<AgentHookInstallStatus | null> | null
|
||||
}): Promise<AgentHookInstallStatus | null> {
|
||||
if (!args.hooksEnabled) {
|
||||
return null
|
||||
}
|
||||
const target = resolveManagedWslCodexShellPreflightTarget(args.env)
|
||||
if (!target) {
|
||||
return null
|
||||
}
|
||||
const realHome = await withTimeout(
|
||||
realpath(target.runtimeHomePath),
|
||||
WSL_MANAGED_HOME_REALPATH_TIMEOUT_MS,
|
||||
null
|
||||
)
|
||||
if (!realHome || !wslRuntimeHomePathsEqual(realHome, target.runtimeHomePath)) {
|
||||
return null
|
||||
}
|
||||
const install =
|
||||
args.install ??
|
||||
((home: string, hookTarget: CodexWslRuntimeHookTarget) =>
|
||||
codexHookService.installForRuntimeHomeSerialized(home, hookTarget))
|
||||
return await install(target.runtimeHomePath, {
|
||||
runtime: 'wsl',
|
||||
wslDistro: target.wslDistro
|
||||
})
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
type Tray,
|
||||
session
|
||||
} from 'electron'
|
||||
import { applyMacPressAndHoldDefaultAtStartup } from './macos-press-and-hold-default'
|
||||
import { initTccPromptNotice, stopTccPromptNotice } from './macos-tcc-prompt-notice'
|
||||
import { electronApp, is } from '@electron-toolkit/utils'
|
||||
import {
|
||||
@@ -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,
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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' })
|
||||
|
||||
@@ -122,7 +122,7 @@ export function buildPtyHostEnv(
|
||||
if (opts.isWsl === true) {
|
||||
// Why: hook POSTs to 127.0.0.1 die inside WSL's NAT namespace; use the guest-resident relay's endpoint instead of the Windows one.
|
||||
const distro = opts.wslDistro ?? null
|
||||
wslHookRelayManager.ensureForDistro(distro)
|
||||
wslHookRelayManager.ensureForDistro(distro, opts.selectedCodexHomePath)
|
||||
const guestEndpoint = wslHookRelayManager.getGuestEndpointFilePath(distro)
|
||||
if (guestEndpoint) {
|
||||
baseEnv.ORCA_AGENT_HOOK_ENDPOINT = guestEndpoint
|
||||
|
||||
@@ -3,6 +3,7 @@ export const AGENT_HOOK_RUNTIME_ENV_KEYS = [
|
||||
'ORCA_AGENT_HOOK_TOKEN',
|
||||
'ORCA_AGENT_HOOK_ENV',
|
||||
'ORCA_AGENT_HOOK_VERSION',
|
||||
'ORCA_AGENT_HOOK_TRANSPORT',
|
||||
'ORCA_AGENT_HOOK_ENDPOINT',
|
||||
// Why: PR 2778 briefly exported this path; keep deleting stale inherited values so older PTYs can't leak the reverted path.
|
||||
'ORCA_CLAUDE_AGENT_STATUS_SETTINGS'
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { chmodSync, mkdirSync, mkdtempSync, rmSync } from 'node:fs'
|
||||
import { homedir, tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { runProcessSync } from '../shared/child-process/run-process'
|
||||
import {
|
||||
PRESS_AND_HOLD_KEY,
|
||||
ensureMacPressAndHoldDefault,
|
||||
interpretDefaultsRead,
|
||||
readDomainPressAndHoldPreference,
|
||||
writeDomainPressAndHoldPreference,
|
||||
type PressAndHoldHost,
|
||||
type PressAndHoldRecord
|
||||
} from './macos-press-and-hold-default'
|
||||
|
||||
/**
|
||||
* Runs against the real `/usr/bin/defaults` on a throwaway Orca-owned domain.
|
||||
*
|
||||
* The whole design rests on one claim the mocks cannot make: a domain that has never been written
|
||||
* is distinguishable from one explicitly set to `false`. Electron's `systemPreferences` cannot tell
|
||||
* them apart, so if `defaults` could not either, "only write when unset" would be unimplementable.
|
||||
*
|
||||
* Every domain used here is a throwaway UUID under Orca's own prefix, deleted along with its plist
|
||||
* in `afterEach`; the real `com.stablyai.orca` domain is never read or written.
|
||||
*/
|
||||
|
||||
// Why a real Orca-owned domain shape: the ownership guard rejects anything else, so a fake prefix
|
||||
// would exercise a different branch than production.
|
||||
const domains: string[] = []
|
||||
|
||||
/** Pinned against the real binary below, then reused by the platform-agnostic unit tests. */
|
||||
const DEFAULTS_MISSING_EXIT_CODE = 1
|
||||
|
||||
function throwawayDomain(): string {
|
||||
const domain = `com.stablyai.orca.defaults-domain-test.${randomUUID()}`
|
||||
domains.push(domain)
|
||||
return domain
|
||||
}
|
||||
|
||||
function write(domain: string, value: 'true' | 'false'): void {
|
||||
execFileSync('/usr/bin/defaults', ['write', domain, PRESS_AND_HOLD_KEY, '-bool', value], {
|
||||
stdio: 'ignore'
|
||||
})
|
||||
}
|
||||
|
||||
function rawRead(domain: string): string {
|
||||
return execFileSync('/usr/bin/defaults', ['read', domain, PRESS_AND_HOLD_KEY], {
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'ignore']
|
||||
}).trim()
|
||||
}
|
||||
|
||||
function hostFor(domain: string, record: PressAndHoldRecord | null = null): PressAndHoldHost {
|
||||
return {
|
||||
platform: 'darwin',
|
||||
resolveBundleIdentifier: () => domain,
|
||||
readRecord: () => record,
|
||||
writeRecord: () => undefined,
|
||||
readDomainPreference: readDomainPressAndHoldPreference,
|
||||
writeDomainPreference: writeDomainPressAndHoldPreference,
|
||||
now: () => new Date().toISOString()
|
||||
}
|
||||
}
|
||||
|
||||
describe.skipIf(process.platform !== 'darwin')(
|
||||
'press-and-hold against the real defaults binary',
|
||||
() => {
|
||||
afterEach(() => {
|
||||
for (const domain of domains.splice(0)) {
|
||||
try {
|
||||
execFileSync('/usr/bin/defaults', ['delete', domain], { stdio: 'ignore' })
|
||||
} catch {
|
||||
// The domain may never have been created; nothing to clean up.
|
||||
}
|
||||
// Why the file too: `defaults delete` empties the domain but leaves its plist behind, so
|
||||
// without this every run litters the developer's ~/Library/Preferences.
|
||||
rmSync(join(homedir(), 'Library', 'Preferences', `${domain}.plist`), { force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('tells an unset key apart from an explicit false', () => {
|
||||
const unset = throwawayDomain()
|
||||
const explicitlyFalse = throwawayDomain()
|
||||
write(explicitlyFalse, 'false')
|
||||
|
||||
expect(readDomainPressAndHoldPreference(unset)).toBe('unset')
|
||||
expect(readDomainPressAndHoldPreference(explicitlyFalse)).toBe('set')
|
||||
expect(rawRead(explicitlyFalse)).toBe('0')
|
||||
})
|
||||
|
||||
it('reports an explicit true as set', () => {
|
||||
const domain = throwawayDomain()
|
||||
write(domain, 'true')
|
||||
|
||||
expect(readDomainPressAndHoldPreference(domain)).toBe('set')
|
||||
expect(rawRead(domain)).toBe('1')
|
||||
})
|
||||
|
||||
it('writes false into a previously unset domain', () => {
|
||||
const domain = throwawayDomain()
|
||||
|
||||
expect(ensureMacPressAndHoldDefault(hostFor(domain))).toBe('applied')
|
||||
expect(rawRead(domain)).toBe('0')
|
||||
})
|
||||
|
||||
it('leaves an explicit value untouched, in either direction', () => {
|
||||
for (const value of ['true', 'false'] as const) {
|
||||
const domain = throwawayDomain()
|
||||
write(domain, value)
|
||||
|
||||
expect(ensureMacPressAndHoldDefault(hostFor(domain))).toBe('kept-user-preference')
|
||||
expect(rawRead(domain)).toBe(value === 'true' ? '1' : '0')
|
||||
}
|
||||
})
|
||||
|
||||
it('refuses to call a failed probe "unset"', () => {
|
||||
// Real runs, not hand-built results: a binary that is not there must come back 'unknown'
|
||||
// through the same path production takes, not be mistaken for "the key does not exist".
|
||||
const spawnFailure = (): ReturnType<typeof runProcessSync> =>
|
||||
runProcessSync({
|
||||
program: '/usr/bin/defaults-does-not-exist',
|
||||
args: ['read'],
|
||||
stdio: ['ignore', 'ignore', 'ignore']
|
||||
})
|
||||
expect(spawnFailure).toThrow()
|
||||
expect(interpretDefaultsRead(spawnFailure)).toBe('unknown')
|
||||
|
||||
const missingKey = runProcessSync({
|
||||
program: '/usr/bin/defaults',
|
||||
args: ['read', throwawayDomain(), PRESS_AND_HOLD_KEY],
|
||||
stdio: ['ignore', 'ignore', 'ignore']
|
||||
})
|
||||
// The one exit code the whole design reads as unset; every other outcome is 'unknown'.
|
||||
expect(missingKey.code).toBe(DEFAULTS_MISSING_EXIT_CODE)
|
||||
expect(missingKey.timedOut).toBe(false)
|
||||
expect(interpretDefaultsRead(() => missingKey)).toBe('unset')
|
||||
})
|
||||
|
||||
// Skipped as root, where the read-only directory below would still be writable.
|
||||
it.skipIf(process.getuid?.() === 0)('reports a write the binary refused', () => {
|
||||
// Why a real refusal: `defaults write` exits non-zero instead of throwing, so a caller that
|
||||
// only caught exceptions would record 'applied' for a value that never reached the plist.
|
||||
const readOnly = join(mkdtempSync(join(tmpdir(), 'orca-press-hold-ro-')), 'locked')
|
||||
mkdirSync(readOnly)
|
||||
chmodSync(readOnly, 0o500)
|
||||
try {
|
||||
expect(writeDomainPressAndHoldPreference(join(readOnly, 'domain'), false)).toBe(false)
|
||||
} finally {
|
||||
chmodSync(readOnly, 0o700)
|
||||
rmSync(readOnly, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('sees the key disappear again after the user deletes it', () => {
|
||||
const domain = throwawayDomain()
|
||||
write(domain, 'false')
|
||||
execFileSync('/usr/bin/defaults', ['delete', domain, PRESS_AND_HOLD_KEY], { stdio: 'ignore' })
|
||||
|
||||
expect(readDomainPressAndHoldPreference(domain)).toBe('unset')
|
||||
// A launch that already decided must not rewrite it, which is what makes the delete stick.
|
||||
const decided: PressAndHoldRecord = {
|
||||
version: 1,
|
||||
decision: 'applied',
|
||||
domain,
|
||||
decidedAt: '2026-01-01T00:00:00.000Z'
|
||||
}
|
||||
expect(ensureMacPressAndHoldDefault(hostFor(domain, decided))).toBe('already-decided')
|
||||
expect(readDomainPressAndHoldPreference(domain)).toBe('unset')
|
||||
})
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,286 @@
|
||||
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { ProcessResult } from '../shared/child-process/run-process'
|
||||
import {
|
||||
ensureMacPressAndHoldDefault,
|
||||
interpretDefaultsRead,
|
||||
isOrcaPreferencesDomain,
|
||||
readBundleIdentifierFromExecutablePath,
|
||||
type PressAndHoldDecision,
|
||||
type PressAndHoldHost,
|
||||
type PressAndHoldRecord
|
||||
} from './macos-press-and-hold-default'
|
||||
|
||||
const ORCA_DOMAIN = 'com.stablyai.orca'
|
||||
|
||||
type HostOverrides = Partial<PressAndHoldHost> & { record?: PressAndHoldRecord | null }
|
||||
|
||||
/** A macOS host whose domain has no explicit value — the state every affected user is in. */
|
||||
function createHost(overrides: HostOverrides = {}): {
|
||||
host: PressAndHoldHost
|
||||
writes: { domain: string; value: boolean }[]
|
||||
records: PressAndHoldRecord[]
|
||||
} {
|
||||
const writes: { domain: string; value: boolean }[] = []
|
||||
const records: PressAndHoldRecord[] = []
|
||||
let stored = overrides.record ?? null
|
||||
const host: PressAndHoldHost = {
|
||||
platform: 'darwin',
|
||||
resolveBundleIdentifier: () => ORCA_DOMAIN,
|
||||
readRecord: () => stored,
|
||||
writeRecord: (record) => {
|
||||
stored = record
|
||||
records.push(record)
|
||||
},
|
||||
readDomainPreference: () => 'unset',
|
||||
writeDomainPreference: (domain, value) => {
|
||||
writes.push({ domain, value })
|
||||
return true
|
||||
},
|
||||
now: () => '2026-08-20T00:00:00.000Z',
|
||||
...overrides
|
||||
}
|
||||
return { host, writes, records }
|
||||
}
|
||||
|
||||
function terminalRecord(decision: PressAndHoldDecision): PressAndHoldRecord {
|
||||
return { version: 1, decision, domain: ORCA_DOMAIN, decidedAt: '2026-01-01T00:00:00.000Z' }
|
||||
}
|
||||
|
||||
describe('ensureMacPressAndHoldDefault', () => {
|
||||
it('writes the opt-out when the domain has no explicit value', () => {
|
||||
const { host, writes, records } = createHost()
|
||||
|
||||
expect(ensureMacPressAndHoldDefault(host)).toBe('applied')
|
||||
expect(writes).toEqual([{ domain: ORCA_DOMAIN, value: false }])
|
||||
expect(records.at(-1)?.decision).toBe('applied')
|
||||
})
|
||||
|
||||
describe('platform guard', () => {
|
||||
for (const platform of ['win32', 'linux'] as const) {
|
||||
it(`does nothing at all on ${platform}`, () => {
|
||||
const probe = vi.fn(() => 'unset' as const)
|
||||
const readRecord = vi.fn(() => null)
|
||||
const { host, writes, records } = createHost({
|
||||
platform,
|
||||
readDomainPreference: probe,
|
||||
readRecord
|
||||
})
|
||||
|
||||
expect(ensureMacPressAndHoldDefault(host)).toBe('not-macos')
|
||||
// Why assert the reads too: "inert" means no `defaults` subprocess and no userData I/O,
|
||||
// not merely no write.
|
||||
expect(probe).not.toHaveBeenCalled()
|
||||
expect(readRecord).not.toHaveBeenCalled()
|
||||
expect(writes).toEqual([])
|
||||
expect(records).toEqual([])
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
describe('respects an explicit user setting', () => {
|
||||
it('leaves the domain alone when the user already set a value', () => {
|
||||
// One arm covers both `true` and `false`: the probe deliberately reports only presence, so
|
||||
// whichever way the user's value points it is theirs. That the probe really does report
|
||||
// presence for both is pinned against the real binary in the .defaults-domain test.
|
||||
const { host, writes, records } = createHost({ readDomainPreference: () => 'set' })
|
||||
|
||||
expect(ensureMacPressAndHoldDefault(host)).toBe('kept-user-preference')
|
||||
expect(writes).toEqual([])
|
||||
expect(records.at(-1)?.decision).toBe('kept-user-preference')
|
||||
})
|
||||
|
||||
it('never re-applies after a launch already decided', () => {
|
||||
for (const decision of ['applied', 'kept-user-preference'] as const) {
|
||||
const probe = vi.fn(() => 'unset' as const)
|
||||
const { host, writes } = createHost({
|
||||
record: terminalRecord(decision),
|
||||
readDomainPreference: probe
|
||||
})
|
||||
|
||||
expect(ensureMacPressAndHoldDefault(host)).toBe('already-decided')
|
||||
expect(probe).not.toHaveBeenCalled()
|
||||
expect(writes).toEqual([])
|
||||
}
|
||||
})
|
||||
|
||||
it('stays out of the way after the user deletes the key we wrote', () => {
|
||||
// The documented way back to the accent picker is `defaults delete`, which returns the
|
||||
// domain to "unset". Without the recorded decision the next launch would rewrite it.
|
||||
const { host, writes } = createHost({
|
||||
record: terminalRecord('applied'),
|
||||
readDomainPreference: () => 'unset'
|
||||
})
|
||||
|
||||
expect(ensureMacPressAndHoldDefault(host)).toBe('already-decided')
|
||||
expect(writes).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('domains we do not own', () => {
|
||||
it('skips a bare Electron bundle rather than writing into a shared domain', () => {
|
||||
const probe = vi.fn(() => 'unset' as const)
|
||||
const { host, writes, records } = createHost({
|
||||
resolveBundleIdentifier: () => 'com.github.Electron',
|
||||
readDomainPreference: probe
|
||||
})
|
||||
|
||||
expect(ensureMacPressAndHoldDefault(host)).toBe('foreign-bundle')
|
||||
expect(probe).not.toHaveBeenCalled()
|
||||
expect(writes).toEqual([])
|
||||
expect(records.at(-1)?.domain).toBe('com.github.Electron')
|
||||
})
|
||||
|
||||
it('skips when the bundle identifier cannot be resolved', () => {
|
||||
const { host, writes } = createHost({ resolveBundleIdentifier: () => null })
|
||||
|
||||
expect(ensureMacPressAndHoldDefault(host)).toBe('foreign-bundle')
|
||||
expect(writes).toEqual([])
|
||||
})
|
||||
|
||||
it('accepts Orca and its channel-scoped bundles, and nothing else', () => {
|
||||
expect(isOrcaPreferencesDomain('com.stablyai.orca')).toBe(true)
|
||||
expect(isOrcaPreferencesDomain('com.stablyai.orca.dev')).toBe(true)
|
||||
expect(isOrcaPreferencesDomain('com.github.Electron')).toBe(false)
|
||||
// Why: a prefix test without the dot would accept a lookalike bundle id.
|
||||
expect(isOrcaPreferencesDomain('com.stablyai.orcafake')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('failures leave the decision open', () => {
|
||||
it('does not write when the probe cannot tell unset from user-set', () => {
|
||||
const { host, writes, records } = createHost({ readDomainPreference: () => 'unknown' })
|
||||
|
||||
expect(ensureMacPressAndHoldDefault(host)).toBe('probe-failed')
|
||||
expect(writes).toEqual([])
|
||||
expect(records.at(-1)?.decision).toBe('probe-failed')
|
||||
})
|
||||
|
||||
it('retries on the next launch after a failed write', () => {
|
||||
const { host, records } = createHost({ writeDomainPreference: () => false })
|
||||
expect(ensureMacPressAndHoldDefault(host)).toBe('write-failed')
|
||||
|
||||
const retry = createHost({ record: records.at(-1) })
|
||||
expect(ensureMacPressAndHoldDefault(retry.host)).toBe('applied')
|
||||
expect(retry.writes).toEqual([{ domain: ORCA_DOMAIN, value: false }])
|
||||
})
|
||||
})
|
||||
|
||||
it('does not rewrite an unchanged non-terminal record', () => {
|
||||
const first = createHost({ resolveBundleIdentifier: () => 'com.github.Electron' })
|
||||
expect(ensureMacPressAndHoldDefault(first.host)).toBe('foreign-bundle')
|
||||
|
||||
const second = createHost({
|
||||
record: first.records.at(-1),
|
||||
resolveBundleIdentifier: () => 'com.github.Electron'
|
||||
})
|
||||
expect(ensureMacPressAndHoldDefault(second.host)).toBe('foreign-bundle')
|
||||
expect(second.records).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Why here and not only in the .defaults-domain file: that file is macOS-only and CI has no macOS
|
||||
* runner, so this three-way rule would otherwise be enforced nowhere in CI. These cases need no
|
||||
* `defaults` binary — the sibling file pins that the real binary produces them.
|
||||
*/
|
||||
describe('interpretDefaultsRead', () => {
|
||||
function probe(overrides: Partial<ProcessResult>): () => ProcessResult {
|
||||
return () => ({ code: 0, signal: null, stdout: '', stderr: '', timedOut: false, ...overrides })
|
||||
}
|
||||
|
||||
it('reads a clean exit as an explicit value', () => {
|
||||
expect(interpretDefaultsRead(probe({ code: 0 }))).toBe('set')
|
||||
})
|
||||
|
||||
it('reads only "does not exist" as unset', () => {
|
||||
expect(interpretDefaultsRead(probe({ code: 1 }))).toBe('unset')
|
||||
})
|
||||
|
||||
describe('refuses to call a failed probe unset', () => {
|
||||
// Each of these would otherwise overwrite a value the user deliberately set.
|
||||
const failures: [string, () => ProcessResult][] = [
|
||||
['killed before it exited', probe({ code: null, signal: 'SIGTERM' })],
|
||||
['timed out', probe({ code: null, timedOut: true })],
|
||||
[
|
||||
'timed out after somehow reporting the missing-key code',
|
||||
probe({ code: 1, timedOut: true })
|
||||
],
|
||||
['exited with some other error', probe({ code: 2 })],
|
||||
['exited with a negative status', probe({ code: -1 })],
|
||||
[
|
||||
'never started at all',
|
||||
() => {
|
||||
throw Object.assign(new Error('spawn ENOENT'), { code: 'ENOENT' })
|
||||
}
|
||||
]
|
||||
]
|
||||
for (const [name, failing] of failures) {
|
||||
it(name, () => {
|
||||
expect(interpretDefaultsRead(failing)).toBe('unknown')
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('readBundleIdentifierFromExecutablePath', () => {
|
||||
const roots: string[] = []
|
||||
|
||||
afterEach(() => {
|
||||
for (const root of roots.splice(0)) {
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
function bundleWithPlist(body: string): string {
|
||||
const root = mkdtempSync(join(tmpdir(), 'orca-press-hold-'))
|
||||
roots.push(root)
|
||||
mkdirSync(join(root, 'Orca.app', 'Contents', 'MacOS'), { recursive: true })
|
||||
writeFileSync(join(root, 'Orca.app', 'Contents', 'Info.plist'), body)
|
||||
return join(root, 'Orca.app', 'Contents', 'MacOS', 'Orca')
|
||||
}
|
||||
|
||||
it('reads CFBundleIdentifier from the plist beside the executable', () => {
|
||||
const exe = bundleWithPlist(
|
||||
'<plist><dict>\n<key>CFBundleName</key>\n<string>Orca</string>\n' +
|
||||
'<key>CFBundleIdentifier</key>\n\t<string>com.stablyai.orca</string>\n</dict></plist>'
|
||||
)
|
||||
|
||||
expect(readBundleIdentifierFromExecutablePath(exe)).toBe('com.stablyai.orca')
|
||||
})
|
||||
|
||||
it('returns null when the plist is missing or carries no identifier', () => {
|
||||
expect(readBundleIdentifierFromExecutablePath('/nonexistent/App.app/Contents/MacOS/App')).toBe(
|
||||
null
|
||||
)
|
||||
expect(readBundleIdentifierFromExecutablePath(bundleWithPlist('<plist><dict/></plist>'))).toBe(
|
||||
null
|
||||
)
|
||||
expect(
|
||||
readBundleIdentifierFromExecutablePath(
|
||||
bundleWithPlist('<key>CFBundleIdentifier</key><string></string>')
|
||||
)
|
||||
).toBe(null)
|
||||
})
|
||||
})
|
||||
|
||||
describe('startup wiring', () => {
|
||||
const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8')
|
||||
|
||||
it('runs before app.whenReady(), which is the last point AppKit could still see it', () => {
|
||||
const callIndex = source.indexOf(
|
||||
'applyMacPressAndHoldDefaultAtStartup(getCanonicalUserDataPath())'
|
||||
)
|
||||
const initDataPathIndex = source.indexOf('initDataPath()')
|
||||
const readyIndex = source.indexOf('app.whenReady().then(')
|
||||
|
||||
expect(callIndex).toBeGreaterThanOrEqual(0)
|
||||
expect(readyIndex).toBeGreaterThanOrEqual(0)
|
||||
// Why after initDataPath: the record lives beside orca-data.json, and the canonical userData
|
||||
// path is only captured there.
|
||||
expect(callIndex).toBeGreaterThan(initDataPathIndex)
|
||||
expect(callIndex).toBeLessThan(readyIndex)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,257 @@
|
||||
import { mkdirSync, readFileSync } from 'node:fs'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { runProcessSync, type ProcessResult } from '../shared/child-process/run-process'
|
||||
import { writeFileAtomically } from './codex-accounts/fs-utils'
|
||||
|
||||
/**
|
||||
* Turns off the macOS accent picker for Orca's own preferences domain (#14746).
|
||||
*
|
||||
* macOS routes press-and-hold to the accent popup unless an app opts out via
|
||||
* `ApplePressAndHoldEnabled`, so holding `j` in vim inserts one character instead of repeating.
|
||||
* The key is unset by default, which is why every terminal-hosting Mac app ships this opt-out.
|
||||
*
|
||||
* Written once and never again: a user who wants the accent picker back sets
|
||||
* `defaults write com.stablyai.orca ApplePressAndHoldEnabled -bool true` (or deletes the key), and
|
||||
* the recorded decision below keeps a later launch from overwriting that choice.
|
||||
*
|
||||
* A fresh write is assumed to land for the *next* launch, not the current one: it goes out through
|
||||
* a separate `defaults` process, so this app's own cached copy may not observe it. Whether AppKit
|
||||
* re-reads sooner is untested, so the conservative assumption is the one encoded here.
|
||||
*
|
||||
* If this is ever reverted, delete the key as well — AppKit reads the plist, not this file, so
|
||||
* removing the code alone leaves press-and-hold disabled forever for everyone who ran an affected
|
||||
* build, with nothing left in the tree to explain it.
|
||||
*
|
||||
* The `defaults`-binary behaviour this relies on is pinned by the sibling test file, which runs
|
||||
* only on a developer Mac: CI has no macOS test runner, so those tests never execute there.
|
||||
*/
|
||||
|
||||
export const PRESS_AND_HOLD_KEY = 'ApplePressAndHoldEnabled'
|
||||
export const PRESS_AND_HOLD_RECORD_FILE = 'macos-press-and-hold-default.json'
|
||||
export const PRESS_AND_HOLD_RECORD_VERSION = 1
|
||||
|
||||
/** Why not `systemPreferences.getUserDefault`: it reads through the whole NSUserDefaults search
|
||||
* list and is typed non-nullable, so an unset key and an explicit `false` both come back `false` —
|
||||
* and the system default is unset, so it reports `false` on a Mac where press-and-hold is on.
|
||||
* `defaults read <domain> <key>` is domain-scoped and exits 1 when the key is absent. */
|
||||
const DEFAULTS_BINARY = '/usr/bin/defaults'
|
||||
/** A hang cutoff, not a latency budget: the probe measures ~6ms, and cutting a slow-but-working
|
||||
* `defaults` short costs the user the feature for that launch. The shared 30s default is what
|
||||
* must not apply — this runs before `ready`, so a 30s block is a hung app. */
|
||||
const DEFAULTS_TIMEOUT_MS = 5_000
|
||||
/** Why: `defaults` exits 1 for "does not exist"; anything else means the probe itself failed. */
|
||||
const DEFAULTS_MISSING_STATUS = 1
|
||||
|
||||
const ORCA_BUNDLE_ID = 'com.stablyai.orca'
|
||||
|
||||
export type PressAndHoldDecision =
|
||||
/** Not macOS — nothing is read or written. */
|
||||
| 'not-macos'
|
||||
/** A previous launch already decided; the domain is never touched again. */
|
||||
| 'already-decided'
|
||||
/** The running bundle is not Orca's (e.g. a bare `Electron.app`), whose domain we do not own. */
|
||||
| 'foreign-bundle'
|
||||
/** `defaults read` could not answer, so we cannot tell an unset key from a user's choice. */
|
||||
| 'probe-failed'
|
||||
/** `defaults write` failed. */
|
||||
| 'write-failed'
|
||||
/** The domain already carries an explicit value; the user's choice stands. */
|
||||
| 'kept-user-preference'
|
||||
/** The key was unset and we wrote `false`. */
|
||||
| 'applied'
|
||||
|
||||
/** Decisions about the domain itself. Anything else is a condition that can change by next launch. */
|
||||
const TERMINAL_DECISIONS = new Set<PressAndHoldDecision>(['applied', 'kept-user-preference'])
|
||||
|
||||
export type PressAndHoldRecord = {
|
||||
version: number
|
||||
decision: PressAndHoldDecision
|
||||
domain: string | null
|
||||
decidedAt: string
|
||||
}
|
||||
|
||||
export type PressAndHoldHost = {
|
||||
platform: NodeJS.Platform
|
||||
resolveBundleIdentifier: () => string | null
|
||||
readRecord: () => PressAndHoldRecord | null
|
||||
writeRecord: (record: PressAndHoldRecord) => void
|
||||
readDomainPreference: (domain: string) => 'set' | 'unset' | 'unknown'
|
||||
writeDomainPreference: (domain: string, value: boolean) => boolean
|
||||
now: () => string
|
||||
}
|
||||
|
||||
/** Only Orca's own bundle: an unpackaged run is `com.github.Electron`, shared with every other
|
||||
* unpackaged Electron app on the machine. */
|
||||
export function isOrcaPreferencesDomain(domain: string): boolean {
|
||||
return domain === ORCA_BUNDLE_ID || domain.startsWith(`${ORCA_BUNDLE_ID}.`)
|
||||
}
|
||||
|
||||
/** `<bundle>/Contents/MacOS/<exe>` → `<bundle>/Contents/Info.plist`. */
|
||||
export function readBundleIdentifierFromExecutablePath(execPath: string): string | null {
|
||||
try {
|
||||
const plist = readFileSync(join(dirname(dirname(execPath)), 'Info.plist'), 'utf8')
|
||||
const match = /<key>CFBundleIdentifier<\/key>\s*<string>([^<]*)<\/string>/.exec(plist)
|
||||
const identifier = match?.[1]?.trim()
|
||||
return identifier ? identifier : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Why this distinction carries the whole design: only a real "does not exist" answer may be read as
|
||||
* unset. A killed or timed-out probe never answered, and calling that unset would let a broken
|
||||
* probe overwrite a value the user chose.
|
||||
*/
|
||||
export function interpretDefaultsRead(probe: () => ProcessResult): 'set' | 'unset' | 'unknown' {
|
||||
let result: ProcessResult
|
||||
try {
|
||||
result = probe()
|
||||
} catch {
|
||||
// `runProcessSync` throws only when the child never started, which answers nothing at all.
|
||||
return 'unknown'
|
||||
}
|
||||
// Why before the code: a timeout kills mid-run, so any exit it leaves behind is not an answer.
|
||||
if (result.timedOut) {
|
||||
return 'unknown'
|
||||
}
|
||||
if (result.code === 0) {
|
||||
return 'set'
|
||||
}
|
||||
return result.code === DEFAULTS_MISSING_STATUS ? 'unset' : 'unknown'
|
||||
}
|
||||
|
||||
export function readDomainPressAndHoldPreference(domain: string): 'set' | 'unset' | 'unknown' {
|
||||
// Why no captured output: the exit code is the whole answer, and reading only presence is what
|
||||
// makes an explicit `true` and an explicit `false` both count as the user's.
|
||||
return interpretDefaultsRead(() =>
|
||||
runProcessSync({
|
||||
program: DEFAULTS_BINARY,
|
||||
args: ['read', domain, PRESS_AND_HOLD_KEY],
|
||||
timeoutMs: DEFAULTS_TIMEOUT_MS,
|
||||
stdio: ['ignore', 'ignore', 'ignore']
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
export function writeDomainPressAndHoldPreference(domain: string, value: boolean): boolean {
|
||||
try {
|
||||
// Why the code check: a non-zero `defaults write` returns rather than throwing, so treating a
|
||||
// completed run as success would record 'applied' for a write that never landed.
|
||||
return (
|
||||
runProcessSync({
|
||||
program: DEFAULTS_BINARY,
|
||||
args: ['write', domain, PRESS_AND_HOLD_KEY, '-bool', value ? 'true' : 'false'],
|
||||
timeoutMs: DEFAULTS_TIMEOUT_MS,
|
||||
stdio: ['ignore', 'ignore', 'ignore']
|
||||
}).code === 0
|
||||
)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export function pressAndHoldRecordPath(userDataPath: string): string {
|
||||
return join(userDataPath, PRESS_AND_HOLD_RECORD_FILE)
|
||||
}
|
||||
|
||||
function parseRecord(raw: string): PressAndHoldRecord | null {
|
||||
const parsed = JSON.parse(raw) as Partial<PressAndHoldRecord>
|
||||
if (parsed.version !== PRESS_AND_HOLD_RECORD_VERSION || typeof parsed.decision !== 'string') {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
version: PRESS_AND_HOLD_RECORD_VERSION,
|
||||
decision: parsed.decision as PressAndHoldDecision,
|
||||
domain: typeof parsed.domain === 'string' ? parsed.domain : null,
|
||||
decidedAt: typeof parsed.decidedAt === 'string' ? parsed.decidedAt : ''
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply Orca's press-and-hold default at most once, leaving any explicit user value alone.
|
||||
*
|
||||
* Returns the decision so startup can log it; the same value is persisted for support triage.
|
||||
*/
|
||||
export function ensureMacPressAndHoldDefault(host: PressAndHoldHost): PressAndHoldDecision {
|
||||
// Why first and without touching disk: Windows and Linux must do no work at all here.
|
||||
if (host.platform !== 'darwin') {
|
||||
return 'not-macos'
|
||||
}
|
||||
|
||||
const previous = host.readRecord()
|
||||
if (previous && TERMINAL_DECISIONS.has(previous.decision)) {
|
||||
return 'already-decided'
|
||||
}
|
||||
|
||||
const record = (decision: PressAndHoldDecision, domain: string | null): PressAndHoldDecision => {
|
||||
// Why skip an unchanged rewrite: the non-terminal decisions are re-evaluated every launch.
|
||||
if (!previous || previous.decision !== decision || previous.domain !== domain) {
|
||||
host.writeRecord({
|
||||
version: PRESS_AND_HOLD_RECORD_VERSION,
|
||||
decision,
|
||||
domain,
|
||||
decidedAt: host.now()
|
||||
})
|
||||
}
|
||||
return decision
|
||||
}
|
||||
|
||||
const domain = host.resolveBundleIdentifier()
|
||||
if (!domain || !isOrcaPreferencesDomain(domain)) {
|
||||
return record('foreign-bundle', domain)
|
||||
}
|
||||
|
||||
const existing = host.readDomainPreference(domain)
|
||||
if (existing === 'unknown') {
|
||||
return record('probe-failed', domain)
|
||||
}
|
||||
if (existing === 'set') {
|
||||
return record('kept-user-preference', domain)
|
||||
}
|
||||
if (!host.writeDomainPreference(domain, false)) {
|
||||
return record('write-failed', domain)
|
||||
}
|
||||
return record('applied', domain)
|
||||
}
|
||||
|
||||
/** Why log: silently rewriting a macOS default for the user's login account should leave a trace,
|
||||
* and the failure paths are otherwise invisible. The quiet decisions are the steady state. */
|
||||
const REPORTED_DECISIONS: Partial<Record<PressAndHoldDecision, string>> = {
|
||||
applied: `set ${PRESS_AND_HOLD_KEY}=false so held keys repeat; takes effect next launch`,
|
||||
'probe-failed': `could not read ${PRESS_AND_HOLD_KEY}; leaving it alone`,
|
||||
'write-failed': `could not write ${PRESS_AND_HOLD_KEY}; held keys will not repeat`
|
||||
}
|
||||
|
||||
/** Wires {@link ensureMacPressAndHoldDefault} to the real bundle, `defaults`, and userData. */
|
||||
export function applyMacPressAndHoldDefaultAtStartup(userDataPath: string): PressAndHoldDecision {
|
||||
const recordPath = pressAndHoldRecordPath(userDataPath)
|
||||
const decision = ensureMacPressAndHoldDefault({
|
||||
platform: process.platform,
|
||||
resolveBundleIdentifier: () => readBundleIdentifierFromExecutablePath(process.execPath),
|
||||
readRecord: () => {
|
||||
try {
|
||||
return parseRecord(readFileSync(recordPath, 'utf8'))
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
},
|
||||
writeRecord: (record) => {
|
||||
try {
|
||||
// Why: this runs before `ready`, and Electron has not necessarily created userData yet.
|
||||
mkdirSync(userDataPath, { recursive: true })
|
||||
writeFileAtomically(recordPath, `${JSON.stringify(record, null, 2)}\n`)
|
||||
} catch {
|
||||
// Best-effort: an unwritten record only costs one repeated probe next launch.
|
||||
}
|
||||
},
|
||||
readDomainPreference: readDomainPressAndHoldPreference,
|
||||
writeDomainPreference: writeDomainPressAndHoldPreference,
|
||||
now: () => new Date().toISOString()
|
||||
})
|
||||
const reported = REPORTED_DECISIONS[decision]
|
||||
if (reported) {
|
||||
console.log(`[press-and-hold] ${reported}`)
|
||||
}
|
||||
return decision
|
||||
}
|
||||
@@ -3,8 +3,11 @@ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from '
|
||||
import { homedir } from 'node:os'
|
||||
import { dirname } from 'node:path'
|
||||
import { getDefaultPersistedState, getDefaultWorkspaceSession } from '../../shared/constants'
|
||||
import type { ExecutionHostId } from '../../shared/execution-host'
|
||||
import { projectHostSetupProjectionFromRepos } from '../../shared/project-host-setup-projection'
|
||||
import {
|
||||
normalizeProjectHostSetupRows,
|
||||
normalizeProjectRows
|
||||
} from '../../shared/project-catalog-row-normalization'
|
||||
import { carryProjectStateThroughIdentityChange } from '../../shared/project-identity-succession'
|
||||
import type { PersistedState } from '../../shared/persisted-state-types'
|
||||
import type { Project, ProjectHostSetup } from '../../shared/project-types'
|
||||
@@ -16,16 +19,16 @@ import { getOrcaProfileDataFile } from './profile-index-store'
|
||||
|
||||
export type TransferProfileState = PersistedState
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
function isRecord<T>(value: unknown): value is Record<string, T> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function arrayOrEmpty<T>(value: unknown): T[] {
|
||||
return Array.isArray(value) ? (value as T[]) : []
|
||||
return Array.isArray(value) ? value : []
|
||||
}
|
||||
|
||||
function recordOrEmpty<T>(value: unknown): Record<string, T> {
|
||||
return isRecord(value) ? (value as Record<string, T>) : {}
|
||||
return isRecord<T>(value) ? value : {}
|
||||
}
|
||||
|
||||
export function readProfileState(profileId: string, userDataPath: string): TransferProfileState {
|
||||
@@ -34,13 +37,17 @@ export function readProfileState(profileId: string, userDataPath: string): Trans
|
||||
if (!existsSync(dataFile)) {
|
||||
return structuredClone(defaults)
|
||||
}
|
||||
const parsed = JSON.parse(readFileSync(dataFile, 'utf-8')) as Partial<PersistedState>
|
||||
const parsed: Partial<PersistedState> = JSON.parse(readFileSync(dataFile, 'utf-8'))
|
||||
return rebuildRepoBackedProjectState({
|
||||
...defaults,
|
||||
...parsed,
|
||||
repos: arrayOrEmpty<Repo>(parsed.repos),
|
||||
projects: arrayOrEmpty<Project>(parsed.projects),
|
||||
projectHostSetups: arrayOrEmpty<ProjectHostSetup>(parsed.projectHostSetups),
|
||||
// Why normalize: another profile's file is untrusted JSON, and a null repoId/path here would be
|
||||
// carried straight into the importing app's state.
|
||||
projects: normalizeProjectRows(arrayOrEmpty<Project>(parsed.projects)),
|
||||
projectHostSetups: normalizeProjectHostSetupRows(
|
||||
arrayOrEmpty<ProjectHostSetup>(parsed.projectHostSetups)
|
||||
),
|
||||
projectGroups: arrayOrEmpty(parsed.projectGroups),
|
||||
folderWorkspaces: arrayOrEmpty(parsed.folderWorkspaces),
|
||||
sparsePresetsByRepo: recordOrEmpty<SparsePreset[]>(parsed.sparsePresetsByRepo),
|
||||
@@ -59,17 +66,15 @@ export function readProfileState(profileId: string, userDataPath: string): Trans
|
||||
ui: isRecord(parsed.ui) ? { ...defaults.ui, ...parsed.ui } : defaults.ui,
|
||||
githubCache: isRecord(parsed.githubCache)
|
||||
? {
|
||||
pr: recordOrEmpty((parsed.githubCache as PersistedState['githubCache']).pr),
|
||||
issue: recordOrEmpty((parsed.githubCache as PersistedState['githubCache']).issue)
|
||||
pr: recordOrEmpty(parsed.githubCache.pr),
|
||||
issue: recordOrEmpty(parsed.githubCache.issue)
|
||||
}
|
||||
: defaults.githubCache,
|
||||
workspaceSession: isRecord(parsed.workspaceSession)
|
||||
? { ...getDefaultWorkspaceSession(), ...parsed.workspaceSession }
|
||||
: defaults.workspaceSession,
|
||||
workspaceSessionsByHostId: isRecord(parsed.workspaceSessionsByHostId)
|
||||
? (parsed.workspaceSessionsByHostId as Partial<
|
||||
Record<ExecutionHostId, WorkspaceSessionState>
|
||||
>)
|
||||
workspaceSessionsByHostId: isRecord<WorkspaceSessionState>(parsed.workspaceSessionsByHostId)
|
||||
? parsed.workspaceSessionsByHostId
|
||||
: {},
|
||||
sshTargets: arrayOrEmpty(parsed.sshTargets),
|
||||
sshRemotePtyLeases: arrayOrEmpty(parsed.sshRemotePtyLeases),
|
||||
|
||||
@@ -21,7 +21,8 @@ import { normalizeLoadedUiState } from './normalize-loaded-ui-state'
|
||||
import {
|
||||
normalizeLoadedAutomationRuns,
|
||||
normalizeLoadedHostSessions,
|
||||
normalizeLoadedLocalSession
|
||||
normalizeLoadedLocalSession,
|
||||
normalizeLoadedProjectCatalog
|
||||
} from './normalize-loaded-state-collections'
|
||||
import { normalizeRetiredNameRegistryMap } from './retired-name-registry-normalization'
|
||||
|
||||
@@ -33,6 +34,7 @@ export function normalizeLoadedProfileState(
|
||||
): PersistedState {
|
||||
const { defaults, migratedExternalVisibility, osc52ClipboardNoticePending } = terminal
|
||||
const { normalizedOnboarding, normalizedProjectGroups, loadedCompactWorktreeCards } = profile
|
||||
const projectCatalog = normalizeLoadedProjectCatalog(parsed, markNeedsSave)
|
||||
|
||||
return {
|
||||
...defaults,
|
||||
@@ -42,6 +44,9 @@ export function normalizeLoadedProfileState(
|
||||
),
|
||||
projectGroups: normalizedProjectGroups,
|
||||
repos: migratedExternalVisibility.repos,
|
||||
// Why: persisted catalog rows are untrusted JSON; consumers call string methods on fields the type says are strings.
|
||||
projects: projectCatalog.projects,
|
||||
projectHostSetups: projectCatalog.projectHostSetups,
|
||||
folderWorkspaces: normalizeFolderWorkspaces(parsed.folderWorkspaces, normalizedProjectGroups),
|
||||
folderWorkspaceDiffComments: normalizeFolderWorkspaceDiffComments(
|
||||
parsed.folderWorkspaceDiffComments
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { PersistedState } from '../../../shared/persisted-state-types'
|
||||
import type { Project, ProjectHostSetup } from '../../../shared/project-types'
|
||||
import { normalizeLoadedProjectCatalog } from './normalize-loaded-state-collections'
|
||||
|
||||
// Why Reflect.set and not a cast: these rows deliberately violate their declared types, which is
|
||||
// exactly what a stale on-disk catalog does.
|
||||
function corrupt<T extends object>(row: T, overrides: Record<string, unknown>): T {
|
||||
for (const [key, value] of Object.entries(overrides)) {
|
||||
Reflect.set(row, key, value)
|
||||
}
|
||||
return row
|
||||
}
|
||||
|
||||
function makeParsed(
|
||||
setups: ProjectHostSetup[],
|
||||
projects: Project[] = []
|
||||
): Partial<Pick<PersistedState, 'projects' | 'projectHostSetups'>> {
|
||||
return { projects, projectHostSetups: setups }
|
||||
}
|
||||
|
||||
function makeBadSetup(): ProjectHostSetup {
|
||||
const setup: ProjectHostSetup = {
|
||||
id: 'setup-1',
|
||||
projectId: 'project-1',
|
||||
hostId: 'local',
|
||||
repoId: 'repo-1',
|
||||
path: '/Users/alice/orca',
|
||||
displayName: 'orca',
|
||||
setupState: 'ready',
|
||||
setupMethod: 'legacy-repo',
|
||||
createdAt: 1,
|
||||
updatedAt: 1
|
||||
}
|
||||
return corrupt(setup, { repoId: null, path: null })
|
||||
}
|
||||
|
||||
describe('normalizeLoadedProjectCatalog', () => {
|
||||
it('repairs stored rows whose field types do not match the declared ones', () => {
|
||||
const result = normalizeLoadedProjectCatalog(makeParsed([makeBadSetup()]), vi.fn())
|
||||
expect(result.projectHostSetups[0]?.repoId).toBe('')
|
||||
expect(result.projectHostSetups[0]?.path).toBe('')
|
||||
})
|
||||
|
||||
// Why: without a save the bad rows stay on disk, get repaired again every launch, and this
|
||||
// host keeps publishing them to paired clients. Marking dirty is the migration.
|
||||
it('marks the profile dirty so the repair is persisted', () => {
|
||||
const markNeedsSave = vi.fn()
|
||||
normalizeLoadedProjectCatalog(makeParsed([makeBadSetup()]), markNeedsSave)
|
||||
expect(markNeedsSave).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('leaves a conforming catalog untouched and does not schedule a save', () => {
|
||||
const markNeedsSave = vi.fn()
|
||||
const setups = [{ ...makeBadSetup(), repoId: 'repo-1', path: '/repo' }]
|
||||
const parsed = makeParsed(setups)
|
||||
const result = normalizeLoadedProjectCatalog(parsed, markNeedsSave)
|
||||
expect(result.projectHostSetups).toBe(setups)
|
||||
expect(markNeedsSave).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('tolerates missing or non-array collections', () => {
|
||||
const result = normalizeLoadedProjectCatalog({}, vi.fn())
|
||||
expect(result.projects).toEqual([])
|
||||
expect(result.projectHostSetups).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,8 @@
|
||||
import type { PersistedState } from '../../../shared/persisted-state-types'
|
||||
import {
|
||||
normalizeProjectHostSetupRows,
|
||||
normalizeProjectRows
|
||||
} from '../../../shared/project-catalog-row-normalization'
|
||||
import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types'
|
||||
import { parseWorkspaceSessionSalvaging } from '../../../shared/workspace-session-salvage'
|
||||
import {
|
||||
@@ -64,3 +68,21 @@ export function normalizeLoadedAutomationRuns(
|
||||
}
|
||||
return runs
|
||||
}
|
||||
|
||||
/**
|
||||
* Repairs project/setup rows whose stored field types do not match the declared ones — a null
|
||||
* `repoId` or `path` written by an older build reaches every consumer that calls `.trim()` on it.
|
||||
* Marking dirty is the migration: without a save the bad rows stay on disk and are repaired again
|
||||
* every launch, and this host keeps publishing them to paired clients over the wire.
|
||||
*/
|
||||
export function normalizeLoadedProjectCatalog(
|
||||
parsed: Partial<Pick<PersistedState, 'projects' | 'projectHostSetups'>>,
|
||||
markNeedsSave: () => void
|
||||
): Pick<PersistedState, 'projects' | 'projectHostSetups'> {
|
||||
const projects = normalizeProjectRows(parsed.projects ?? [])
|
||||
const projectHostSetups = normalizeProjectHostSetupRows(parsed.projectHostSetups ?? [])
|
||||
if (projects !== parsed.projects || projectHostSetups !== parsed.projectHostSetups) {
|
||||
markNeedsSave()
|
||||
}
|
||||
return { projects, projectHostSetups }
|
||||
}
|
||||
|
||||
@@ -162,13 +162,13 @@ describe.skipIf(!bashAvailable)('Codex preflight under a profile-rewritten PATH'
|
||||
expect(readFileSync(fixture.codexMarker, 'utf-8').trim()).toBe('--alias-flag --version')
|
||||
})
|
||||
|
||||
// Why: pins the defect itself, so a regression back to an unqualified name fails here.
|
||||
it('would run the impostor if the preflight carried an unqualified command name', () => {
|
||||
// Why: an unqualified preflight is rejected so profile PATH entries cannot hijack it.
|
||||
it('skips an unqualified preflight value while still launching codex', () => {
|
||||
const fixture = buildFixture()
|
||||
|
||||
launchCodexThroughRcfile(fixture, 'orca')
|
||||
|
||||
expect(existsSync(fixture.hijackMarker)).toBe(true)
|
||||
expect(existsSync(fixture.hijackMarker)).toBe(false)
|
||||
expect(existsSync(fixture.intendedMarker)).toBe(false)
|
||||
expect(existsSync(fixture.codexMarker)).toBe(true)
|
||||
})
|
||||
|
||||
@@ -170,6 +170,30 @@ describe.skipIf(process.platform === 'win32')('Codex shell launch preflight', ()
|
||||
)
|
||||
})
|
||||
|
||||
it('does not trigger a preflight outside an Orca terminal', () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'orca-codex-plain-shell-'))
|
||||
roots.push(root)
|
||||
const bin = join(root, 'bin')
|
||||
const marker = join(root, 'preflight-ran')
|
||||
mkdirSync(bin)
|
||||
writeExecutable(join(bin, 'codex'), '#!/bin/sh\nprintf launched\n')
|
||||
writeExecutable(join(bin, 'orca-test'), `#!/bin/sh\nprintf ran > ${JSON.stringify(marker)}\n`)
|
||||
|
||||
const output = execFileSync(
|
||||
'/bin/bash',
|
||||
['--noprofile', '--norc', '-c', `${getPosixCodexShellLaunchPreflight()}\ncodex`],
|
||||
{
|
||||
encoding: 'utf-8',
|
||||
env: {
|
||||
PATH: `${bin}${delimiter}${process.env.PATH ?? ''}`
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
expect(output.trim()).toBe('launched')
|
||||
expect(existsSync(marker)).toBe(false)
|
||||
})
|
||||
|
||||
// Why a loop over it.each: an early `return` reported green on hosts without the
|
||||
// shell, so the zsh half silently never ran on Linux. skipIf reports it as a skip.
|
||||
for (const [shell, strict] of [
|
||||
@@ -319,6 +343,24 @@ describe('Codex shell launch preflight command', () => {
|
||||
).toBe(launcherPath)
|
||||
})
|
||||
|
||||
it('carries the packaged Windows launcher for WSLENV path translation', () => {
|
||||
const { userDataPath, resourcesPath } = makeCliRoot()
|
||||
const launcherPath = join(resourcesPath, 'bin', 'orca.exe')
|
||||
writeExecutable(launcherPath, '#!/bin/sh\nexit 0\n')
|
||||
|
||||
expect(
|
||||
resolveCodexShellLaunchPreflightCommand({
|
||||
hooksEnabled: true,
|
||||
isPackaged: true,
|
||||
isWsl: true,
|
||||
managedHomePath: '/home/jin/.local/share/orca/codex-runtime-home/home',
|
||||
userDataPath,
|
||||
resourcesPath,
|
||||
platform: 'win32'
|
||||
})
|
||||
).toBe(launcherPath)
|
||||
})
|
||||
|
||||
it('never returns an unqualified command name that a profile-rewritten PATH could hijack', () => {
|
||||
const { userDataPath, resourcesPath } = makeCliRoot()
|
||||
writeExecutable(join(resourcesPath, 'bin', 'orca'), '#!/bin/sh\nexit 0\n')
|
||||
@@ -340,16 +382,12 @@ describe('Codex shell launch preflight command', () => {
|
||||
|
||||
it.each([
|
||||
{ label: 'the launcher file is missing', create: null },
|
||||
{ label: 'the launcher is not executable', create: 0o644 },
|
||||
{ label: 'the launcher path is a directory', create: 'directory' as const }
|
||||
])('skips the preflight when $label', (config) => {
|
||||
const { userDataPath, resourcesPath } = makeCliRoot()
|
||||
const launcherPath = join(resourcesPath, 'bin', 'orca')
|
||||
if (config.create === 'directory') {
|
||||
mkdirSync(launcherPath)
|
||||
} else if (config.create !== null) {
|
||||
writeFileSync(launcherPath, '#!/bin/sh\nexit 0\n')
|
||||
chmodSync(launcherPath, config.create)
|
||||
}
|
||||
|
||||
expect(
|
||||
@@ -364,6 +402,27 @@ describe('Codex shell launch preflight command', () => {
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it.skipIf(process.platform === 'win32')(
|
||||
'skips the preflight when the launcher is not executable',
|
||||
() => {
|
||||
const { userDataPath, resourcesPath } = makeCliRoot()
|
||||
const launcherPath = join(resourcesPath, 'bin', 'orca')
|
||||
writeFileSync(launcherPath, '#!/bin/sh\nexit 0\n')
|
||||
chmodSync(launcherPath, 0o644)
|
||||
|
||||
expect(
|
||||
resolveCodexShellLaunchPreflightCommand({
|
||||
hooksEnabled: true,
|
||||
isPackaged: true,
|
||||
managedHomePath: '/managed/home',
|
||||
userDataPath,
|
||||
resourcesPath,
|
||||
platform: 'darwin'
|
||||
})
|
||||
).toBeNull()
|
||||
}
|
||||
)
|
||||
|
||||
it('skips the preflight when the packaged build exposes no resources root', () => {
|
||||
const { userDataPath } = makeCliRoot()
|
||||
|
||||
@@ -381,7 +440,7 @@ describe('Codex shell launch preflight command', () => {
|
||||
|
||||
it.each([
|
||||
{ hooksEnabled: false, isWsl: false, managedHomePath: '/managed/home' },
|
||||
{ hooksEnabled: true, isWsl: true, managedHomePath: '/managed/home' },
|
||||
{ hooksEnabled: true, isWsl: true, managedHomePath: '/managed/home', isPackaged: false },
|
||||
{ hooksEnabled: true, isWsl: false, managedHomePath: null }
|
||||
])('does not enable an unsupported preflight for %o', (options) => {
|
||||
const { userDataPath, resourcesPath } = makeCliRoot()
|
||||
@@ -390,7 +449,7 @@ describe('Codex shell launch preflight command', () => {
|
||||
expect(
|
||||
resolveCodexShellLaunchPreflightCommand({
|
||||
...options,
|
||||
isPackaged: true,
|
||||
isPackaged: options.isPackaged ?? true,
|
||||
userDataPath,
|
||||
resourcesPath,
|
||||
platform: 'darwin'
|
||||
|
||||
@@ -29,7 +29,7 @@ export type CodexShellLaunchPreflightCommandOptions = {
|
||||
export function resolveCodexShellLaunchPreflightCommand(
|
||||
options: CodexShellLaunchPreflightCommandOptions
|
||||
): string | null {
|
||||
if (!options.hooksEnabled || options.isWsl || !options.managedHomePath) {
|
||||
if (!options.hooksEnabled || !options.managedHomePath) {
|
||||
return null
|
||||
}
|
||||
const platform = options.platform ?? process.platform
|
||||
@@ -42,7 +42,14 @@ export function resolveCodexShellLaunchPreflightCommand(
|
||||
...DEV_LAUNCHER_DIR,
|
||||
platform === 'win32' ? `${DEV_COMMAND_NAME}.cmd` : DEV_COMMAND_NAME
|
||||
)
|
||||
return candidate && isExecutableFileOnDisk(candidate, platform) ? candidate : null
|
||||
if (!candidate || !isExecutableFileOnDisk(candidate, platform)) {
|
||||
return null
|
||||
}
|
||||
if (!options.isWsl) {
|
||||
return candidate
|
||||
}
|
||||
// Why: WSLENV /p translates the verified Windows launcher with the distro's configured automount root.
|
||||
return platform === 'win32' && options.isPackaged ? candidate : null
|
||||
}
|
||||
|
||||
function isExecutableFileOnDisk(path: string, platform: NodeJS.Platform): boolean {
|
||||
@@ -65,7 +72,7 @@ export function getPosixCodexShellLaunchPreflight(): string {
|
||||
# Why || : twice — zsh alone aborts inside the substitution, but every shell's
|
||||
# assignment adopts its exit status, so an absent codex trips set -e in bash too.
|
||||
__orca_codex_binary="$(unalias codex 2>/dev/null || :; command -v codex 2>/dev/null || :)"
|
||||
if [[ -n "\${ORCA_CODEX_LAUNCH_PREFLIGHT:-}" && -n "\${__orca_codex_binary:-}" && -x "\${__orca_codex_binary}" ]]; then
|
||||
if [[ -n "\${ORCA_CODEX_LAUNCH_PREFLIGHT:-}" && -x "\${ORCA_CODEX_LAUNCH_PREFLIGHT}" && -n "\${__orca_codex_binary:-}" && -x "\${__orca_codex_binary}" ]]; then
|
||||
# Why the function reserved word: it suppresses alias expansion of the name,
|
||||
# which otherwise rewrites this header at parse time and aborts the whole file.
|
||||
function codex {
|
||||
@@ -78,7 +85,7 @@ unset __orca_codex_binary
|
||||
}
|
||||
|
||||
export function getFishCodexShellLaunchPreflight(): string {
|
||||
return `if test -n "$ORCA_CODEX_LAUNCH_PREFLIGHT"; and test (type -t codex 2>/dev/null) = file
|
||||
return `if test -x "$ORCA_CODEX_LAUNCH_PREFLIGHT"; and test (type -t codex 2>/dev/null) = file
|
||||
function codex
|
||||
command "$ORCA_CODEX_LAUNCH_PREFLIGHT" agent hooks prepare-codex >/dev/null 2>&1; or true
|
||||
command codex $argv
|
||||
|
||||
@@ -73,6 +73,7 @@ describe('addOrcaWslInteropEnv', () => {
|
||||
ORCA_AGENT_HOOK_TOKEN: 'token',
|
||||
ORCA_AGENT_HOOK_ENV: 'dev',
|
||||
ORCA_AGENT_HOOK_VERSION: '1',
|
||||
ORCA_AGENT_HOOK_TRANSPORT: 'raw-json-v1',
|
||||
ORCA_WSL_HOOK_INSTANCE: 'testinstance',
|
||||
ORCA_ORCHESTRATION_COMPATIBILITY_HOST_KIND: 'wsl',
|
||||
ORCA_ORCHESTRATION_COMPATIBILITY_HOST_ID: 'local',
|
||||
@@ -84,7 +85,7 @@ describe('addOrcaWslInteropEnv', () => {
|
||||
expect(env.WSLENV).toContain('ORCA_TERMINAL_HANDLE/u')
|
||||
expect(env.WSLENV).toContain('ORCA_USER_DATA_PATH/p')
|
||||
expect(env.WSLENV).toContain('ORCA_CLI_COMMAND/u')
|
||||
expect(env.WSLENV).not.toContain('ORCA_CODEX_LAUNCH_PREFLIGHT')
|
||||
expect(env.WSLENV).toContain('ORCA_CODEX_LAUNCH_PREFLIGHT/p')
|
||||
expect(env.WSLENV).toContain('ORCA_OMP_STATUS_EXTENSION/p')
|
||||
expect(env.WSLENV).not.toContain('ORCA_PRIME_AGENT_STATUS_EXTENSION')
|
||||
expect(env.WSLENV).toContain('ORCA_PANE_KEY/u')
|
||||
@@ -95,6 +96,7 @@ describe('addOrcaWslInteropEnv', () => {
|
||||
expect(env.WSLENV).toContain('ORCA_AGENT_HOOK_TOKEN/u')
|
||||
expect(env.WSLENV).toContain('ORCA_AGENT_HOOK_ENV/u')
|
||||
expect(env.WSLENV).toContain('ORCA_AGENT_HOOK_VERSION/u')
|
||||
expect(env.WSLENV).toContain('ORCA_AGENT_HOOK_TRANSPORT/u')
|
||||
expect(env.WSLENV).toContain('ORCA_WSL_HOOK_INSTANCE/u')
|
||||
expect(env.WSLENV).toContain('ORCA_ORCHESTRATION_COMPATIBILITY_HOST_KIND/u')
|
||||
expect(env.WSLENV).toContain('ORCA_ORCHESTRATION_COMPATIBILITY_HOST_ID/u')
|
||||
|
||||
@@ -77,6 +77,7 @@ export function addOrcaWslInteropEnv(env: Record<string, string>): void {
|
||||
// and it cannot derive the hash segment from ORCA_USER_DATA_PATH alone.
|
||||
'ORCA_SHELL_READY_ROOT/p',
|
||||
'ORCA_CLI_COMMAND/u',
|
||||
'ORCA_CODEX_LAUNCH_PREFLIGHT/p',
|
||||
'ORCA_PANE_KEY/u',
|
||||
'ORCA_TAB_ID/u',
|
||||
'ORCA_WORKTREE_ID/u',
|
||||
@@ -90,6 +91,7 @@ export function addOrcaWslInteropEnv(env: Record<string, string>): void {
|
||||
'ORCA_AGENT_HOOK_TOKEN/u',
|
||||
'ORCA_AGENT_HOOK_ENV/u',
|
||||
'ORCA_AGENT_HOOK_VERSION/u',
|
||||
'ORCA_AGENT_HOOK_TRANSPORT/u',
|
||||
`ORCA_AGENT_HOOK_ENDPOINT/${endpointFlag}`,
|
||||
...opencodeOverlayEntries,
|
||||
'ORCA_WSL_HOOK_RELAY_VERSION/u',
|
||||
|
||||
@@ -2845,6 +2845,7 @@ const AGENT_HOOK_RUNTIME_ENV_KEYS = [
|
||||
'ORCA_AGENT_HOOK_TOKEN',
|
||||
'ORCA_AGENT_HOOK_ENV',
|
||||
'ORCA_AGENT_HOOK_VERSION',
|
||||
'ORCA_AGENT_HOOK_TRANSPORT',
|
||||
'ORCA_AGENT_HOOK_ENDPOINT'
|
||||
] as const
|
||||
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { OrcaRuntimeService } from '../../orca-runtime'
|
||||
import { isStreamingMethod, type RpcContext } from '../core'
|
||||
|
||||
const { installForRuntimeHomeSerializedMock, realpathMock } = vi.hoisted(() => ({
|
||||
installForRuntimeHomeSerializedMock: vi.fn(),
|
||||
realpathMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('../../../codex/hook-service', () => ({
|
||||
codexHookService: { installForRuntimeHomeSerialized: installForRuntimeHomeSerializedMock }
|
||||
}))
|
||||
vi.mock('node:fs/promises', () => ({ realpath: realpathMock }))
|
||||
|
||||
import { AGENT_HOOK_METHODS } from './agent-hooks'
|
||||
import {
|
||||
_internals as managedWslHomeRegistryInternals,
|
||||
recordManagedWslCodexHome
|
||||
} from '../../../codex/managed-wsl-codex-home-registry'
|
||||
|
||||
const LINUX_HOME = '/home/jin/.local/share/orca/codex-runtime-home/home'
|
||||
const RUNTIME_HOME =
|
||||
'\\\\wsl.localhost\\Ubuntu-24.04\\home\\jin\\.local\\share\\orca\\codex-runtime-home\\home'
|
||||
|
||||
function prepareMethod() {
|
||||
const method = AGENT_HOOK_METHODS.find(
|
||||
(candidate) => candidate.name === 'agentHooks.prepareCodexForWslPane'
|
||||
)
|
||||
if (!method || isStreamingMethod(method)) {
|
||||
throw new Error('Missing agentHooks.prepareCodexForWslPane request method')
|
||||
}
|
||||
return method
|
||||
}
|
||||
|
||||
function runtimeWithSettings(enabled = true, disabledTuiAgents: string[] = []): OrcaRuntimeService {
|
||||
return {
|
||||
getClientSettings: vi.fn(() => ({
|
||||
agentStatusHooksEnabled: enabled,
|
||||
disabledTuiAgents
|
||||
}))
|
||||
} as unknown as OrcaRuntimeService
|
||||
}
|
||||
|
||||
describe('agent hook RPC methods', () => {
|
||||
beforeEach(() => {
|
||||
installForRuntimeHomeSerializedMock.mockReset()
|
||||
realpathMock.mockReset()
|
||||
realpathMock.mockImplementation(async (path: string) => path)
|
||||
managedWslHomeRegistryInternals.clearRecordedManagedWslCodexHomes()
|
||||
recordManagedWslCodexHome('Ubuntu-24.04', RUNTIME_HOME)
|
||||
})
|
||||
|
||||
it('installs the pane-selected WSL home once and returns its status', async () => {
|
||||
const status = { agent: 'codex', state: 'installed' }
|
||||
installForRuntimeHomeSerializedMock.mockResolvedValue(status)
|
||||
const method = prepareMethod()
|
||||
const params = method.params!.parse({
|
||||
codexHome: LINUX_HOME,
|
||||
orcaCodexHome: LINUX_HOME,
|
||||
wslDistro: 'Ubuntu-24.04'
|
||||
})
|
||||
|
||||
await expect(method.handler(params, { runtime: runtimeWithSettings() })).resolves.toBe(status)
|
||||
expect(installForRuntimeHomeSerializedMock).toHaveBeenCalledExactlyOnceWith(RUNTIME_HOME, {
|
||||
runtime: 'wsl',
|
||||
wslDistro: 'Ubuntu-24.04'
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
[false, []],
|
||||
[true, ['codex']]
|
||||
])('does not install when hooks are disabled (%s, %j)', async (enabled, disabledTuiAgents) => {
|
||||
const method = prepareMethod()
|
||||
const params = method.params!.parse({
|
||||
codexHome: '/home/jin/.local/share/orca/codex-runtime-home/home',
|
||||
orcaCodexHome: '/home/jin/.local/share/orca/codex-runtime-home/home',
|
||||
wslDistro: 'Ubuntu-24.04'
|
||||
})
|
||||
|
||||
await expect(
|
||||
method.handler(params, { runtime: runtimeWithSettings(enabled, disabledTuiAgents) })
|
||||
).resolves.toBeNull()
|
||||
expect(installForRuntimeHomeSerializedMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each(['runtime', 'mobile'] as const)('rejects non-local %s callers', async (clientKind) => {
|
||||
const method = prepareMethod()
|
||||
const params = method.params!.parse({
|
||||
codexHome: '/home/jin/.local/share/orca/codex-runtime-home/home',
|
||||
orcaCodexHome: '/home/jin/.local/share/orca/codex-runtime-home/home',
|
||||
wslDistro: 'Ubuntu-24.04'
|
||||
})
|
||||
|
||||
await expect(
|
||||
method.handler(params, {
|
||||
runtime: runtimeWithSettings(),
|
||||
clientKind
|
||||
} as RpcContext)
|
||||
).rejects.toThrow(/only available to the local Orca CLI/)
|
||||
expect(installForRuntimeHomeSerializedMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('propagates an attempted installer failure', async () => {
|
||||
installForRuntimeHomeSerializedMock.mockRejectedValue(new Error('install failed'))
|
||||
const method = prepareMethod()
|
||||
const params = method.params!.parse({
|
||||
codexHome: '/home/jin/.local/share/orca/codex-runtime-home/home',
|
||||
orcaCodexHome: '/home/jin/.local/share/orca/codex-runtime-home/home',
|
||||
wslDistro: 'Ubuntu-24.04'
|
||||
})
|
||||
|
||||
await expect(method.handler(params, { runtime: runtimeWithSettings() })).rejects.toThrow(
|
||||
'install failed'
|
||||
)
|
||||
expect(installForRuntimeHomeSerializedMock).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('rejects a managed-looking home that resolves through a symlink', async () => {
|
||||
realpathMock.mockResolvedValue(
|
||||
'\\\\wsl.localhost\\Ubuntu-24.04\\home\\jin\\outside-managed-home'
|
||||
)
|
||||
const method = prepareMethod()
|
||||
const params = method.params!.parse({
|
||||
codexHome: LINUX_HOME,
|
||||
orcaCodexHome: LINUX_HOME,
|
||||
wslDistro: 'Ubuntu-24.04'
|
||||
})
|
||||
|
||||
await expect(method.handler(params, { runtime: runtimeWithSettings() })).resolves.toBeNull()
|
||||
expect(installForRuntimeHomeSerializedMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects malformed distro names at the RPC schema', () => {
|
||||
const method = prepareMethod()
|
||||
|
||||
expect(() =>
|
||||
method.params!.parse({
|
||||
codexHome: '/home/jin/.local/share/orca/codex-runtime-home/home',
|
||||
orcaCodexHome: '/home/jin/.local/share/orca/codex-runtime-home/home',
|
||||
wslDistro: 'Ubuntu\\..\\host'
|
||||
})
|
||||
).toThrow()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,38 @@
|
||||
import { z } from 'zod'
|
||||
import { prepareManagedWslCodexHomeBeforeShellLaunch } from '../../../codex/managed-wsl-home-shell-preflight'
|
||||
import { defineMethod, type RpcMethod } from '../core'
|
||||
|
||||
const PrepareCodexForWslPaneParams = z
|
||||
.object({
|
||||
codexHome: z.string().max(4_096),
|
||||
orcaCodexHome: z.string().max(4_096),
|
||||
wslDistro: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1)
|
||||
.max(255)
|
||||
.regex(/^[^\\/\r\n]+$/)
|
||||
})
|
||||
.strict()
|
||||
|
||||
export const AGENT_HOOK_METHODS: readonly RpcMethod[] = [
|
||||
defineMethod({
|
||||
name: 'agentHooks.prepareCodexForWslPane',
|
||||
params: PrepareCodexForWslPaneParams,
|
||||
handler: async (params, { runtime, clientKind }) => {
|
||||
if (clientKind !== undefined) {
|
||||
throw new Error('Codex hook preparation is only available to the local Orca CLI.')
|
||||
}
|
||||
const settings = runtime.getClientSettings()
|
||||
return await prepareManagedWslCodexHomeBeforeShellLaunch({
|
||||
env: {
|
||||
CODEX_HOME: params.codexHome,
|
||||
ORCA_CODEX_HOME: params.orcaCodexHome,
|
||||
WSL_DISTRO_NAME: params.wslDistro
|
||||
},
|
||||
hooksEnabled:
|
||||
settings.agentStatusHooksEnabled && !settings.disabledTuiAgents.includes('codex')
|
||||
})
|
||||
}
|
||||
})
|
||||
]
|
||||
@@ -43,12 +43,14 @@ import { PAIRING_METHODS } from './pairing'
|
||||
import { UPDATER_METHODS } from './updater'
|
||||
import { AGENT_SESSION_METHODS } from './agent-session'
|
||||
import { ARTIFACT_METHODS } from './artifacts'
|
||||
import { AGENT_HOOK_METHODS } from './agent-hooks'
|
||||
|
||||
// Why: a flat manifest keeps registration order explicit and provides one
|
||||
// grep-point for "what methods does the RPC server expose?" — useful when
|
||||
// auditing the security boundary or wiring new CLI commands.
|
||||
export const ALL_RPC_METHODS: readonly RpcAnyMethod[] = [
|
||||
...STATUS_METHODS,
|
||||
...AGENT_HOOK_METHODS,
|
||||
...AI_VAULT_METHODS,
|
||||
...ARTIFACT_METHODS,
|
||||
...AUTOMATION_METHODS,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -4,7 +4,10 @@
|
||||
import { basename, dirname, join } from 'node:path'
|
||||
import { homedir } from 'node:os'
|
||||
|
||||
import { ORCA_HOOK_PROTOCOL_VERSION } from '../shared/agent-hook-types'
|
||||
import {
|
||||
ORCA_HOOK_PROTOCOL_VERSION,
|
||||
ORCA_HOOK_RAW_JSON_TRANSPORT
|
||||
} from '../shared/agent-hook-types'
|
||||
|
||||
// Why: relay's userData equivalent under $HOME so each user on a shared dev box gets their own 0o700 dir.
|
||||
const RELAY_HOOKS_DIR_NAME = '.orca-relay'
|
||||
@@ -49,7 +52,8 @@ export function buildRelayHookPtyEnv(coordinates: {
|
||||
ORCA_AGENT_HOOK_PORT: String(coordinates.port),
|
||||
ORCA_AGENT_HOOK_TOKEN: coordinates.token,
|
||||
ORCA_AGENT_HOOK_ENV: coordinates.env,
|
||||
ORCA_AGENT_HOOK_VERSION: ORCA_HOOK_PROTOCOL_VERSION
|
||||
ORCA_AGENT_HOOK_VERSION: ORCA_HOOK_PROTOCOL_VERSION,
|
||||
ORCA_AGENT_HOOK_TRANSPORT: ORCA_HOOK_RAW_JSON_TRANSPORT
|
||||
}
|
||||
if (coordinates.endpointFileWritten) {
|
||||
env.ORCA_AGENT_HOOK_ENDPOINT = coordinates.endpointFilePath
|
||||
|
||||
@@ -8,6 +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()
|
||||
|
||||
@@ -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">
|
||||
|
||||
+189
@@ -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.
|
||||
})
|
||||
}
|
||||
+12
-4
@@ -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
Reference in New Issue
Block a user