Merge remote-tracking branch 'origin/main' into brennanb2025/fix-run-binding-loss

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

Before

Width:  |  Height:  |  Size: 935 B

After

Width:  |  Height:  |  Size: 935 B

+58
View File
@@ -0,0 +1,58 @@
# macOS press-and-hold and key repeat
macOS opens the accent picker when a key is held unless an application opts out in its preferences
domain. That prevents held keys from repeating in terminal applications such as vim. On the first
eligible launch, Orca writes:
```sh
defaults write com.stablyai.orca ApplePressAndHoldEnabled -bool false
```
The write is scoped to Orca's packaged bundle domain. Bare Electron development bundles and
non-macOS platforms are left untouched. A fresh write is conservatively treated as taking effect
on the next launch.
## Precedence and decision record
Orca checks for an explicit domain value before writing. Either `true` or `false` is treated as a
user choice and preserved. Only an unset key receives the `false` default.
The decision is stored once in
`<userData>/macos-press-and-hold-default.json`. An `applied` or
`kept-user-preference` decision prevents future launches from touching the domain again.
Probe and write failures remain retryable so a transient failure does not permanently disable the
fix.
`defaults read <domain> <key>` is used instead of
`systemPreferences.getUserDefault`: the Electron API cannot distinguish an unset key from an
explicit `false`. Only the missing-key exit status is interpreted as unset; spawn failures,
timeouts, and other exit statuses leave the preference alone.
## Restoring the accent picker
Set the preference explicitly, then restart Orca:
```sh
defaults write com.stablyai.orca ApplePressAndHoldEnabled -bool true
```
After Orca has recorded its one-time decision, deleting the key also restores the macOS default
without Orca recreating it:
```sh
defaults delete com.stablyai.orca ApplePressAndHoldEnabled
```
Development and prerelease channels may use a channel-suffixed Orca bundle identifier; use that
domain instead when applicable.
## Reverting
Deleting the startup code is not enough. AppKit reads the persisted preference, so a code revert
must also arrange to delete the key for users who ran an affected build.
## Test coverage
Unit tests cover platform guards, explicit-value preservation, retry behavior, domain ownership,
record persistence, and subprocess exit interpretation on CI. A macOS-only test additionally pins
the real `defaults(1)` behavior, but current PR CI does not execute tests on macOS.
+1
View File
@@ -209,6 +209,7 @@ export const electronViteConfig: UserConfig = {
index: resolve('src/main/index.ts'),
// Why: sandboxed webview preloads cannot load Rollup helper chunks.
'browser-window-close-preload': resolve('src/preload/browser-window-close.ts'),
'doc-preview-link-preload': resolve('src/preload/doc-preview-link.ts'),
'daemon-entry': resolve('src/main/daemon/daemon-entry.ts'),
'plugin-host-entry': resolve('src/main/plugins/plugin-host-entry.ts'),
'computer-sidecar': resolve('src/main/computer/sidecar-entry.ts'),
@@ -26,6 +26,7 @@ function createDeferredBoolean(): DeferredBoolean {
}
type AccessoryInputCommitHarnessOptions = {
readonly composing?: boolean
readonly heldText?: string
readonly sentText?: string
readonly pendingHandle?: string | null
@@ -46,6 +47,7 @@ type AccessoryInputCommitHarness = {
}
function createAccessoryInputCommitHarness({
composing,
heldText = '',
sentText = '',
pendingHandle = null,
@@ -55,6 +57,7 @@ function createAccessoryInputCommitHarness({
}: AccessoryInputCommitHarnessOptions = {}): AccessoryInputCommitHarness {
const activeHandle = 'terminal-a'
const heldLiveInputTextRef: RefObject<string> = { current: heldText }
const liveInputComposingRef: RefObject<boolean | undefined> = { current: composing }
const sentLiveInputTextRef: RefObject<string> = { current: sentText }
const pendingLiveInputHandleRef: RefObject<string | null> = { current: pendingHandle }
const liveInputRef: RefObject<TextInput | null> = { current: null }
@@ -66,7 +69,9 @@ function createAccessoryInputCommitHarness({
return sendResult
}
}
const applyLiveInputMirror = vi.fn((_handle: string, _fieldText: string) => {})
const applyLiveInputMirror = vi.fn(
(_handle: string, _fieldText: string, _composing?: boolean) => {}
)
const clearPendingLiveInputCommit = vi.fn(() => {})
const flushPendingLiveInputText = vi.fn(async (_expectedHandle: string | null) => flushResult)
const waitForPendingLiveInputFlush = vi.fn(async () => waitResult)
@@ -82,6 +87,7 @@ function createAccessoryInputCommitHarness({
clearPendingLiveInputCommit,
flushPendingLiveInputText,
heldLiveInputTextRef,
liveInputComposingRef,
liveInputRef,
liveInputTerminalHandles,
pendingLiveInputHandleRef,
@@ -178,10 +184,11 @@ describe('terminal live accessory input commit hook', () => {
expect(harness.sent).toEqual([])
})
it('Given accessory backspace with a held syllable When committed Then mirrors the emptied field without terminal bytes', async () => {
it('Given accessory backspace with reported composition When committed Then keeps the edited preedit held', async () => {
// Given
const harness = createAccessoryInputCommitHarness({
heldText: '한',
composing: true,
heldText: 'ni hao',
sentText: '',
pendingHandle: 'terminal-a'
})
@@ -190,14 +197,31 @@ describe('terminal live accessory input commit hook', () => {
const result = await harness.commit({ bytes: '\x7f', localEdit: 'backspace' })
// Then
expect(harness.applyLiveInputMirror).toHaveBeenCalledWith('terminal-a', '')
expect(harness.applyLiveInputMirror).toHaveBeenCalledWith('terminal-a', 'ni ha', true)
expect(result).toEqual({ kind: 'handled' })
expect(harness.sent).toEqual([])
})
it('Given accessory backspace with an unreported hold When committed Then preserves the Android fallback', async () => {
// Given
const harness = createAccessoryInputCommitHarness({
heldText: '한글',
sentText: '',
pendingHandle: 'terminal-a'
})
// When
const result = await harness.commit({ bytes: '\x7f', localEdit: 'backspace' })
// Then
expect(harness.applyLiveInputMirror).toHaveBeenCalledWith('terminal-a', '한', undefined)
expect(result).toEqual({ kind: 'handled' })
})
it('Given accessory backspace with mirrored sent text When committed Then mirrors the shortened field so the diff emits DEL', async () => {
// Given
const harness = createAccessoryInputCommitHarness({
composing: false,
heldText: '',
sentText: 'ab',
pendingHandle: 'terminal-a'
@@ -207,7 +231,7 @@ describe('terminal live accessory input commit hook', () => {
const result = await harness.commit({ bytes: '\x7f', localEdit: 'backspace' })
// Then
expect(harness.applyLiveInputMirror).toHaveBeenCalledWith('terminal-a', 'a')
expect(harness.applyLiveInputMirror).toHaveBeenCalledWith('terminal-a', 'a', false)
expect(result).toEqual({ kind: 'handled' })
})
})
@@ -21,10 +21,11 @@ export async function getTerminalLiveAccessoryInactiveInputCommitResult(
type TerminalLiveAccessoryInputCommitOptions = {
readonly activeHandle: string | null
readonly applyLiveInputMirror: (handle: string, fieldText: string) => void
readonly applyLiveInputMirror: (handle: string, fieldText: string, composing?: boolean) => void
readonly clearPendingLiveInputCommit: () => void
readonly flushPendingLiveInputText: (expectedHandle: string | null) => Promise<boolean>
readonly heldLiveInputTextRef: RefObject<string>
readonly liveInputComposingRef: RefObject<boolean | undefined>
readonly liveInputRef: RefObject<TextInput | null>
readonly liveInputTerminalHandles: ReadonlySet<string>
readonly pendingLiveInputHandleRef: RefObject<string | null>
@@ -40,6 +41,7 @@ export function useTerminalLiveAccessoryInputCommit({
clearPendingLiveInputCommit,
flushPendingLiveInputText,
heldLiveInputTextRef,
liveInputComposingRef,
liveInputRef,
liveInputTerminalHandles,
pendingLiveInputHandleRef,
@@ -81,7 +83,8 @@ export function useTerminalLiveAccessoryInputCommit({
// field is edited here and the mirror diff syncs the PTY echo.
setLiveInputCapture(editedText)
liveInputRef.current?.setNativeProps({ text: editedText })
applyLiveInputMirror(activeHandle, editedText)
// Preserve undefined so Android's heuristic hold still settles on its timer.
applyLiveInputMirror(activeHandle, editedText, liveInputComposingRef.current)
return { kind: 'handled' }
}
case 'commit-held-then-send':
@@ -101,6 +104,7 @@ export function useTerminalLiveAccessoryInputCommit({
clearPendingLiveInputCommit,
flushPendingLiveInputText,
heldLiveInputTextRef,
liveInputComposingRef,
liveInputRef,
liveInputTerminalHandles,
pendingLiveInputHandleRef,
@@ -161,6 +161,35 @@ describe('terminal live input commit hook', () => {
expect(sent).toEqual([])
})
it('Given an iOS pinyin preedit When accessory Backspace edits it Then only the candidate reaches the terminal', async () => {
// Given
vi.useFakeTimers()
const { handlers, sent } = createTerminalLiveInputCommitHarness()
changeLiveInput(handlers, 'ni hao', true)
// When
await handlers.handleLiveInputAccessoryBytes({ bytes: '\x7f', localEdit: 'backspace' })
await vi.advanceTimersByTimeAsync(TERMINAL_LIVE_HELD_PREEDIT_COMMIT_DELAY_MS * 2)
changeLiveInput(handlers, '你好', false)
// Then
await vi.waitFor(() => expect(sent).toEqual(['你好']))
})
it('Given an Android heuristic hold When accessory Backspace edits it Then the remaining text still settles', async () => {
// Given
vi.useFakeTimers()
const { handlers, sent } = createTerminalLiveInputCommitHarness()
changeLiveInput(handlers, '한글')
// When
await handlers.handleLiveInputAccessoryBytes({ bytes: '\x7f', localEdit: 'backspace' })
await vi.advanceTimersByTimeAsync(TERMINAL_LIVE_HELD_PREEDIT_COMMIT_DELAY_MS)
// Then
await vi.waitFor(() => expect(sent).toEqual(['한']))
})
it('Given a held syllable When the settle timer elapses Then commits it to the terminal', async () => {
// Given
vi.useFakeTimers()
@@ -68,6 +68,7 @@ export function useTerminalLiveInputCommit<TTabType extends string>({
clearPendingLiveInputCommit,
flushPendingLiveInputText,
heldLiveInputTextRef,
liveInputComposingRef,
pendingLiveInputHandleRef,
sentLiveInputTextRef,
waitForPendingLiveInputFlush
@@ -196,6 +197,7 @@ export function useTerminalLiveInputCommit<TTabType extends string>({
clearPendingLiveInputCommit,
flushPendingLiveInputText,
heldLiveInputTextRef,
liveInputComposingRef,
liveInputRef,
liveInputTerminalHandles,
pendingLiveInputHandleRef,
@@ -34,6 +34,7 @@ type TerminalLivePendingInputFlush = {
readonly clearPendingLiveInputCommit: () => void
readonly flushPendingLiveInputText: (expectedHandle: string | null) => Promise<boolean>
readonly heldLiveInputTextRef: RefObject<string>
readonly liveInputComposingRef: RefObject<boolean | undefined>
readonly pendingLiveInputHandleRef: RefObject<string | null>
readonly sentLiveInputTextRef: RefObject<string>
readonly waitForPendingLiveInputFlush: () => Promise<boolean>
@@ -50,6 +51,7 @@ export function useTerminalLivePendingInputFlush<TTabType extends string>({
const heldCommitTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const pendingLiveInputFlushRef = useRef(createTerminalLivePendingFlushState())
const heldLiveInputTextRef = useRef('')
const liveInputComposingRef = useRef<boolean | undefined>(undefined)
const sentLiveInputTextRef = useRef('')
const pendingLiveInputHandleRef = useRef<string | null>(null)
const runMirrorStepRef = useRef<RunTerminalLiveMirrorStep>(async () => false)
@@ -65,6 +67,7 @@ export function useTerminalLivePendingInputFlush<TTabType extends string>({
clearHeldCommitTimer()
cancelTerminalLivePendingFlush(pendingLiveInputFlushRef.current)
heldLiveInputTextRef.current = ''
liveInputComposingRef.current = undefined
sentLiveInputTextRef.current = ''
pendingLiveInputHandleRef.current = null
}, [clearHeldCommitTimer])
@@ -106,6 +109,7 @@ export function useTerminalLivePendingInputFlush<TTabType extends string>({
})
sentLiveInputTextRef.current = step.nextSentText
heldLiveInputTextRef.current = step.heldText
liveInputComposingRef.current = composing
pendingLiveInputHandleRef.current =
step.heldText.length > 0 || step.nextSentText.length > 0 ? handle : null
@@ -187,6 +191,7 @@ export function useTerminalLivePendingInputFlush<TTabType extends string>({
heldCommitTimerRef.current = null
}
heldLiveInputTextRef.current = ''
liveInputComposingRef.current = undefined
sentLiveInputTextRef.current = ''
pendingLiveInputHandleRef.current = null
cancelTerminalLivePendingFlush(pendingLiveInputFlushRef.current)
@@ -198,6 +203,7 @@ export function useTerminalLivePendingInputFlush<TTabType extends string>({
clearPendingLiveInputCommit,
flushPendingLiveInputText,
heldLiveInputTextRef,
liveInputComposingRef,
pendingLiveInputHandleRef,
sentLiveInputTextRef,
waitForPendingLiveInputFlush
+28
View File
@@ -92,6 +92,7 @@ describe('agent hooks CLI handler', () => {
})
afterEach(() => {
vi.unstubAllEnvs()
vi.restoreAllMocks()
rmSync(userDataPath, { recursive: true, force: true })
})
@@ -139,6 +140,33 @@ describe('agent hooks CLI handler', () => {
})
})
it('forwards WSL pane routing to the runtime exactly once without using the host installer', async () => {
const home = '/home/jin/.local/share/orca/codex-runtime-home/home'
vi.stubEnv('CODEX_HOME', home)
vi.stubEnv('ORCA_CODEX_HOME', home)
vi.stubEnv('WSL_DISTRO_NAME', 'Ubuntu-24.04')
callMock.mockResolvedValue({ result: { state: 'installed' } })
await main(['agent', 'hooks', 'prepare-codex'], userDataPath)
expect(callMock).toHaveBeenCalledExactlyOnceWith(
'agentHooks.prepareCodexForWslPane',
{ codexHome: home, orcaCodexHome: home, wslDistro: 'Ubuntu-24.04' },
{ timeoutMs: 50_000 }
)
expect(prepareManagedCodexHomeBeforeShellLaunchMock).not.toHaveBeenCalled()
})
it('fails open when WSL runtime preparation is unavailable', async () => {
vi.stubEnv('CODEX_HOME', '/home/jin/.local/share/orca/codex-runtime-home/home')
vi.stubEnv('ORCA_CODEX_HOME', '/home/jin/.local/share/orca/codex-runtime-home/home')
vi.stubEnv('WSL_DISTRO_NAME', 'Ubuntu')
callMock.mockRejectedValue(new Error('method_not_found'))
await expect(main(['agent', 'hooks', 'prepare-codex'], userDataPath)).resolves.toBeUndefined()
expect(prepareManagedCodexHomeBeforeShellLaunchMock).not.toHaveBeenCalled()
})
it('honors Codex-specific disablement when the runtime is unavailable', async () => {
const state = getDefaultPersistedState(userDataPath)
state.settings.disabledTuiAgents = ['codex']
+19
View File
@@ -28,6 +28,9 @@ type AgentHookCommandResult = {
statuses: AgentHookInstallStatus[]
}
// Covers managed-home verification, WSL identity, trust grant, and bounded app-server reap.
const WSL_CODEX_PREPARE_TIMEOUT_MS = 50_000
function getDataPath(): string {
const userDataPath = getDefaultUserDataPath()
const indexPath = join(userDataPath, 'orca-profile-index.json')
@@ -207,6 +210,22 @@ async function setAgentHooksEnabled(
export const AGENT_HOOK_HANDLERS: Record<string, CommandHandler> = {
'agent hooks prepare-codex': async ({ client }) => {
if (process.env.WSL_DISTRO_NAME?.trim()) {
try {
await client.call(
'agentHooks.prepareCodexForWslPane',
{
codexHome: process.env.CODEX_HOME ?? '',
orcaCodexHome: process.env.ORCA_CODEX_HOME ?? '',
wslDistro: process.env.WSL_DISTRO_NAME
},
{ timeoutMs: WSL_CODEX_PREPARE_TIMEOUT_MS }
)
} catch {
// Best effort: old or unavailable runtimes must not block Codex launch.
}
return
}
const settings = await readHookSettings(client)
await prepareManagedCodexHomeBeforeShellLaunch({
userDataPath: getDefaultUserDataPath(),
@@ -211,6 +211,16 @@ describe('orchestration gate commands carry caller identity', () => {
'The Orca runtime closed the connection before responding. Restart Orca and try again. Orchestration mutation request ID: mutation_1.',
{
orchestrationRequestId: 'mutation_1',
originalCommand: [
'orca',
'orchestration',
'gate-create',
'--task',
'task_1',
'--question',
'ship?',
'--json'
],
failedStage: 'dispatch_input',
residualResources: [
{ kind: 'worktree', id: 'repo::child' },
+5 -5
View File
@@ -268,9 +268,9 @@ Common Commands:
Selectors:
--repo <selector> Registered repo selector such as id:<id>, name:<name>, or path:<path>
--worktree <selector> Worktree selector such as id:<repo-id>::<path>, name:<displayName>, branch:<branch>, issue:<number>, path:<path>, or active/current
--worktree <selector> Worktree selector such as identity:<identity>, id:<repo-id>::<path>, name:<displayName>, branch:<branch>, issue:<number>, path:<path>, or active/current
--terminal <handle> Runtime-issued terminal handle returned by \`orca terminal list --json\`
--parent-worktree <selector> Parent worktree selector such as id:<repo-id>::<path>, branch:<branch>, issue:<number>, path:<path>, or active/current
--parent-worktree <selector> Parent worktree selector such as identity:<identity>, id:<repo-id>::<path>, branch:<branch>, issue:<number>, path:<path>, or active/current
--no-parent Force no parent lineage for unrelated worktree creation/update
Terminal Send Options:
@@ -509,7 +509,7 @@ function formatCommandFlagHelp(flag: string, commandPath: string[]): string {
return '--parent-current Use the current linked issue as parent'
}
if (command === 'worktree create' && flag === 'parent-worktree') {
return '--parent-worktree <selector> Parent selector such as active/current, id:<repo-id>::<path>, branch:<branch>, issue:<number>, path:<path>, folder:<id>, or worktree:<worktreeId>'
return '--parent-worktree <selector> Parent selector such as identity:<identity>, active/current, id:<repo-id>::<path>, branch:<branch>, issue:<number>, path:<path>, folder:<id>, or worktree:<worktreeId>'
}
if (command === 'orchestration task-create' && flag === 'task-title') {
return '--task-title <text> Concise title for the orchestration task'
@@ -577,7 +577,7 @@ export function formatFlagHelp(flag: string): string {
'no-screenshot': '--no-screenshot Skip screenshot capture after the operation',
pages: '--pages <n> Number of scroll pages',
'parent-worktree':
'--parent-worktree <selector> Parent worktree selector such as id:<repo-id>::<path>, branch:<branch>, issue:<number>, path:<path>, or active/current',
'--parent-worktree <selector> Parent worktree selector such as identity:<identity>, id:<repo-id>::<path>, branch:<branch>, issue:<number>, path:<path>, or active/current',
path: '--path <path> Path argument for the command',
prompt: '--prompt <text> Prompt text for agent-backed commands',
query: '--query <text> Search text for matching refs',
@@ -601,7 +601,7 @@ export function formatFlagHelp(flag: string): string {
'to-x': '--to-x <x> Destination window-local x coordinate',
'to-y': '--to-y <y> Destination window-local y coordinate',
worktree:
'--worktree <selector> Worktree selector such as id:<repo-id>::<path>, name:<displayName>, branch:<branch>, issue:<number>, path:<path>, or active/current',
'--worktree <selector> Worktree selector such as identity:<identity>, id:<repo-id>::<path>, name:<displayName>, branch:<branch>, issue:<number>, path:<path>, or active/current',
workspace: '--workspace <selector> Existing worktree selector for automation runs',
'workspace-status':
'--workspace-status <id> Board status id (defaults: todo, in-progress, in-review, completed)',
+3
View File
@@ -41,6 +41,7 @@ vi.mock('child_process', async () => {
})
import { COMMAND_SPECS, main } from './index'
import { formatFlagHelp } from './help'
import { GLOBAL_FLAGS, specPaths } from './args'
import { okFixture, queueFixtures } from './test-fixtures'
@@ -480,6 +481,7 @@ describe('orca root help', () => {
const rootHelp = String(logSpy.mock.calls[0][0])
expect(rootHelp).not.toContain('--parent-workspace')
expect(rootHelp).toContain('[--parent-worktree <selector>] [--no-parent]')
expect(rootHelp).toContain('identity:<identity>')
logSpy.mockClear()
await main(['worktree', 'create', '--help'], '/tmp/repo')
@@ -504,6 +506,7 @@ describe('orca root help', () => {
const setHelp = String(logSpy.mock.calls[0][0])
expect(setHelp).not.toContain('--parent-workspace')
expect(setHelp).not.toContain('folder:<id>')
expect(formatFlagHelp('parent-worktree')).toContain('identity:<identity>')
expect(setHelp).not.toContain('worktree:<id>')
expect(callMock).not.toHaveBeenCalled()
})
+4 -1
View File
@@ -18,6 +18,7 @@ import { reportCliError } from './format'
import { printHelp } from './help'
import type { RuntimeClient } from './runtime-client'
import { COMMAND_SPECS } from './specs'
import { resolveOrchestrationCliExecutable } from './runtime/orchestration-recovery-command'
export { COMMAND_SPECS } from './specs'
export { buildCurrentWorktreeSelector, normalizeWorktreeSelector } from './selectors'
@@ -149,7 +150,9 @@ export async function main(
undefined,
undefined,
remotePairingCode,
remoteEnvironment
remoteEnvironment,
resolveOrchestrationCliExecutable(),
argv
)
return client
},
@@ -0,0 +1,269 @@
import { describe, expect, it } from 'vitest'
import { runProcess } from '../shared/child-process/run-process'
import {
orchestrationMutationRecoveryError,
renderCommand
} from './orchestration-mutation-recovery'
import { RuntimeClientError } from './runtime-client'
describe('orchestration mutation recovery', () => {
it('queries a known dispatch before issuing the keyed retry', () => {
const result = orchestrationMutationRecoveryError(
new RuntimeClientError('runtime_timeout', 'request timed out', {
orchestrationRequestId: 'request_1',
dispatchId: 'dispatch_1',
originalCommand: ['orca', 'orchestration', 'worker-start', '--task', 'task_1']
})
) as RuntimeClientError
expect(result.data).toMatchObject({
recovery: {
orchestrationRequestId: 'request_1',
dispatchId: 'dispatch_1',
queryCommand: [
'orca',
'orchestration',
'worker-show',
'--dispatch',
'dispatch_1',
'--json'
],
retryCommand: [
'orca',
'orchestration',
'worker-start',
'--task',
'task_1',
'--retry-request',
'request_1'
],
workerDeathInferred: false
}
})
expect(result.message.indexOf('orca orchestration worker-show')).toBeLessThan(
result.message.indexOf('orca orchestration worker-start')
)
expect((result.data as { nextSteps?: string[] }).nextSteps).toEqual([
'Run orca orchestration worker-show --dispatch dispatch_1 --json before retrying.',
'Run orca orchestration worker-start --task task_1 --retry-request request_1.'
])
})
it('does not invent a dispatch for an old-client-shaped error', () => {
const result = orchestrationMutationRecoveryError(
new RuntimeClientError('runtime_timeout', 'request timed out', {
orchestrationRequestId: 'request_2',
originalCommand: ['orca', 'orchestration', 'worker-start', '--task', 'task_2']
})
) as RuntimeClientError
expect(result.data).toMatchObject({
recovery: {
orchestrationRequestId: 'request_2',
retryCommand: expect.arrayContaining(['--retry-request', 'request_2']),
workerDeathInferred: false
}
})
expect((result.data as Record<string, unknown>).recovery).not.toHaveProperty('dispatchId')
expect(result.message).not.toContain('worker death')
})
it('renders the exact executable and safely quotes original arguments', () => {
const result = orchestrationMutationRecoveryError(
new RuntimeClientError('runtime_timeout', 'request timed out', {
orchestrationRequestId: 'request_3',
dispatchId: 'dispatch_3',
originalCommand: [
'orca-dev',
'orchestration',
'worker-start',
'--task',
'task 3',
'--comment',
'literal $(do-not-run)'
]
})
) as RuntimeClientError
expect((result.data as { nextSteps?: string[] }).nextSteps).toEqual([
'Run orca-dev orchestration worker-show --dispatch dispatch_3 --json before retrying.',
"Run orca-dev orchestration worker-start --task 'task 3' --comment 'literal $(do-not-run)' --retry-request request_3."
])
expect(result.message).toContain("'literal $(do-not-run)'")
})
it('parses legacy command text without losing quoted arguments', () => {
const result = orchestrationMutationRecoveryError(
new RuntimeClientError('runtime_timeout', 'request timed out', {
orchestrationRequestId: 'request_4',
originalCommand:
'orca-ide orchestration worker-stop --dispatch dispatch_4 --comment "quoted value"'
})
) as RuntimeClientError
expect(
(result.data as { recovery?: { retryCommand?: string[] } }).recovery?.retryCommand
).toEqual([
'orca-ide',
'orchestration',
'worker-stop',
'--dispatch',
'dispatch_4',
'--comment',
'quoted value',
'--retry-request',
'request_4'
])
})
it.each([
[
'gate-create',
['orca', 'orchestration', 'gate-create', '--task', 'task_1', '--question', 'ship?']
],
[
'worker-retain',
['orca', 'orchestration', 'worker-retain', '--dispatch', 'dispatch_1', '--json']
]
])('replays exact %s argv with the keyed retry', (_name, originalCommand) => {
const result = orchestrationMutationRecoveryError(
new RuntimeClientError('runtime_timeout', 'request timed out', {
orchestrationRequestId: 'request_exact',
originalCommand
})
) as RuntimeClientError
expect(
(result.data as { recovery?: { retryCommand?: string[] } }).recovery?.retryCommand
).toEqual([...originalCommand, '--retry-request', 'request_exact'])
})
it('reuses the request identity without duplicating an existing retry flag', () => {
const result = orchestrationMutationRecoveryError(
new RuntimeClientError('runtime_timeout', 'request timed out', {
orchestrationRequestId: 'request_reused',
originalCommand: [
'orca',
'orchestration',
'worker-retain',
'--dispatch',
'dispatch_1',
'--retry-request=request_reused'
]
})
) as RuntimeClientError
expect(
(result.data as { recovery?: { retryCommand?: string[] } }).recovery?.retryCommand
).toEqual([
'orca',
'orchestration',
'worker-retain',
'--dispatch',
'dispatch_1',
'--retry-request',
'request_reused'
])
})
it('renders Windows cmd recovery guidance without quote drift or percent expansion', () => {
expect(
renderCommand(
['orca', 'orchestration', 'worker-start', '--comment', 'literal "quoted" %PATH% & safe'],
'win32',
{ ComSpec: 'C:\\Windows\\System32\\cmd.exe' }
)
).toBe(
'"orca" "orchestration" "worker-start" "--comment" "literal ""quoted"" "^%"PATH"^%" & safe"'
)
})
it('keeps PowerShell and POSIX recovery guidance literal', () => {
expect(
renderCommand(['orca', 'literal "quoted" $HOME'], 'win32', {
ComSpec: 'powershell.exe'
})
).toBe("& 'orca' 'literal \\\"quoted\\\" $HOME'")
expect(renderCommand(['orca', 'literal $(do-not-run)'], 'darwin')).toBe(
"orca 'literal $(do-not-run)'"
)
})
it.runIf(process.platform !== 'win32')(
'round trips POSIX recovery argv through /bin/sh',
async () => {
const values = ['with spaces', "apostrophe's", 'literal $(do-not-run)', 'line one\nline two']
const command = renderCommand(
[
process.execPath,
'-e',
'process.stdout.write(JSON.stringify(process.argv.slice(1)))',
...values
],
'darwin'
)
const result = await runProcess({ program: '/bin/sh', args: ['-c', command] })
expect(result).toMatchObject({ code: 0, stderr: '', timedOut: false })
expect(JSON.parse(result.stdout)).toEqual(values)
}
)
it.each([
[
'split',
['orca', 'orchestration', 'send', '--pairing-code', 'split-secret', '--subject', 'status'],
'split-secret'
],
[
'equals',
['orca', 'orchestration', 'send', '--pairing-code=equals-secret', '--subject', 'status'],
'equals-secret'
],
[
'dispatch split',
[
'orca',
'orchestration',
'send',
'--dispatch-capability',
'split-dispatch-secret',
'--subject',
'status'
],
'split-dispatch-secret'
],
[
'dispatch equals',
[
'orca',
'orchestration',
'send',
'--dispatch-capability=equals-dispatch-secret',
'--subject',
'status'
],
'equals-dispatch-secret'
]
])('blocks recovery and removes %s credentials', (_name, originalCommand, secret) => {
const result = orchestrationMutationRecoveryError(
new RuntimeClientError('runtime_timeout', 'request timed out', {
orchestrationRequestId: 'request_secret',
originalCommand
})
) as RuntimeClientError
const output = JSON.stringify({ message: result.message, data: result.data })
expect(output).not.toContain(secret)
expect(result.message).toContain('Recovery is blocked')
expect(result.data).toMatchObject({
recovery: {
orchestrationRequestId: 'request_secret',
recoveryBlocked: true
}
})
expect(result.data).not.toHaveProperty('originalCommand')
expect((result.data as { recovery: object }).recovery).not.toHaveProperty('retryCommand')
})
})
+163 -3
View File
@@ -1,4 +1,12 @@
import { RuntimeClientError } from './runtime-client'
import { RuntimeClientError, RuntimeRpcFailureError } from './runtime-client'
import {
recoverableOrchestrationArgs,
resolveOrchestrationCliExecutable
} from './runtime/orchestration-recovery-command'
import { quoteWindowsCmdArgument } from '../shared/child-process/windows-command-line'
import { quotePowerShellNativeArgument } from '../shared/powershell-native-argument'
import { resolveWindowsShellStartupFamily } from '../shared/windows-terminal-shell'
import type { AgentStartupShell } from '../shared/tui-agent-startup-shell'
export function orchestrationMutationRecoveryError(error: unknown): unknown {
if (!(error instanceof RuntimeClientError) || !isUnknownMutationOutcomeCode(error.code)) {
@@ -9,16 +17,64 @@ export function orchestrationMutationRecoveryError(error: unknown): unknown {
if (typeof requestId !== 'string' || requestId.length === 0) {
return error
}
const dispatchId = typeof data?.dispatchId === 'string' ? data.dispatchId : undefined
const parsedOriginalCommand = commandParts(data?.originalCommand)
const originalCommand = parsedOriginalCommand
? recoverableOrchestrationArgs(parsedOriginalCommand)
: undefined
const safeData = { ...data }
delete safeData.originalCommand
const retryCommand = originalCommand
? [...originalCommand, '--retry-request', requestId]
: undefined
const executable = originalCommand?.[0] ?? resolveOrchestrationCliExecutable()
const queryCommand = dispatchId
? [executable, 'orchestration', 'worker-show', '--dispatch', dispatchId, '--json']
: undefined
const recovery = {
orchestrationRequestId: requestId,
...(dispatchId ? { dispatchId } : {}),
...(queryCommand ? { queryCommand } : {}),
...(retryCommand ? { retryCommand } : {}),
recoveryBlocked: !retryCommand,
disposition: 'outcome_unknown',
workerDeathInferred: false
}
const retryStep = retryCommand
? `Run ${renderCommand(retryCommand)}.`
: 'Recovery is blocked until the exact original command is available; no retry command was emitted.'
const nextSteps = queryCommand
? [`Run ${renderCommand(queryCommand)} before retrying.`, retryStep]
: [retryStep]
const message = [
stripUnsafeRetryAdvice(error.message, requestId),
'The orchestration mutation may already have taken effect; do not assume it failed.',
`Re-issue the same command with --retry-request ${requestId} to recover idempotently. Do not retry this mutation without --retry-request.`,
...nextSteps,
typeof data?.failedStage === 'string' ? `Failed stage: ${data.failedStage}.` : undefined,
Array.isArray(data?.residualResources)
? `Residual resources: ${JSON.stringify(data.residualResources)}.`
: undefined
].filter((line): line is string => line !== undefined)
return new RuntimeClientError(error.code, message.join('\n'), error.data)
const recoveredData = {
...safeData,
orchestrationRequestId: requestId,
...(originalCommand ? { originalCommand } : {}),
recovery,
nextSteps
}
// Preserve the RPC failure envelope so --json callers retain the request id and
// runtime metadata while receiving the structured recovery guidance.
if (error instanceof RuntimeRpcFailureError) {
return new RuntimeRpcFailureError({
...error.response,
error: {
...error.response.error,
message: message.join('\n'),
data: recoveredData
}
})
}
return new RuntimeClientError(error.code, message.join('\n'), recoveredData)
}
function isUnknownMutationOutcomeCode(code: string): boolean {
@@ -36,6 +92,110 @@ function objectRecord(value: unknown): Record<string, unknown> | undefined {
: undefined
}
function commandParts(value: unknown): string[] | undefined {
if (Array.isArray(value) && value.every((part) => typeof part === 'string')) {
return [...value]
}
if (typeof value === 'string' && value.length > 0) {
return parseCommandLine(value)
}
return undefined
}
function parseCommandLine(value: string): string[] | undefined {
const parts: string[] = []
let part = ''
let quote: "'" | '"' | undefined
let tokenStarted = false
for (let index = 0; index < value.length; index += 1) {
const character = value[index]
if (quote === "'") {
if (character === "'") {
quote = undefined
} else {
part += character
}
tokenStarted = true
continue
}
if (quote === '"') {
if (character === '"') {
quote = undefined
} else if (character === '\\' && ['"', '\\'].includes(value[index + 1] ?? '')) {
part += value[++index]
} else {
part += character
}
tokenStarted = true
continue
}
if (character === "'" || character === '"') {
quote = character
tokenStarted = true
} else if (/\s/.test(character)) {
if (tokenStarted) {
parts.push(part)
part = ''
tokenStarted = false
}
} else if (character === '\\' && [' ', '\\', "'", '"'].includes(value[index + 1] ?? '')) {
part += value[++index]
tokenStarted = true
} else {
part += character
tokenStarted = true
}
}
if (quote !== undefined) {
return undefined
}
if (tokenStarted) {
parts.push(part)
}
return parts.length > 0 ? parts : undefined
}
export function renderCommand(
command: readonly string[],
platform: NodeJS.Platform = process.platform,
env: NodeJS.ProcessEnv = process.env
): string {
const shell = resolveRecoveryShell(platform, env)
const rendered = command.map((value) => quoteRecoveryArgument(value, shell)).join(' ')
return shell === 'powershell' && rendered ? `& ${rendered}` : rendered
}
function resolveRecoveryShell(
platform: NodeJS.Platform,
env: NodeJS.ProcessEnv
): AgentStartupShell {
if (platform !== 'win32') {
return 'posix'
}
return resolveWindowsShellStartupFamily(
env.ORCA_TERMINAL_WINDOWS_SHELL ?? env.ORCA_WINDOWS_SHELL ?? env.ComSpec ?? env.COMSPEC
)
}
function quoteRecoveryArgument(value: string, shell: AgentStartupShell): string {
if (shell === 'cmd') {
// cmd has no single-quote syntax and expands %VAR% inside double quotes;
// use the argv encoder that keeps quote parity and escapes percent pairs.
return quoteWindowsCmdArgument(value)
}
if (shell === 'powershell') {
return quotePowerShellNativeArgument(value)
}
return shellQuote(value)
}
function shellQuote(value: string): string {
if (/^[A-Za-z0-9_./:@%+=,-]+$/.test(value)) {
return value
}
return `'${value.replaceAll("'", `'"'"'`)}'`
}
function stripUnsafeRetryAdvice(message: string, requestId: string): string {
return message
.replace(' Restart Orca and try again.', '')
+31
View File
@@ -0,0 +1,31 @@
import { RuntimeClientError, RuntimeRpcFailureError } from './types'
export function attachMutationRecovery(
error: unknown,
requestId: string | undefined,
originalCommand?: string[]
): unknown {
if (!requestId || !(error instanceof RuntimeClientError)) {
return error
}
const data = {
...(error.data && typeof error.data === 'object' ? error.data : {}),
orchestrationRequestId: requestId,
...(originalCommand ? { originalCommand } : {})
}
if (error instanceof RuntimeRpcFailureError) {
return new RuntimeRpcFailureError({
...error.response,
error: {
...error.response.error,
message: `${error.message} Orchestration mutation request ID: ${requestId}.`,
data
}
})
}
return new RuntimeClientError(
error.code,
`${error.message} Orchestration mutation request ID: ${requestId}.`,
data
)
}
+130
View File
@@ -0,0 +1,130 @@
import { createServer, type Server } from 'node:net'
import { mkdtempSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { ORCHESTRATION_CONTRACT_RUNTIME_CAPABILITY } from '../../shared/protocol-version'
import { ORCHESTRATION_WORKER_START_CLIENT_GRACE_MS } from '../../shared/orchestration-timing-budgets'
import { MAX_TIMER_DELAY_MS } from '../../shared/timer-delay'
import { orchestrationMutationRecoveryError } from '../orchestration-mutation-recovery'
import { RuntimeClient, RuntimeRpcFailureError } from '../runtime-client'
const servers = new Set<Server>()
afterEach(async () => {
await Promise.all(
[...servers].map(
(server) =>
new Promise<void>((resolve) => {
server.close(() => resolve())
})
)
)
servers.clear()
})
describe('RuntimeClient orchestration recovery identity', () => {
it('rejects a worker-start timeout whose client grace would overflow timers', () => {
const client = new RuntimeClient(undefined, 60_000, null, null, 'orca')
const resolve = (
client as unknown as {
resolveMethodTimeoutMs: (method: string, params?: unknown) => number
}
).resolveMethodTimeoutMs.bind(client)
const maxValid = MAX_TIMER_DELAY_MS - ORCHESTRATION_WORKER_START_CLIENT_GRACE_MS
expect(resolve('orchestration.workerStart', { timeoutMs: maxValid })).toBe(MAX_TIMER_DELAY_MS)
expect(() => resolve('orchestration.workerStart', { timeoutMs: maxValid + 1 })).toThrow(
'derived timeout must be'
)
})
it('attaches the request and exact retry identity to a real RPC failure response', async () => {
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-recovery-'))
const endpoint = join(userDataPath, 'runtime.sock')
const server = createServer((socket) => {
let buffer = ''
socket.setEncoding('utf8')
socket.on('data', (chunk: string) => {
buffer += chunk
const newline = buffer.indexOf('\n')
if (newline === -1) {
return
}
const request = JSON.parse(buffer.slice(0, newline)) as { id: string; method: string }
const response =
request.method === 'status.get'
? {
id: request.id,
ok: true,
result: { capabilities: [ORCHESTRATION_CONTRACT_RUNTIME_CAPABILITY] },
_meta: { runtimeId: 'runtime-1' }
}
: {
id: request.id,
ok: false,
error: {
code: 'runtime_timeout',
message: 'request timed out',
data: { requestId: 'request_1', dispatchId: 'dispatch_1' }
},
_meta: { runtimeId: 'runtime-1' }
}
socket.end(`${JSON.stringify(response)}\n`)
})
})
servers.add(server)
await new Promise<void>((resolve) => server.listen(endpoint, resolve))
writeFileSync(
join(userDataPath, 'orca-runtime.json'),
JSON.stringify({
runtimeId: 'runtime-1',
pid: 1,
transports: [{ kind: 'unix', endpoint }],
authToken: 'token',
startedAt: 1
})
)
const client = new RuntimeClient(userDataPath, 500, null, null, 'orca')
try {
await client.call('orchestration.workerStart', { task: 'task_1' })
throw new Error('expected worker-start failure')
} catch (error) {
expect(error).toBeInstanceOf(RuntimeRpcFailureError)
const recovered = orchestrationMutationRecoveryError(error) as {
data?: Record<string, unknown>
response?: { id?: string; _meta?: { runtimeId?: string } }
}
expect(recovered).toBeInstanceOf(RuntimeRpcFailureError)
expect(recovered.response).toMatchObject({
id: expect.any(String),
_meta: { runtimeId: 'runtime-1' }
})
expect(recovered.data).toMatchObject({
orchestrationRequestId: expect.any(String),
dispatchId: 'dispatch_1',
originalCommand: ['orca', 'orchestration', 'worker-start', '--task', 'task_1'],
recovery: {
queryCommand: [
'orca',
'orchestration',
'worker-show',
'--dispatch',
'dispatch_1',
'--json'
],
retryCommand: [
'orca',
'orchestration',
'worker-start',
'--task',
'task_1',
'--retry-request',
expect.any(String)
],
recoveryBlocked: false
}
})
}
})
})
+45 -19
View File
@@ -11,6 +11,7 @@ import { getDefaultUserDataPath, readMetadata } from './metadata'
import { getCliStatus, projectRemoteAppStatus } from './status'
import { sendRequest } from './transport'
import { RuntimeClientError, RuntimeRpcFailureError, type RuntimeRpcSuccess } from './types'
import { attachMutationRecovery } from './client-error-recovery'
import { markEnvironmentUsed, resolveEnvironmentPairingOffer } from './environments'
import {
ORCHESTRATION_CONTRACT_RUNTIME_CAPABILITY,
@@ -19,6 +20,16 @@ import {
import { RemoteRuntimeCompatGate } from './remote-runtime-compat-gate'
import { createOrchestrationCompatibilityEnvelope } from './orchestration-compatibility-envelope'
import { getTimeoutMsParam, isWaitingCheck } from './runtime-request-timeout'
import {
isWorkerStartTimeoutWithinTimerLimit,
resolveWorkerStartClientTimeoutMs,
resolveWorkerStartReadinessTimeoutMs
} from '../../shared/orchestration-timing-budgets'
import { MAX_TIMER_DELAY_MS } from '../../shared/timer-delay'
import {
buildOrchestrationRecoveryCommand,
resolveOrchestrationCliExecutable
} from './orchestration-recovery-command'
// Why: for long-poll methods the caller's method-level
// `params.timeoutMs` is the inner waiter budget; we extend the client-side
@@ -40,6 +51,8 @@ export class RuntimeClient {
private readonly requestTimeoutMs: number
private readonly remotePairing: PairingOffer | null
private readonly environmentSelector: string | null
private readonly cliExecutable: string
private readonly originalArgs: readonly string[] | undefined
private readonly remoteCompat: RemoteRuntimeCompatGate
private orchestrationContractCheck: Promise<void> | null = null
private readonly orchestrationCompatibility = createOrchestrationCompatibilityEnvelope(
@@ -53,11 +66,15 @@ export class RuntimeClient {
userDataPath = getDefaultUserDataPath(),
requestTimeoutMs = 60_000,
remotePairingCode = process.env.ORCA_PAIRING_CODE ?? process.env.ORCA_REMOTE_PAIRING ?? null,
environmentSelector = process.env.ORCA_ENVIRONMENT ?? null
environmentSelector = process.env.ORCA_ENVIRONMENT ?? null,
cliExecutable = resolveOrchestrationCliExecutable(),
originalArgs?: readonly string[]
) {
this.userDataPath = userDataPath
this.requestTimeoutMs = requestTimeoutMs
this.environmentSelector = environmentSelector
this.cliExecutable = cliExecutable
this.originalArgs = originalArgs ? [...originalArgs] : undefined
this.remotePairing = resolveRemotePairing(userDataPath, remotePairingCode, environmentSelector)
this.remoteCompat = new RemoteRuntimeCompatGate(userDataPath, environmentSelector)
}
@@ -79,6 +96,9 @@ export class RuntimeClient {
const orchestrationRequestId = orchestrationMutation
? (options?.orchestrationRequestId ?? randomUUID())
: undefined
const originalCommand = orchestrationMutation
? buildOrchestrationRecoveryCommand(method, params, this.cliExecutable, this.originalArgs)
: undefined
const compatibilityEnvelope = method.startsWith('orchestration.')
? {
...this.orchestrationCompatibility,
@@ -107,10 +127,14 @@ export class RuntimeClient {
envelope
})
} catch (error) {
throw attachMutationRecovery(error, orchestrationRequestId)
throw attachMutationRecovery(error, orchestrationRequestId, originalCommand)
}
if (response.ok === false) {
throw new RuntimeRpcFailureError(response)
throw attachMutationRecovery(
new RuntimeRpcFailureError(response),
orchestrationRequestId,
originalCommand
)
}
if (this.environmentSelector) {
markEnvironmentUsed(this.userDataPath, this.environmentSelector, {
@@ -124,10 +148,14 @@ export class RuntimeClient {
try {
response = await sendRequest<TResult>(metadata, method, params, effectiveTimeoutMs, envelope)
} catch (error) {
throw attachMutationRecovery(error, orchestrationRequestId)
throw attachMutationRecovery(error, orchestrationRequestId, originalCommand)
}
if (response.ok === false) {
throw new RuntimeRpcFailureError(response)
throw attachMutationRecovery(
new RuntimeRpcFailureError(response),
orchestrationRequestId,
originalCommand
)
}
return response
}
@@ -138,6 +166,18 @@ export class RuntimeClient {
// to resolve. Without this, a 5 min wait would still die at the 60 s default.
// See design doc §3.1.
private resolveMethodTimeoutMs(method: string, params?: unknown): number {
if (method === 'orchestration.workerStart') {
const requestedValue = getTimeoutMsParam(params)
const requested = typeof requestedValue === 'number' ? requestedValue : Number(requestedValue)
if (!isWorkerStartTimeoutWithinTimerLimit(requested)) {
throw new RuntimeClientError(
'invalid_argument',
`--timeout-ms is too large for worker-start transport grace; the derived timeout must be <= ${MAX_TIMER_DELAY_MS}ms.`
)
}
const readiness = resolveWorkerStartReadinessTimeoutMs(requested)
return Math.max(resolveWorkerStartClientTimeoutMs(readiness), this.requestTimeoutMs)
}
if (
(method === 'orchestration.check' && isWaitingCheck(params)) ||
method === 'terminal.wait'
@@ -241,20 +281,6 @@ export class RuntimeClient {
}
}
function attachMutationRecovery(error: unknown, requestId: string | undefined): unknown {
if (!requestId || !(error instanceof RuntimeClientError)) {
return error
}
return new RuntimeClientError(
error.code,
`${error.message} Orchestration mutation request ID: ${requestId}.`,
{
...(error.data && typeof error.data === 'object' ? error.data : {}),
orchestrationRequestId: requestId
}
)
}
function throwDesktopActivationBlocked(): never {
throw new RuntimeClientError(
'desktop_activation_blocked',
@@ -0,0 +1,133 @@
import { describe, expect, it } from 'vitest'
import {
buildOrchestrationRecoveryCommand,
resolveOrchestrationCliExecutable
} from './orchestration-recovery-command'
describe('orchestration recovery command identity', () => {
it.each([
['configured dev', { ORCA_CLI_COMMAND: 'orca-dev' }, 'darwin', 'orca-dev'],
['configured WSL', { ORCA_CLI_COMMAND: 'orca-ide' }, 'linux', 'orca-ide'],
['dev checkout', { ORCA_DEV_REPO_ROOT: '/repo' }, 'darwin', 'orca-dev'],
['packaged Linux', {}, 'linux', 'orca-ide'],
['local macOS', {}, 'darwin', 'orca'],
['local Windows', {}, 'win32', 'orca']
] as const)('resolves the %s CLI identity', (_name, env, platform, expected) => {
expect(resolveOrchestrationCliExecutable(env, platform)).toBe(expected)
})
it('reconstructs the keyed worker-start command from its RPC params', () => {
expect(
buildOrchestrationRecoveryCommand(
'orchestration.workerStart',
{
task: 'task_1',
on: 'windows',
worktree: 'new-top-level',
timeoutMs: 90_000,
devMode: false
},
'orca'
)
).toEqual([
'orca',
'orchestration',
'worker-start',
'--task',
'task_1',
'--on',
'windows',
'--worktree',
'new-top-level',
'--timeout-ms',
'90000'
])
})
it('preserves the selected executable and raw command arguments', () => {
expect(
buildOrchestrationRecoveryCommand(
'orchestration.workerStart',
{ task: 'task_1' },
'orca-dev',
[
'orchestration',
'worker-start',
'--task',
'task_1',
'--comment',
'literal $(do-not-run) "quoted"',
'--json'
]
)
).toEqual([
'orca-dev',
'orchestration',
'worker-start',
'--task',
'task_1',
'--comment',
'literal $(do-not-run) "quoted"',
'--json'
])
})
it.each([
[
'gate-create',
'orchestration.gateCreate',
['orchestration', 'gate-create', '--task', 'task_1', '--question', 'ship?']
],
[
'worker-retain',
'orchestration.workerRetain',
['orchestration', 'worker-retain', '--dispatch', 'dispatch_1', '--json']
]
] as const)('preserves exact raw argv for %s recovery', (_name, method, args) => {
expect(buildOrchestrationRecoveryCommand(method, {}, 'orca', args)).toEqual(['orca', ...args])
})
it.each([
['split', ['orchestration', 'send', '--pairing-code', 'secret', '--subject', 'status']],
['equals', ['orchestration', 'send', '--pairing-code=secret', '--subject', 'status']],
[
'dispatch split',
['orchestration', 'send', '--dispatch-capability', 'secret', '--subject', 'status']
],
[
'dispatch equals',
['orchestration', 'send', '--dispatch-capability=secret', '--subject', 'status']
]
])('blocks %s credential argv instead of exposing it', (_name, args) => {
expect(
buildOrchestrationRecoveryCommand('orchestration.send', {}, 'orca', args)
).toBeUndefined()
})
it('supports the explicit executable-first form', () => {
expect(
buildOrchestrationRecoveryCommand('orca-ide', 'orchestration.workerStop', {
dispatch: 'dispatch_1'
})
).toEqual(['orca-ide', 'orchestration', 'worker-stop', '--dispatch', 'dispatch_1'])
})
it('reconstructs worker-retain when raw argv is unavailable', () => {
expect(
buildOrchestrationRecoveryCommand('orchestration.workerRetain', {
dispatch: 'dispatch_1'
})
).toEqual([
resolveOrchestrationCliExecutable(),
'orchestration',
'worker-retain',
'--dispatch',
'dispatch_1'
])
})
it('does not fabricate identity for unsupported mutations or malformed params', () => {
expect(buildOrchestrationRecoveryCommand('orchestration.send', {})).toBeUndefined()
expect(buildOrchestrationRecoveryCommand('orchestration.workerStart', null)).toBeUndefined()
})
})
@@ -0,0 +1,102 @@
export function resolveOrchestrationCliExecutable(
env: NodeJS.ProcessEnv = process.env,
platform: NodeJS.Platform = process.platform
): string {
const configured = env.ORCA_CLI_COMMAND?.trim()
if (configured) {
return configured
}
if (env.ORCA_DEV_REPO_ROOT) {
return 'orca-dev'
}
return platform === 'linux' ? 'orca-ide' : 'orca'
}
export function buildOrchestrationRecoveryCommand(
method: string,
params: unknown,
executable?: string,
originalArgs?: readonly string[]
): string[] | undefined
export function buildOrchestrationRecoveryCommand(
executable: string,
method: string,
params: unknown,
originalArgs?: readonly string[]
): string[] | undefined
export function buildOrchestrationRecoveryCommand(
methodOrExecutable: string,
paramsOrMethod: unknown,
executableOrParams?: unknown,
originalArgs?: readonly string[]
): string[] | undefined {
const legacyOrder = typeof paramsOrMethod === 'string' && executableOrParams !== undefined
const method = (legacyOrder ? paramsOrMethod : methodOrExecutable) as string
const params = legacyOrder ? executableOrParams : paramsOrMethod
const executable = legacyOrder
? methodOrExecutable
: typeof executableOrParams === 'string'
? executableOrParams
: resolveOrchestrationCliExecutable()
if (originalArgs && originalArgs.length > 0) {
const recoverableArgs = recoverableOrchestrationArgs(originalArgs)
return recoverableArgs ? [executable, ...recoverableArgs] : undefined
}
const command =
method === 'orchestration.workerStart'
? 'worker-start'
: method === 'orchestration.workerStop'
? 'worker-stop'
: method === 'orchestration.workerAbandon'
? 'worker-abandon'
: method === 'orchestration.workerRelease'
? 'worker-release'
: method === 'orchestration.workerRetain'
? 'worker-retain'
: undefined
if (!command || params === null || typeof params !== 'object') {
return undefined
}
const record = params as Record<string, unknown>
const requiredKey = command === 'worker-start' ? 'task' : 'dispatch'
if (typeof record[requiredKey] !== 'string' || record[requiredKey].length === 0) {
return undefined
}
const args = [executable, 'orchestration', command]
for (const [key, value] of Object.entries(record)) {
if (value === undefined || value === null || value === false) {
continue
}
const flag = `--${key.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`)}`
if (value === true) {
args.push(flag)
} else if (typeof value === 'string' || typeof value === 'number') {
args.push(flag, String(value))
}
}
return args
}
export function recoverableOrchestrationArgs(args: readonly string[]): string[] | undefined {
const containsCredential = args.some((arg) =>
['--pairing-code', '--dispatch-capability'].some(
(flag) => arg === flag || arg.startsWith(`${flag}=`)
)
)
if (containsCredential) {
return undefined
}
const result: string[] = []
for (let index = 0; index < args.length; index += 1) {
const arg = args[index]
if (arg === '--retry-request') {
index += 1
continue
}
if (arg.startsWith('--retry-request=')) {
continue
}
result.push(arg)
}
return result
}
@@ -0,0 +1,101 @@
import { describe, expect, it, vi } from 'vitest'
import { RemoteRuntimeClientError } from '../../shared/remote-runtime-client-error'
import { orchestrationMutationRecoveryError } from '../orchestration-mutation-recovery'
const { sendRemoteRuntimeRequest, sendRemoteRuntimeRequestWithStatusPreflight } = vi.hoisted(
() => ({
sendRemoteRuntimeRequest: vi.fn(),
sendRemoteRuntimeRequestWithStatusPreflight: vi.fn()
})
)
vi.mock('../../shared/remote-runtime-client', () => ({
RemoteRuntimeClientError,
sendRemoteRuntimeRequest,
sendRemoteRuntimeRequestWithStatusPreflight
}))
import {
sendWebSocketRequest,
sendWebSocketRequestWithStatusPreflight
} from './websocket-transport'
const pairing = {
v: 2,
endpoint: 'ws://127.0.0.1:1',
deviceToken: 'token',
publicKeyB64: 'key'
} as never
describe('CLI WebSocket transport error identity', () => {
it('preserves recovery data from one-shot transport errors', async () => {
sendRemoteRuntimeRequest.mockRejectedValueOnce(
new RemoteRuntimeClientError('runtime_timeout', 'timed out', {
data: {
orchestrationRequestId: 'request_1',
dispatchId: 'dispatch_1',
originalCommand: ['orca', 'orchestration', 'worker-start', '--task', 'task_1']
}
})
)
try {
await sendWebSocketRequest(pairing, 'orchestration.workerStart', {}, 100)
throw new Error('expected transport failure')
} catch (error) {
expect(error).toMatchObject({
code: 'runtime_timeout',
data: {
orchestrationRequestId: 'request_1',
dispatchId: 'dispatch_1',
originalCommand: ['orca', 'orchestration', 'worker-start', '--task', 'task_1']
}
})
expect(orchestrationMutationRecoveryError(error)).toMatchObject({
data: {
recovery: {
dispatchId: 'dispatch_1',
queryCommand: [
'orca',
'orchestration',
'worker-show',
'--dispatch',
'dispatch_1',
'--json'
],
retryCommand: [
'orca',
'orchestration',
'worker-start',
'--task',
'task_1',
'--retry-request',
'request_1'
]
}
}
})
}
})
it('preserves recovery data through status-preflight transport errors', async () => {
sendRemoteRuntimeRequestWithStatusPreflight.mockRejectedValueOnce(
new RemoteRuntimeClientError('runtime_timeout', 'timed out', {
data: { orchestrationRequestId: 'request_2' }
})
)
await expect(
sendWebSocketRequestWithStatusPreflight(
pairing,
'orchestration.workerStart',
{},
100,
() => {}
)
).rejects.toMatchObject({
code: 'runtime_timeout',
data: { orchestrationRequestId: 'request_2' }
})
})
})
+2 -2
View File
@@ -19,7 +19,7 @@ export async function sendWebSocketRequest<TResult>(
return await sendRemoteRuntimeRequest<TResult>(pairing, method, params, timeoutMs, envelope)
} catch (error) {
if (error instanceof RemoteRuntimeClientError) {
throw new RuntimeClientError(error.code, error.message)
throw new RuntimeClientError(error.code, error.message, error.data)
}
throw error
}
@@ -44,7 +44,7 @@ export async function sendWebSocketRequestWithStatusPreflight<TResult>(
)
} catch (error) {
if (error instanceof RemoteRuntimeClientError) {
throw new RuntimeClientError(error.code, error.message)
throw new RuntimeClientError(error.code, error.message, error.data)
}
throw error
}
+1 -1
View File
@@ -39,7 +39,7 @@ function assertLocalCwdWorktreeSelector(selector: string, client: RuntimeClient)
// server, so cwd-derived worktree selectors are only valid locally.
throw new RuntimeClientError(
'invalid_argument',
`${selector} is a local cwd shortcut and cannot be resolved against a remote runtime. Pass an explicit server-side worktree selector such as id:<repo-id>::<path>, name:<displayName>, branch:<branch>, issue:<number>, or path:<absolute-server-path>.`
`${selector} is a local cwd shortcut and cannot be resolved against a remote runtime. Pass an explicit server-side worktree selector such as identity:<identity>, id:<repo-id>::<path>, name:<displayName>, branch:<branch>, issue:<number>, or path:<absolute-server-path>.`
)
}
+1
View File
@@ -208,6 +208,7 @@ export const CORE_COMMAND_SPECS: CommandSpec[] = [
'By default this returns accumulated terminal output with escape sequences stripped, not the rendered screen. Any program that repaints a line — shells, progress bars, TUIs — comes back as stacked fragments, so one `clear` keystroke by keystroke reads as `cclclecleaclear`, and spaces a prompt draws by moving the cursor are absent.',
'Use --screen to read what the terminal actually renders. Prefer it whenever the answer depends on how output looks rather than what was emitted over time; the default is unsuitable for verifying rendered output.',
'The result reports source: stream when it is accumulated output, screen when it is the rendered screen, and screen-unavailable when a screen was asked for but none could be rendered and the accumulated output is being returned instead. An absent source means the host predates the field.',
'When present, draft is UI-only composer text excluded from tail; never treat it as terminal output or a submitted instruction.',
'--screen and --cursor are mutually exclusive: a screen read is the current frame and has no history to page.',
'Use --cursor with the nextCursor value from a previous read to get only new output since that read.',
'Use --limit to request more retained lines for long agent responses; output reports oldestCursor when older lines were dropped.',
+21
View File
@@ -0,0 +1,21 @@
import { describe, expect, it } from 'vitest'
import { formatTerminalRead } from './terminal-format'
describe('formatTerminalRead draft separation', () => {
it('labels composer drafts outside terminal output', () => {
const output = formatTerminalRead({
terminal: {
handle: 'term_1',
status: 'running',
tail: ['Build passed', '❯'],
truncated: false,
nextCursor: null,
source: 'screen',
draft: 'proceed with the release'
}
})
expect(output).toContain('draft: "proceed with the release"')
expect(output).toContain('\n\nBuild passed\n❯')
})
})
+1
View File
@@ -141,6 +141,7 @@ export function formatTerminalRead(result: { terminal: RuntimeTerminalRead }): s
`handle: ${terminal.handle}`,
`status: ${terminal.status}`,
...(terminal.source ? [`source: ${terminal.source}`] : []),
...(terminal.draft ? [`draft: ${JSON.stringify(terminal.draft)}`] : []),
...(terminal.nextCursor !== null ? [`cursor: ${terminal.nextCursor}`] : []),
...oldestCursor,
...latestCursor,
@@ -72,7 +72,7 @@ fi
# Why || : twice — zsh alone aborts inside the substitution, but every shell's
# assignment adopts its exit status, so an absent codex trips set -e in bash too.
__orca_codex_binary="$(unalias codex 2>/dev/null || :; command -v codex 2>/dev/null || :)"
if [[ -n "${ORCA_CODEX_LAUNCH_PREFLIGHT:-}" && -n "${__orca_codex_binary:-}" && -x "${__orca_codex_binary}" ]]; then
if [[ -n "${ORCA_CODEX_LAUNCH_PREFLIGHT:-}" && -x "${ORCA_CODEX_LAUNCH_PREFLIGHT}" && -n "${__orca_codex_binary:-}" && -x "${__orca_codex_binary}" ]]; then
# Why the function reserved word: it suppresses alias expansion of the name,
# which otherwise rewrites this header at parse time and aborts the whole file.
function codex {
@@ -2,7 +2,7 @@ function __orca_shell_ready_marker --on-event fish_prompt
builtin printf "\033]777;orca-shell-ready\007"
functions -e __orca_shell_ready_marker
end
if test -n "$ORCA_CODEX_LAUNCH_PREFLIGHT"; and test (type -t codex 2>/dev/null) = file
if test -x "$ORCA_CODEX_LAUNCH_PREFLIGHT"; and test (type -t codex 2>/dev/null) = file
function codex
command "$ORCA_CODEX_LAUNCH_PREFLIGHT" agent hooks prepare-codex >/dev/null 2>&1; or true
command codex $argv
@@ -110,7 +110,7 @@ __orca_deferred_init() {
# Why || : twice — zsh alone aborts inside the substitution, but every shell's
# assignment adopts its exit status, so an absent codex trips set -e in bash too.
__orca_codex_binary="$(unalias codex 2>/dev/null || :; command -v codex 2>/dev/null || :)"
if [[ -n "${ORCA_CODEX_LAUNCH_PREFLIGHT:-}" && -n "${__orca_codex_binary:-}" && -x "${__orca_codex_binary}" ]]; then
if [[ -n "${ORCA_CODEX_LAUNCH_PREFLIGHT:-}" && -x "${ORCA_CODEX_LAUNCH_PREFLIGHT}" && -n "${__orca_codex_binary:-}" && -x "${__orca_codex_binary}" ]]; then
# Why the function reserved word: it suppresses alias expansion of the name,
# which otherwise rewrites this header at parse time and aborts the whole file.
function codex {
@@ -75,7 +75,7 @@ fi
# Why || : twice — zsh alone aborts inside the substitution, but every shell's
# assignment adopts its exit status, so an absent codex trips set -e in bash too.
__orca_codex_binary="$(unalias codex 2>/dev/null || :; command -v codex 2>/dev/null || :)"
if [[ -n "${ORCA_CODEX_LAUNCH_PREFLIGHT:-}" && -n "${__orca_codex_binary:-}" && -x "${__orca_codex_binary}" ]]; then
if [[ -n "${ORCA_CODEX_LAUNCH_PREFLIGHT:-}" && -x "${ORCA_CODEX_LAUNCH_PREFLIGHT}" && -n "${__orca_codex_binary:-}" && -x "${__orca_codex_binary}" ]]; then
# Why the function reserved word: it suppresses alias expansion of the name,
# which otherwise rewrites this header at parse time and aborts the whole file.
function codex {
@@ -2,7 +2,7 @@ function __orca_shell_ready_marker --on-event fish_prompt
builtin printf "\033]777;orca-shell-ready\007"
functions -e __orca_shell_ready_marker
end
if test -n "$ORCA_CODEX_LAUNCH_PREFLIGHT"; and test (type -t codex 2>/dev/null) = file
if test -x "$ORCA_CODEX_LAUNCH_PREFLIGHT"; and test (type -t codex 2>/dev/null) = file
function codex
command "$ORCA_CODEX_LAUNCH_PREFLIGHT" agent hooks prepare-codex >/dev/null 2>&1; or true
command codex $argv
@@ -110,7 +110,7 @@ __orca_deferred_init() {
# Why || : twice — zsh alone aborts inside the substitution, but every shell's
# assignment adopts its exit status, so an absent codex trips set -e in bash too.
__orca_codex_binary="$(unalias codex 2>/dev/null || :; command -v codex 2>/dev/null || :)"
if [[ -n "${ORCA_CODEX_LAUNCH_PREFLIGHT:-}" && -n "${__orca_codex_binary:-}" && -x "${__orca_codex_binary}" ]]; then
if [[ -n "${ORCA_CODEX_LAUNCH_PREFLIGHT:-}" && -x "${ORCA_CODEX_LAUNCH_PREFLIGHT}" && -n "${__orca_codex_binary:-}" && -x "${__orca_codex_binary}" ]]; then
# Why the function reserved word: it suppresses alias expansion of the name,
# which otherwise rewrites this header at parse time and aborts the whole file.
function codex {
+37
View File
@@ -0,0 +1,37 @@
import type { AgentHookSource } from '../../shared/agent-hook-relay'
import { ORCA_HOOK_RAW_JSON_TRANSPORT } from '../../shared/agent-hook-types'
export function buildPosixAgentHookPostCommand(
source: AgentHookSource,
options: { curlCommand?: string; indent?: string } = {}
): string[] {
const curlCommand = options.curlCommand ?? 'curl'
const indent = options.indent ?? ' '
return [
`if [ "\${ORCA_AGENT_HOOK_TRANSPORT:-}" = "${ORCA_HOOK_RAW_JSON_TRANSPORT}" ] && command -v base64 >/dev/null 2>&1 && command -v tr >/dev/null 2>&1; then`,
` orca_hook_metadata=$(printf '%s\\037%s\\037%s\\037%s\\037%s\\037%s' "$ORCA_PANE_KEY" "$ORCA_TAB_ID" "$ORCA_AGENT_LAUNCH_TOKEN" "$ORCA_WORKTREE_ID" "$ORCA_AGENT_HOOK_ENV" "$ORCA_AGENT_HOOK_VERSION" | base64 | tr -d '\\n') && \\`,
` [ -n "$orca_hook_metadata" ] && \\`,
` printf '%s' "$payload" | ${curlCommand} -sS -X POST "http://127.0.0.1:\${ORCA_AGENT_HOOK_PORT}/hook/${source}" \\`,
` ${indent}--connect-timeout "\${connect_timeout:-0.5}" --max-time "\${max_time:-1.5}" \\`,
` ${indent}--noproxy "127.0.0.1" \\`,
` ${indent}-H "Content-Type: application/json" \\`,
` ${indent}-H "X-Orca-Agent-Hook-Token: \${ORCA_AGENT_HOOK_TOKEN}" \\`,
` ${indent}-H "X-Orca-Agent-Hook-Meta-Encoding: base64" \\`,
` ${indent}-H "X-Orca-Agent-Hook-Meta: \${orca_hook_metadata}" \\`,
` ${indent}--data-binary @-`,
'else',
` printf '%s' "$payload" | ${curlCommand} -sS -X POST "http://127.0.0.1:\${ORCA_AGENT_HOOK_PORT}/hook/${source}" \\`,
` ${indent}--connect-timeout "\${connect_timeout:-0.5}" --max-time "\${max_time:-1.5}" \\`,
` ${indent}--noproxy "127.0.0.1" \\`,
` ${indent}-H "Content-Type: application/x-www-form-urlencoded" \\`,
` ${indent}-H "X-Orca-Agent-Hook-Token: \${ORCA_AGENT_HOOK_TOKEN}" \\`,
` ${indent}--data-urlencode "paneKey=\${ORCA_PANE_KEY}" \\`,
` ${indent}--data-urlencode "tabId=\${ORCA_TAB_ID}" \\`,
` ${indent}--data-urlencode "launchToken=\${ORCA_AGENT_LAUNCH_TOKEN}" \\`,
` ${indent}--data-urlencode "worktreeId=\${ORCA_WORKTREE_ID}" \\`,
` ${indent}--data-urlencode "env=\${ORCA_AGENT_HOOK_ENV}" \\`,
` ${indent}--data-urlencode "version=\${ORCA_AGENT_HOOK_VERSION}" \\`,
` ${indent}--data-urlencode "payload@-"`,
'fi'
]
}
@@ -30,6 +30,7 @@ import {
writeHooksJson,
type HooksConfig
} from './installer-utils'
import { buildPosixAgentHookPostCommand } from './hook-post-command'
import { POSIX_HOOK_STDIN_DRAIN_COMMAND } from './hook-stdin-contract'
import { wrapRuntimeHomeHookCommand } from './runtime-home-hook-command'
@@ -882,6 +883,26 @@ describe('buildWindowsAgentHookPostCommand', () => {
})
})
describe('buildPosixAgentHookPostCommand', () => {
it('uses raw JSON only when the listener advertises support', () => {
const command = buildPosixAgentHookPostCommand('claude').join('\n')
expect(command).toContain('ORCA_AGENT_HOOK_TRANSPORT:-}')
expect(command).toContain('raw-json-v1')
expect(command).toContain('command -v base64')
expect(command).toContain('command -v tr')
expect(command).toContain('Content-Type: application/json')
expect(command).toContain('X-Orca-Agent-Hook-Meta-Encoding: base64')
expect(command).toContain('X-Orca-Agent-Hook-Meta: ${orca_hook_metadata}')
expect(command).toContain("printf '%s\\037%s\\037%s\\037%s\\037%s\\037%s'")
expect(command).toContain('$ORCA_PANE_KEY')
expect(command).toContain('$ORCA_WORKTREE_ID')
expect(command).toContain('--data-binary @-')
expect(command).toContain('Content-Type: application/x-www-form-urlencoded')
expect(command).toContain('--data-urlencode "payload@-"')
})
})
describe('buildWindowsAgentHookCurlPostCommand', () => {
it('posts form fields via curl.exe and reads the payload from stdin', () => {
const command = buildWindowsAgentHookCurlPostCommand('codex')
@@ -284,7 +284,7 @@ describe('remote hook service installers', () => {
hooks: Record<string, { hooks: { command: string }[] }[]>
}
expect(hooks.hooks.Stop?.[0]?.hooks?.[0]?.command).toContain(
'/home/dev/.orca/agent-hooks/codex-hook.sh'
'/home/dev/.local/share/orca/codex-runtime-home/home/.orca/agent-hooks/codex-hook.sh'
)
expect(fs.files.get(`${runtimeHome}/config.toml`)).toContain(
`${runtimeHome}/hooks.json:stop:0:0`
@@ -66,6 +66,7 @@ describe('Endpoint file lifecycle', () => {
expect(contents).toContain(`${prefix}ORCA_AGENT_HOOK_TOKEN=${expectedToken}`)
expect(contents).toContain(`${prefix}ORCA_AGENT_HOOK_ENV=development`)
expect(contents).toContain(`${prefix}ORCA_AGENT_HOOK_VERSION=1`)
expect(contents).toContain(`${prefix}ORCA_AGENT_HOOK_TRANSPORT=raw-json-v1`)
} finally {
server.stop()
}
@@ -44,6 +44,38 @@ async function postClaudeHook(
}
describe('AgentHookServer listener replay', () => {
it('accepts raw JSON hook bodies with base64 metadata headers', async () => {
const server = new AgentHookServer()
await server.start({ env: 'production' })
try {
const env = server.buildPtyEnv()
const response = await fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/claude`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN,
'X-Orca-Agent-Hook-Meta-Encoding': 'base64',
'X-Orca-Agent-Hook-Meta': Buffer.from(
[PANE, 'tab-1', '', 'wt-1', 'production', ''].join('\x1f')
).toString('base64')
},
body: JSON.stringify({ hook_event_name: 'UserPromptSubmit', prompt: 'raw JSON' })
})
expect(response.status).toBe(204)
expect(server.getStatusSnapshot()).toEqual([
expect.objectContaining({
paneKey: PANE,
worktreeId: 'wt-1',
state: 'working',
prompt: 'raw JSON'
})
])
} finally {
server.stop()
}
})
it('caches and notifies status/main/plugin before retry scheduling and HTTP response', async () => {
const server = new AgentHookServer()
await server.start({ env: 'production' })
+12 -5
View File
@@ -8,7 +8,10 @@ import { join } from 'node:path'
import { track } from '../telemetry/client'
import { getCohortAtEmit } from '../telemetry/cohort-classifier'
import { AGENT_KIND_VALUES, type AgentKind } from '../../shared/telemetry-events'
import { ORCA_HOOK_PROTOCOL_VERSION } from '../../shared/agent-hook-types'
import {
ORCA_HOOK_PROTOCOL_VERSION,
ORCA_HOOK_RAW_JSON_TRANSPORT
} from '../../shared/agent-hook-types'
import {
clearAllListenerCaches,
clearPaneCacheState,
@@ -46,6 +49,7 @@ import {
} from '../../shared/agent-hook-listener/listener-limits'
import { isNewTurnEvent } from '../../shared/agent-hook-listener/provider-event-routing'
import { normalizeHookPayload } from '../../shared/agent-hook-listener'
import { mergeAgentHookRequestHeaders } from '../../shared/agent-hook-listener/hook-envelope'
import {
parseFormEncodedBody,
readRequestBody
@@ -2616,8 +2620,9 @@ export class AgentHookServer {
return
}
trackEmptyPaneKeyHook(body)
const aliasedBody = this.normalizeHookBodyPaneKeyAlias(body)
const hookBody = mergeAgentHookRequestHeaders(body, req.headers)
trackEmptyPaneKeyHook(hookBody)
const aliasedBody = this.normalizeHookBodyPaneKeyAlias(hookBody)
const normalized = this.normalizeLocalHookPayload(source, aliasedBody)
const statusDisposition = normalized.event
? this.getAgentStatusDisposition(normalized.event.paneKey, {
@@ -3129,7 +3134,8 @@ export class AgentHookServer {
ORCA_AGENT_HOOK_PORT: String(this.port),
ORCA_AGENT_HOOK_TOKEN: this.token,
ORCA_AGENT_HOOK_ENV: this.env,
ORCA_AGENT_HOOK_VERSION: ORCA_HOOK_PROTOCOL_VERSION
ORCA_AGENT_HOOK_VERSION: ORCA_HOOK_PROTOCOL_VERSION,
ORCA_AGENT_HOOK_TRANSPORT: ORCA_HOOK_RAW_JSON_TRANSPORT
}
// Why: hooks source this file at invocation; dev namespaces it so parallel `pnpm dev` runs don't steal each other's hooks.
if (this.endpointFileWritten && this.endpointFilePathCache) {
@@ -3156,7 +3162,8 @@ export class AgentHookServer {
port: this.port,
token: this.token,
env: this.env,
version: ORCA_HOOK_PROTOCOL_VERSION
version: ORCA_HOOK_PROTOCOL_VERSION,
transport: ORCA_HOOK_RAW_JSON_TRANSPORT
})
this.endpointFileWritten = ok
}
+24 -7
View File
@@ -12,19 +12,23 @@ import {
type ManagedHookDetectionSettings
} from './managed-hook-detection-commands'
import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer'
import { wslCodexRuntimeHomeForGuestHome } from '../pty/codex-home-wsl-env'
import { WSL_HOOK_FS_METHODS, type WslFsResult } from '../../shared/wsl-hook-relay-contract'
import type { AgentHookInstallStatus } from '../../shared/agent-hook-types'
/** Run the shared remote hook installers against a WSL guest over the relay's fs bridge. */
export async function installWslGuestHooks(options: {
mux: SshChannelMultiplexer
guestHome: string
codexHomePath: string | null
distro: string
installHooks: typeof installRemoteManagedAgentHooks
settings: ManagedHookDetectionSettings
warn: (message: string) => void
/** Canonical runtime-host installer for redirected Codex homes. */
installCodex: (runtimeHomePath: string, distro: string) => Promise<AgentHookInstallStatus | null>
}): Promise<void> {
const { mux, guestHome, distro, installHooks, settings, warn } = options
const { mux, guestHome, codexHomePath, distro, installHooks, settings, warn, installCodex } =
options
let agents
try {
const detected = (await mux.request('preflight.detectAgents', {
@@ -42,12 +46,25 @@ export async function installWslGuestHooks(options: {
if (agents.length === 0) {
return
}
if (agents.includes('codex') && codexHomePath) {
try {
const status = await installCodex(codexHomePath, distro)
if (status?.state === 'error') {
warn(
`[agent-hooks] WSL Codex hook install for '${distro}' failed: ${status.detail ?? 'unknown error'}`
)
}
} catch (error) {
warn(
`[agent-hooks] WSL Codex hook install for '${distro}' failed: ${error instanceof Error ? error.message : String(error)}`
)
}
}
// Codex is redirected into the runtime home and must use the canonical
// runtime-host writer above; the relay adapter owns all other agents.
const remoteAgents = agents.filter((agent) => agent !== 'codex')
const results = await installHooks(createWslHookSftpAdapter(mux), guestHome, {
agents,
// WSL Codex launches use Orca's managed runtime CODEX_HOME, not ~/.codex.
// Keep relay hooks in that active home so status callbacks are received.
codexHomeDir: wslCodexRuntimeHomeForGuestHome(guestHome),
deferTrustUntilConfigToml: true
agents: remoteAgents
})
const failed = results.filter((r) => r.state === 'error').length
if (failed > 0) {
@@ -9,6 +9,8 @@ import { agentHookServer } from './server'
import type { ManagedHookDetectionSettings } from './managed-hook-detection-commands'
import { installRemoteManagedAgentHooks } from './remote-managed-hook-installers'
import { getOpenCodePluginSource } from '../opencode/hook-service'
import { codexHookService } from '../codex/hook-service'
import type { AgentHookInstallStatus } from '../../shared/agent-hook-types'
import type { PluginSources } from '../../relay/plugin-overlay'
import {
isWslDistroRunning,
@@ -61,6 +63,7 @@ export type WslHookRelayManagerDeps = {
waitForSentinel: typeof waitForWslRelaySentinel
ingest: (envelope: Record<string, unknown>, connectionId: string) => void
installHooks: typeof installRemoteManagedAgentHooks
installCodex: (runtimeHomePath: string, distro: string) => Promise<AgentHookInstallStatus | null>
managedHookSettings: () => ManagedHookDetectionSettings
/** Plugin source strings shipped to the guest relay so an Orca update needn't redeploy the relay bundle. */
pluginSources: () => PluginSources
@@ -102,6 +105,11 @@ export const defaultWslHookRelayDeps: WslHookRelayManagerDeps = {
connectionId
),
installHooks: installRemoteManagedAgentHooks,
installCodex: (runtimeHomePath, distro) =>
codexHookService.installForRuntimeHomeSerialized(runtimeHomePath, {
runtime: 'wsl',
wslDistro: distro
}),
managedHookSettings: () => null,
// Why: only OpenCode is in scope for WSL now; the payload shape stays identical to SSH so Pi/OMP are additive later.
pluginSources: () => ({ opencodePluginSource: getOpenCodePluginSource() }),
@@ -6,13 +6,14 @@ import type { ManagedHookDetectionSettings } from './managed-hook-detection-comm
import type { installRemoteManagedAgentHooks } from './remote-managed-hook-installers'
import { requestGuestOpenCodeOverlayDir } from './wsl-guest-plugin-install'
import { installWslGuestHooks } from './wsl-hook-fs-adapter'
import { REINSTALL_MIN_INTERVAL_MS } from './wsl-hook-relay-deps'
import { REINSTALL_MIN_INTERVAL_MS, type WslHookRelayManagerDeps } from './wsl-hook-relay-deps'
import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer'
import type { PluginSources } from '../../relay/plugin-overlay'
/** Structural slice of WslHookRelayManagerDeps — only what an install pass uses. */
type GuestInstallDeps = {
installHooks: typeof installRemoteManagedAgentHooks
installCodex: WslHookRelayManagerDeps['installCodex']
managedHookSettings: () => ManagedHookDetectionSettings
pluginSources: () => PluginSources
warn: (message: string) => void
@@ -23,6 +24,7 @@ type GuestInstallState = {
distro: string
mux?: SshChannelMultiplexer
guestHome?: string
codexHomePath?: string
opencodeOverlayDir?: string
lastInstallAt?: number
}
@@ -37,8 +39,10 @@ export async function runWslRelayGuestInstall(
await installWslGuestHooks({
mux,
guestHome,
codexHomePath: state.codexHomePath ?? null,
distro: state.distro,
installHooks: deps.installHooks,
installCodex: deps.installCodex,
settings: deps.managedHookSettings(),
warn: deps.warn
})
@@ -13,7 +13,8 @@ import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
import { AgentHookServer } from './server'
import { WslHookRelayManager } from './wsl-hook-relay-manager'
import { wslCodexRuntimeHomeForGuestHome } from '../pty/codex-home-wsl-env'
import { createManagedHookLocalFilesystem } from './managed-hook-local-filesystem'
import { codexHookService } from '../codex/hook-service'
const BUNDLE_DIR = join(process.cwd(), 'out', 'relay', 'wsl')
const BUNDLE_JS = join(BUNDLE_DIR, 'wsl-agent-hook-relay.js')
@@ -75,6 +76,7 @@ describe.skipIf(process.platform === 'win32')(
const server = orcaServer
const warns: string[] = []
const codexHome = join(fakeHome, '.local', 'share', 'orca', 'codex-runtime-home', 'home')
manager = new WslHookRelayManager({
platform: () => 'win32',
remoteHooksEnabled: () => true,
@@ -102,6 +104,11 @@ describe.skipIf(process.platform === 'win32')(
envelope as Parameters<AgentHookServer['ingestRemote']>[0],
connectionId
),
installCodex: (runtimeHomePath) =>
codexHookService.installRemote(createManagedHookLocalFilesystem(), fakeHome, {
codexHomeDir: runtimeHomePath,
deferTrustUntilConfigToml: true
}),
managedHookSettings: () => ({
agentCmdOverrides: {
claude: process.execPath,
@@ -112,15 +119,17 @@ describe.skipIf(process.platform === 'win32')(
transientRetryDelayMs: 1
})
manager.ensureForDistro('LiveDistro')
manager.ensureForDistro('LiveDistro', codexHome)
// Waiting on Codex's artifact (not Claude's, which is written first) keeps the
// assertions behind the still-running 14-agent installer loop.
const codexHome = wslCodexRuntimeHomeForGuestHome(fakeHome)
await vi.waitFor(() => expect(existsSync(join(codexHome, 'hooks.json'))).toBe(true), {
timeout: 15_000
})
expect(existsSync(join(fakeHome, '.claude', 'settings.json'))).toBe(true)
await vi.waitFor(
() => expect(existsSync(join(fakeHome, '.claude', 'settings.json'))).toBe(true),
{ timeout: 15_000 }
)
const claudeScript = readFileSync(
join(fakeHome, '.orca', 'agent-hooks', 'claude-hook.sh'),
'utf8'
@@ -147,6 +147,8 @@ describe('WslHookRelayManager', () => {
// hosts — installHooks is mocked here, so the fs bridge only ever serves
// the wslfs.home request and never touches the real filesystem.
const home = '/home/wsl-test-user'
const codexHome =
'\\\\wsl.localhost\\Ubuntu\\home\\wsl-test-user\\.local\\share\\orca\\codex-runtime-home\\home'
const opencodeOverlayDir = `${home}/.orca-relay/opencode-overlays/deadbeefcafe`
let harnesses: GuestHarness[]
@@ -229,6 +231,13 @@ describe('WslHookRelayManager', () => {
waitForSentinel: vi.fn(async () => guestTransport()),
ingest: vi.fn(),
installHooks: vi.fn(async () => []),
installCodex: vi.fn(async () => ({
agent: 'codex' as const,
state: 'installed' as const,
configPath: `${home}/.local/share/orca/codex-runtime-home/home/hooks.json`,
managedHooksPresent: true,
detail: null
})),
managedHookSettings: () => null,
pluginSources: () => ({ opencodePluginSource: '// opencode plugin source' }),
warn: vi.fn(),
@@ -240,14 +249,14 @@ describe('WslHookRelayManager', () => {
it('starts one relay per distro, installs hooks, exposes the guest endpoint path, and forwards envelopes', async () => {
const { manager, deps } = createManager({})
manager.ensureForDistro('Ubuntu')
manager.ensureForDistro('Ubuntu')
manager.ensureForDistro('Ubuntu', codexHome)
manager.ensureForDistro('Ubuntu', codexHome)
await vi.waitFor(() => expect(deps.installHooks).toHaveBeenCalledTimes(1))
expect(deps.spawnRelay).toHaveBeenCalledTimes(1)
expect(deps.installCodex).toHaveBeenCalledWith(codexHome, 'Ubuntu')
// Codex is owned by the canonical runtime-host writer, not the relay adapter.
expect(deps.installHooks).toHaveBeenCalledWith(expect.anything(), home, {
agents: ['codex'],
codexHomeDir: `${home}/.local/share/orca/codex-runtime-home/home`,
deferTrustUntilConfigToml: true
agents: []
})
expect(manager.getGuestEndpointFilePath('Ubuntu')).toBe(
@@ -272,9 +281,23 @@ describe('WslHookRelayManager', () => {
manager.disposeAll()
})
it('reinstalls into a newly resolved runtime home without restarting the relay', async () => {
const { manager, deps } = createManager({})
manager.ensureForDistro('Ubuntu', codexHome)
await vi.waitFor(() => expect(deps.installCodex).toHaveBeenCalledTimes(1))
const nextHome = codexHome.replace('codex-runtime-home', 'codex-accounts\\account-2')
manager.ensureForDistro('ubuntu', nextHome)
await vi.waitFor(() => expect(deps.installCodex).toHaveBeenCalledTimes(2))
expect(deps.installCodex).toHaveBeenLastCalledWith(nextHome, 'Ubuntu')
expect(deps.spawnRelay).toHaveBeenCalledTimes(1)
manager.disposeAll()
})
it('ships the OpenCode plugin to the guest and exposes the overlay dir', async () => {
const { manager } = createManager({})
manager.ensureForDistro('Ubuntu')
manager.ensureForDistro('Ubuntu', codexHome)
await vi.waitFor(() => expect(manager.getOpenCodeOverlayDir('Ubuntu')).toBe(opencodeOverlayDir))
manager.disposeAll()
})
@@ -397,7 +420,7 @@ describe('WslHookRelayManager', () => {
it('stops live relays and refuses to revive them once agent status hooks are switched off', async () => {
const settings = { agentStatusHooksEnabled: true }
const { manager, deps } = createManager({ managedHookSettings: () => settings })
manager.ensureForDistro('Ubuntu')
manager.ensureForDistro('Ubuntu', codexHome)
await vi.waitFor(() => expect(deps.installHooks).toHaveBeenCalledTimes(1))
settings.agentStatusHooksEnabled = false
@@ -414,6 +437,8 @@ describe('WslHookRelayManager', () => {
settings.agentStatusHooksEnabled = true
manager.resumeStoppedRelays()
await vi.waitFor(() => expect(deps.spawnRelay).toHaveBeenCalledTimes(2))
await vi.waitFor(() => expect(deps.installCodex).toHaveBeenCalledTimes(2))
expect(deps.installCodex).toHaveBeenLastCalledWith(codexHome, 'Ubuntu')
manager.disposeAll()
})
+27 -8
View File
@@ -29,6 +29,10 @@ import {
WSL_HOOK_FS_METHODS,
wslHookRelayEndpointFilePath
} from '../../shared/wsl-hook-relay-contract'
import {
recordManagedWslCodexHome,
wslRuntimeHomePathsEqual
} from '../codex/managed-wsl-codex-home-registry'
type DistroState = {
/** Original casing for wsl.exe argv and breadcrumbs; map keys are lowercased. */
@@ -37,6 +41,7 @@ type DistroState = {
child?: ChildProcessWithoutNullStreams
mux?: SshChannelMultiplexer
guestHome?: string
codexHomePath?: string
guestEndpointFilePath?: string
opencodeOverlayDir?: string
failures: number
@@ -52,7 +57,7 @@ export class WslHookRelayManager {
private recovery: WslRelayRecovery
private states = new Map<string, DistroState>()
/** Distros a hooks-off teardown stopped, so re-enabling can put them back. */
private stoppedByHooksOff = new Set<string>()
private stoppedByHooksOff = new Map<string, string | undefined>()
private defaultDistro: string | null = null
private disposed = false
private warnedBundleMissing = false
@@ -64,7 +69,7 @@ export class WslHookRelayManager {
warn: (message) => this.deps.warn(message),
isDisposed: () => this.disposed,
isCurrent: (state) => this.states.get(wslHookRelayStateKey(state.distro)) === state,
restart: (distro) => this.ensureForDistro(distro),
restart: (distro) => this.ensureForDistro(distro, this.stateFor(distro)?.codexHomePath),
dropState: (state) => {
// Why: identity-guarded — a fresh ensure() may own this key by now;
// deleting by key alone would orphan its live relay child.
@@ -81,11 +86,11 @@ export class WslHookRelayManager {
}
/** Fire-and-forget from every WSL PTY spawn-env build; errors breadcrumb. */
ensureForDistro(distro: string | null): void {
ensureForDistro(distro: string | null, codexHomePath?: string | null): void {
if (this.disposed || !isWslHookRelayAllowed(this.deps)) {
return
}
void this.ensureInternal(distro).catch((err) => {
void this.ensureInternal(distro, codexHomePath ?? undefined).catch((err) => {
const detail = err instanceof Error ? err.message : String(err)
this.deps.warn(`[agent-hooks] WSL hook relay ensure failed: ${detail}`)
})
@@ -118,7 +123,7 @@ export class WslHookRelayManager {
state.mux?.dispose()
state.child?.kill()
if (!permanent) {
this.stoppedByHooksOff.add(state.distro)
this.stoppedByHooksOff.set(state.distro, state.codexHomePath)
}
}
this.states.clear()
@@ -129,26 +134,39 @@ export class WslHookRelayManager {
resumeStoppedRelays(): void {
const distros = [...this.stoppedByHooksOff]
this.stoppedByHooksOff.clear()
for (const distro of distros) {
for (const [distro, codexHomePath] of distros) {
void this.deps
.isDistroRunning(distro)
.then((running) => {
if (running) {
this.ensureForDistro(distro)
this.ensureForDistro(distro, codexHomePath)
}
})
.catch(() => undefined)
}
}
private async ensureInternal(requestedDistro: string | null): Promise<void> {
private async ensureInternal(
requestedDistro: string | null,
requestedCodexHomePath?: string
): Promise<void> {
const distro = requestedDistro ?? (await this.resolveDefaultDistro())
if (!distro || this.disposed) {
return
}
const key = wslHookRelayStateKey(distro)
const existing = this.states.get(key)
if (requestedCodexHomePath) {
recordManagedWslCodexHome(distro, requestedCodexHomePath)
}
if (existing) {
if (
requestedCodexHomePath &&
!wslRuntimeHomePathsEqual(existing.codexHomePath, requestedCodexHomePath)
) {
existing.codexHomePath = requestedCodexHomePath
existing.lastInstallAt = 0
}
if (existing.phase === 'running') {
void maybeRerunWslRelayGuestInstall(this.deps, existing)
return
@@ -184,6 +202,7 @@ export class WslHookRelayManager {
// Why: instance-keyed and on the distro's persistent fs, so it outlives a relay
// crash — dropping it would blank status on panes spawned mid-relaunch.
opencodeOverlayDir: existing?.opencodeOverlayDir,
codexHomePath: requestedCodexHomePath ?? existing?.codexHomePath,
cooldownUntil: 0
}
this.states.set(key, state)
@@ -0,0 +1,42 @@
import type { RemoteHostPlatform } from '../ssh/ssh-remote-platform'
import { joinRemotePath } from '../ssh/ssh-remote-platform'
import { throwIfAiVaultScanCancelled } from './ai-vault-scan-cancellation'
import { isMissingRemoteSessionPathError } from './remote-session-file-stat'
import type { RemoteSessionSource } from './remote-session-scanner-types'
import {
clineMessagesPathForMetadata,
isClineSessionMetadataPath,
parseClineSessionContent
} from './session-scanner-cline-parser'
export function remoteClineSource(
remoteHome: string,
hostPlatform: RemoteHostPlatform
): RemoteSessionSource {
return {
agent: 'cline',
rootDir: joinRemotePath(hostPlatform, remoteHome, '.cline', 'data', 'sessions'),
extensions: ['.json'],
filePredicate: isClineSessionMetadataPath,
contentDependencyPath: clineMessagesPathForMetadata,
directoryPredicate: (_name, depth) => depth === 0,
parse: async (file, content, context) => {
let messagesContent: string | null = null
try {
throwIfAiVaultScanCancelled(context.signal)
const read = await context.provider.readFile(clineMessagesPathForMetadata(file.path))
throwIfAiVaultScanCancelled(context.signal)
messagesContent = read.isBinary ? null : read.content
} catch (error) {
throwIfAiVaultScanCancelled(context.signal)
if (!isMissingRemoteSessionPathError(error)) {
throw error
}
}
return parseClineSessionContent(file, content, messagesContent, context.hostPlatform.os, {
executionHostId: context.executionHostId,
executionHostPlatform: context.hostPlatform.os
})
}
}
}
@@ -29,18 +29,7 @@ export async function discoverRemoteSourceCandidates(args: {
const files = await mapRemoteScanBatches(
paths,
REMOTE_DISCOVERY_CONCURRENCY,
(path) =>
statRemoteSessionFile(
args.context.provider,
path,
args.source.agent,
args.context.executionHostId,
args.issues,
{
missingIsExpected: Boolean(args.source.fixedChildFileSegments),
signal: args.context.signal
}
),
(path) => statRemoteCandidateFile(path, args.source, args.context, args.issues),
args.context.signal
)
return files
@@ -52,6 +41,46 @@ export async function discoverRemoteSourceCandidates(args: {
}))
}
async function statRemoteCandidateFile(
path: string,
source: RemoteSessionSource,
context: RemoteScannerContext,
issues: AiVaultScanIssue[]
): Promise<FileWithMtime | null> {
const file = await statRemoteSessionFile(
context.provider,
path,
source.agent,
context.executionHostId,
issues,
{
missingIsExpected: Boolean(source.fixedChildFileSegments),
signal: context.signal
}
)
if (!file || !source.contentDependencyPath) {
return file
}
const dependency = await statRemoteSessionFile(
context.provider,
source.contentDependencyPath(path),
source.agent,
context.executionHostId,
issues,
{ missingIsExpected: true, signal: context.signal }
)
if (!dependency) {
return file
}
const mtimeMs = Math.max(file.mtimeMs, dependency.mtimeMs)
return {
...file,
mtimeMs,
modifiedAt: new Date(mtimeMs).toISOString(),
sizeBytes: (file.sizeBytes ?? 0) + (dependency.sizeBytes ?? 0)
}
}
async function listRemoteFixedChildFiles(
source: RemoteSessionSource,
context: RemoteScannerContext,
@@ -17,6 +17,7 @@ import { partitionOmpSubagentTranscriptPaths } from './session-scanner-omp-subag
import type { FileWithMtime } from './session-scanner-types'
import { normalizeAgentSessionsDir } from './session-scanner-values'
import { remoteCodexIndexTitles } from './remote-session-scanner-codex-index'
import { remoteClineSource } from './remote-session-scanner-cline-source'
import type {
RemoteParserOptions,
RemoteScannerContext,
@@ -77,6 +78,7 @@ export function remoteSessionSources(
parseCursorSessionContent,
(path) => remotePathSegments(path).includes('agent-transcripts')
),
remoteClineSource(remoteHome, hostPlatform),
source(
'hermes',
remoteHome,
@@ -33,6 +33,7 @@ export type RemoteSessionSource = {
codexHome?: string
extensions: readonly string[]
filePredicate?: (path: string) => boolean
contentDependencyPath?: (path: string) => string
// Depth 0 denotes a direct child of rootDir.
directoryPredicate?: (name: string, depth: number) => boolean
// A canonical file directly beneath every top-level session directory.
@@ -5,6 +5,98 @@ import { MemoryRemoteProvider, jsonLines } from './remote-session-scanner-test-f
import { primeAgentFixture } from './session-scanner-prime-agent-fixtures'
describe('scanRemoteAiVaultSessions', () => {
it('indexes Cline manifests on the SSH-owned disk without messages-file phantoms', async () => {
const provider = new MemoryRemoteProvider()
const sessionId = '1786466194549_xrzrl'
const sessionDir = `/home/ada/.cline/data/sessions/${sessionId}`
provider.addFile(
`${sessionDir}/${sessionId}.json`,
JSON.stringify({
version: 1,
session_id: sessionId,
started_at: '2026-08-11T16:36:34.551Z',
provider: 'deepseek',
model: 'deepseek-v4-flash',
cwd: '/home/ada/repo'
}),
10
)
provider.addFile(
`${sessionDir}/${sessionId}.messages.json`,
JSON.stringify({
version: 1,
updated_at: '2026-08-11T16:38:00.000Z',
sessionId,
messages: [
{
role: 'user',
content: [{ type: 'text', text: 'Fix remote Cline history' }]
}
]
}),
11
)
const result = await scanRemoteAiVaultSessions({
provider,
executionHostId: 'ssh:dev-box',
remoteHome: '/home/ada',
hostPlatform: getRemoteHostPlatform('linux-x64')
})
expect(result.issues).toEqual([])
expect(result.sessions).toHaveLength(1)
expect(result.sessions[0]).toMatchObject({
agent: 'cline',
sessionId,
title: 'Fix remote Cline history',
cwd: '/home/ada/repo',
executionHostId: 'ssh:dev-box',
executionHostPlatform: 'linux',
filePath: `${sessionDir}/${sessionId}.json`
})
})
it('indexes and resumes Cline sessions from a Windows SSH host', async () => {
const provider = new MemoryRemoteProvider()
const sessionId = '1786466194549_xrzrl'
const sessionDir = `C:/Users/Ada/.cline/data/sessions/${sessionId}`
provider.addFile(
`${sessionDir}/${sessionId}.json`,
JSON.stringify({
session_id: sessionId,
started_at: '2026-08-11T16:36:34.551Z',
cwd: 'C:/repo/app'
}),
10
)
provider.addFile(
`${sessionDir}/${sessionId}.messages.json`,
JSON.stringify({
updated_at: '2026-08-11T16:38:00.000Z',
messages: [{ role: 'user', content: [{ type: 'text', text: 'Fix Windows history' }] }]
}),
11
)
const result = await scanRemoteAiVaultSessions({
provider,
executionHostId: 'ssh:win-box',
remoteHome: 'C:/Users/Ada',
hostPlatform: getRemoteHostPlatform('win32-x64')
})
expect(result.issues).toEqual([])
expect(result.sessions).toHaveLength(1)
expect(result.sessions[0]).toMatchObject({
agent: 'cline',
sessionId,
executionHostPlatform: 'win32',
filePath: `${sessionDir}/${sessionId}.json`,
resumeCommand: `cmd /d /s /c "cd /d ""C:/repo/app"" && cline --id ""${sessionId}"""`
})
})
it('parses remote default and Orca-managed Codex homes with SSH host ids', async () => {
const provider = new MemoryRemoteProvider()
provider.addFile(
@@ -19,6 +19,37 @@ const ROVO_ROOT = join(HOME, '.rovodev', 'sessions')
const GROK_ROOT = join(HOME, '.grok', 'sessions')
describe('validateAiVaultSessionDeleteTarget', () => {
it('allows a canonical Cline manifest and removes its whole session directory', () => {
const root = join('/tmp', 'cline-sessions')
const sessionId = '1786466194549_xrzrl'
const sessionDir = join(root, sessionId)
const result = validateAiVaultSessionDeleteTarget({
agent: 'cline',
filePath: join(sessionDir, `${sessionId}.json`),
executionHostId: 'local',
rootOptions: { clineSessionsDir: root }
})
expect(result).toMatchObject({
allowed: true,
agent: 'cline',
removals: [{ path: sessionDir, kind: 'directory' }]
})
})
it('rejects a Cline messages companion as an undiscoverable delete target', () => {
const root = join('/tmp', 'cline-sessions')
const sessionId = '1786466194549_xrzrl'
const result = validateAiVaultSessionDeleteTarget({
agent: 'cline',
filePath: join(root, sessionId, `${sessionId}.messages.json`),
executionHostId: 'local',
rootOptions: { clineSessionsDir: root }
})
expect(result).toEqual({ allowed: false, agent: 'cline', reason: 'undiscoverable-path' })
})
it('allows a supported agent whose file resolves inside its known root', () => {
const result = validateAiVaultSessionDeleteTarget({
agent: 'gemini',
+5 -1
View File
@@ -21,7 +21,11 @@ import type { AiVaultScanOptions } from './session-scanner-types'
// Agents whose session IS the directory holding the scanned file: everything
// beside it belongs to the same session (rovo's session_context.json, grok's
// chat_history.jsonl), so the directory is the only complete delete unit.
const AI_VAULT_DIRECTORY_SHAPED_DELETE_AGENTS = new Set<AiVaultDeletableAgent>(['rovo', 'grok'])
const AI_VAULT_DIRECTORY_SHAPED_DELETE_AGENTS = new Set<AiVaultDeletableAgent>([
'rovo',
'grok',
'cline'
])
export type ValidateAiVaultSessionDeleteTargetArgs = {
agent: AiVaultAgent
@@ -2,6 +2,7 @@ import type { AiVaultSession } from '../../shared/ai-vault-types'
import { parseDevinSessionFile } from './session-scanner-devin-parser'
import { parseAntigravitySessionFile } from './session-scanner-antigravity-parser'
import { parseDroidSessionFile } from './session-scanner-droid-parser'
import { parseClineSessionFile } from './session-scanner-cline-parser'
import { parseGrokSessionFile } from './session-scanner-grok-parser'
import { parseMessageGraphSessionFile, parseRovoSessionFile } from './session-scanner-graph-parsers'
import { parseKimiSessionFile } from './session-scanner-kimi-parser'
@@ -72,6 +73,8 @@ export async function parseAgentSessionFile(
return parseMessageGraphSessionFile('prime-agent', candidate.file, platform)
case 'droid':
return parseDroidSessionFile(candidate.file, platform)
case 'cline':
return parseClineSessionFile(candidate.file, platform)
case 'devin':
return parseDevinSessionFile(candidate.file, platform)
case 'kimi':
@@ -4,6 +4,10 @@ import type { AiVaultAgent } from '../../shared/ai-vault-types'
import type { AiVaultDeletableAgent } from '../../shared/ai-vault-session-deletion'
import { resolveGrokSessionsDir } from '../../shared/grok-session-paths'
import { uniqueCodexSessionsDirs } from './session-scanner-codex-paths'
import {
clineMessagesPathForMetadata,
isClineSessionMetadataPath
} from './session-scanner-cline-parser'
import { resolveKimiSessionsDir } from './session-scanner-kimi-paths'
import { OMP_SESSION_ARTIFACT_DIR_PATTERN } from './session-scanner-omp-subagent-transcripts'
import { claudeProjectsRootDirs, OMP_SESSIONS_DIR, sessionRootDirs } from './session-scanner-roots'
@@ -40,6 +44,8 @@ const DEVIN_TRANSCRIPTS_DIR = join(
)
const DROID_SESSIONS_DIR = join(homedir(), '.factory', 'sessions')
const DROID_PROJECTS_DIR = join(homedir(), '.factory', 'projects')
const CLINE_SESSIONS_DIR =
process.env.CLINE_SESSION_DATA_DIR?.trim() || join(homedir(), '.cline', 'data', 'sessions')
/**
* Where one agent's session files live and which of them count as sessions.
@@ -55,6 +61,8 @@ export type AiVaultAgentSource = {
rootDirs: (options: AiVaultScanOptions, wslHomeDirs: readonly string[]) => string[]
extensions: readonly string[]
filePredicate?: (filePath: string) => boolean
// A sibling whose stat participates in candidate freshness and recency.
contentDependencyPath?: (filePath: string) => string
// Return false to skip a directory; depth 0 is a child of the root.
directoryPredicate?: (name: string, depth: number) => boolean
// Roots that are alternates for one install rather than distinct locations,
@@ -221,6 +229,19 @@ export const AI_VAULT_AGENT_SOURCES: AiVaultAgentSourceTable = {
],
extensions: ['.jsonl']
},
cline: {
rootDirs: (options, wslHomeDirs) =>
sessionRootDirs(options.clineSessionsDir ?? CLINE_SESSIONS_DIR, wslHomeDirs, [
'.cline',
'data',
'sessions'
]),
extensions: ['.json'],
filePredicate: isClineSessionMetadataPath,
contentDependencyPath: clineMessagesPathForMetadata,
// Cline stores one manifest directly beneath each session directory.
directoryPredicate: (_name, depth) => depth === 0
},
kimi: {
rootDirs: (options, wslHomeDirs) =>
sessionRootDirs(resolveKimiSessionsDir(options.kimiSessionsDir), wslHomeDirs, [
@@ -0,0 +1,117 @@
import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { scanAiVaultSessions } from './session-scanner'
import { isolatedScanRoots } from './session-scanner-test-fixtures'
let tempRoots: string[] = []
afterEach(async () => {
await Promise.all(tempRoots.map((root) => rm(root, { recursive: true, force: true })))
tempRoots = []
})
describe('Cline AI Vault sessions', () => {
it('indexes one manifest-backed session without surfacing its messages file as a phantom', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-cline-'))
tempRoots.push(root)
const roots = isolatedScanRoots(root)
const sessionId = '1786466194549_xrzrl'
const sessionDir = join(roots.clineSessionsDir, sessionId)
await mkdir(sessionDir, { recursive: true })
await writeFile(
join(sessionDir, `${sessionId}.json`),
JSON.stringify({
version: 1,
session_id: sessionId,
source: 'cli',
started_at: '2026-08-11T16:36:34.551Z',
status: 'idle',
interactive: true,
provider: 'deepseek',
model: 'deepseek-v4-flash',
cwd: '/repo/cline',
workspace_root: '/repo/cline'
})
)
await writeFile(
join(sessionDir, `${sessionId}.messages.json`),
JSON.stringify({
version: 1,
updated_at: '2026-08-11T16:38:00.000Z',
agent: 'lead',
sessionId,
messages: [
{
id: 'user-1',
role: 'user',
content: [{ type: 'text', text: 'Fix the Cline vault scanner' }]
},
{
id: 'assistant-1',
role: 'assistant',
content: [{ type: 'text', text: 'I will inspect the scanner.' }],
ts: 1_786_466_280_000,
modelInfo: { id: 'deepseek-v4-flash', provider: 'deepseek' }
}
]
})
)
const result = await scanAiVaultSessions({ ...roots, platform: 'darwin' })
expect(result.issues).toEqual([])
expect(result.sessions.filter((session) => session.agent === 'cline')).toHaveLength(1)
expect(result.sessions.find((session) => session.agent === 'cline')).toMatchObject({
sessionId,
title: 'Fix the Cline vault scanner',
cwd: '/repo/cline',
model: 'deepseek-v4-flash',
messageCount: 2,
resumeCommand: `cd '/repo/cline' && cline --id '${sessionId}'`,
filePath: join(sessionDir, `${sessionId}.json`)
})
await writeFile(
join(sessionDir, `${sessionId}.messages.json`),
JSON.stringify({
version: 1,
updated_at: '2026-08-11T16:39:00.000Z',
sessionId,
messages: [
{ role: 'user', content: [{ type: 'text', text: 'Fix the Cline vault scanner' }] },
{ role: 'assistant', content: [{ type: 'text', text: 'I inspected the scanner.' }] },
{ role: 'user', content: [{ type: 'text', text: 'Add the regression test too' }] }
]
})
)
const rescanned = await scanAiVaultSessions({ ...roots, platform: 'darwin' })
expect(rescanned.sessions.find((session) => session.agent === 'cline')).toMatchObject({
messageCount: 3,
updatedAt: '2026-08-11T16:39:00.000Z'
})
})
it('only indexes manifests directly beneath a session directory', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-cline-nested-'))
tempRoots.push(root)
const roots = isolatedScanRoots(root)
const sessionId = 'direct-session'
const nestedId = 'nested-session'
await mkdir(join(roots.clineSessionsDir, sessionId), { recursive: true })
await mkdir(join(roots.clineSessionsDir, 'workspace', nestedId), { recursive: true })
const metadata = JSON.stringify({ session_id: sessionId, cwd: '/repo' })
await writeFile(join(roots.clineSessionsDir, sessionId, `${sessionId}.json`), metadata)
await writeFile(
join(roots.clineSessionsDir, 'workspace', nestedId, `${nestedId}.json`),
JSON.stringify({ session_id: nestedId, cwd: '/unexpected' })
)
const result = await scanAiVaultSessions({ ...roots, platform: 'darwin' })
expect(result.sessions.filter((session) => session.agent === 'cline')).toHaveLength(1)
expect(result.sessions.find((session) => session.agent === 'cline')?.sessionId).toBe(sessionId)
})
})
@@ -0,0 +1,116 @@
import { wslGatedReadFile } from '../native-chat/wsl-transcript-fs-access'
import { WslTranscriptFsError } from '../native-chat/wsl-transcript-fs-gate'
import type { AiVaultSession } from '../../shared/ai-vault-types'
import type { ExecutionHostId } from '../../shared/execution-host'
import {
addPreviewContent,
createAccumulator,
finalizeSession,
updateTimeline
} from './session-scanner-accumulator'
import type { FileWithMtime } from './session-scanner-types'
import {
arrayValue,
asRecord,
extractContentText,
extractString,
normalizeTitleText
} from './session-scanner-values'
type ParserSessionOptions = {
executionHostId?: ExecutionHostId
executionHostPlatform?: NodeJS.Platform | null
}
export function isClineSessionMetadataPath(filePath: string): boolean {
const segments = filePath.replace(/\\/g, '/').split('/').filter(Boolean)
const fileName = segments.pop()
const sessionId = segments.pop()
return Boolean(fileName && sessionId && fileName === `${sessionId}.json`)
}
export function clineMessagesPathForMetadata(filePath: string): string {
return filePath.endsWith('.json') ? `${filePath.slice(0, -'.json'.length)}.messages.json` : ''
}
export async function parseClineSessionFile(
file: FileWithMtime,
platform: NodeJS.Platform = process.platform
): Promise<AiVaultSession | null> {
const metadataContent = await wslGatedReadFile(file.path, 'utf-8', 'scan')
let messagesContent: string | null = null
try {
messagesContent = await wslGatedReadFile(
clineMessagesPathForMetadata(file.path),
'utf-8',
'scan'
)
} catch (error) {
// The manifest is written before the first turn; a missing messages file is
// a valid metadata-only session, while a WSL gate refusal must stay visible.
if (error instanceof WslTranscriptFsError || !isMissingSessionPathError(error)) {
throw error
}
}
return parseClineSessionContent(file, metadataContent, messagesContent, platform)
}
function isMissingSessionPathError(error: unknown): boolean {
const code =
error && typeof error === 'object' && 'code' in error && typeof error.code === 'string'
? error.code
: null
return code === 'ENOENT' || code === 'ENOTDIR'
}
export function parseClineSessionContent(
file: FileWithMtime,
metadataContent: string,
messagesContent: string | null,
platform: NodeJS.Platform = process.platform,
options: ParserSessionOptions = {}
): AiVaultSession | null {
const metadata = parseJsonRecord(metadataContent)
if (!metadata) {
return null
}
const pathSegments = file.path.replace(/\\/g, '/').split('/').filter(Boolean)
const sessionId = extractString(metadata.session_id) ?? pathSegments.at(-2) ?? ''
const accumulator = createAccumulator({ agent: 'cline', file, sessionId })
accumulator.cwd = extractString(metadata.cwd) ?? extractString(metadata.workspace_root)
accumulator.model = extractString(metadata.model)
updateTimeline(accumulator, metadata.started_at)
const messages = messagesContent ? parseJsonRecord(messagesContent) : null
if (messages) {
updateTimeline(accumulator, messages.updated_at)
for (const value of arrayValue(messages.messages)) {
const message = asRecord(value)
const role = message?.role
if (!message || (role !== 'user' && role !== 'assistant')) {
continue
}
accumulator.messageCount++
updateTimeline(accumulator, message.ts)
const content = message.content
if (role === 'user' && !accumulator.fallbackTitle) {
accumulator.fallbackTitle = normalizeTitleText(extractContentText(content) ?? '')
}
if (role === 'assistant' && !accumulator.model) {
accumulator.model = extractString(asRecord(message.modelInfo)?.id)
}
addPreviewContent(accumulator, role, content, message.ts)
}
}
accumulator.fallbackTitle ??= normalizeTitleText(extractString(metadata.prompt) ?? '')
return finalizeSession(accumulator, platform, options)
}
function parseJsonRecord(content: string): Record<string, unknown> | null {
try {
return asRecord(JSON.parse(content) as unknown)
} catch {
return null
}
}
+23 -3
View File
@@ -14,6 +14,7 @@ export async function discoverFiles(args: {
issues: AiVaultScanIssue[]
extensions: string[]
filePredicate?: (path: string) => boolean
contentDependencyPath?: (path: string) => string
directoryPredicate?: (name: string, depth: number) => boolean
}): Promise<SessionFileDiscovery> {
let paths: string[]
@@ -41,11 +42,13 @@ export async function discoverFiles(args: {
for (const path of paths) {
try {
const fileStat = await wslGatedStat(path, 'scan')
const dependencyStat = await optionalContentDependencyStat(args.contentDependencyPath?.(path))
const mtimeMs = Math.max(fileStat.mtimeMs, dependencyStat?.mtimeMs ?? 0)
files.push({
path,
mtimeMs: fileStat.mtimeMs,
modifiedAt: fileStat.mtime.toISOString(),
sizeBytes: fileStat.size,
mtimeMs,
modifiedAt: new Date(mtimeMs).toISOString(),
sizeBytes: fileStat.size + (dependencyStat?.size ?? 0),
dev: fileStat.dev,
ino: fileStat.ino,
nlink: fileStat.nlink
@@ -65,6 +68,23 @@ export async function discoverFiles(args: {
}
}
async function optionalContentDependencyStat(
filePath: string | undefined
): Promise<{ mtimeMs: number; size: number } | null> {
if (!filePath) {
return null
}
try {
const fileStat = await wslGatedStat(filePath, 'scan')
return { mtimeMs: fileStat.mtimeMs, size: fileStat.size }
} catch (error) {
if (error instanceof WslTranscriptFsError) {
throw error
}
return null
}
}
export async function walkSessionFiles(
dirPath: string,
agent: AiVaultAgent,
@@ -76,6 +76,7 @@ function resumableStateFactoryFor(
case 'devin':
case 'grok':
case 'hermes':
case 'cline':
case 'kimi':
case 'opencode':
case 'rovo':
@@ -33,6 +33,7 @@ describe('buildAiVaultServiceEnv', () => {
const env = buildAiVaultServiceEnv(
{
CODEX_HOME: '/home/dev/.codex',
CLINE_SESSION_DATA_DIR: '/home/dev/cline-sessions',
COPILOT_HOME: '/home/dev/.copilot',
DEVIN_HOME: '/home/dev/.devin',
GROK_HOME: '/home/dev/.grok',
@@ -49,6 +50,7 @@ describe('buildAiVaultServiceEnv', () => {
expect(env).toEqual({
CODEX_HOME: '/home/dev/.codex',
CLINE_SESSION_DATA_DIR: '/home/dev/cline-sessions',
COPILOT_HOME: '/home/dev/.copilot',
DEVIN_HOME: '/home/dev/.devin',
GROK_HOME: '/home/dev/.grok',
@@ -34,6 +34,7 @@ export const RUNTIME_ENV_ALLOWLIST = [
// dropping one hides every session of a user who relocated that agent's home.
const AGENT_ROOT_ENV_ALLOWLIST = [
'CODEX_HOME',
'CLINE_SESSION_DATA_DIR',
'COPILOT_HOME',
'DEVIN_HOME',
'GROK_HOME',
@@ -55,6 +55,7 @@ function agentDiscoveries(
issues,
extensions: [...source.extensions],
filePredicate: source.filePredicate,
contentDependencyPath: source.contentDependencyPath,
directoryPredicate: source.directoryPredicate
})
return source.mergeRootDiscoveries
@@ -24,6 +24,7 @@ export function isolatedScanRoots(root: string) {
primeAgentSessionsDir: join(root, 'prime-agent-sessions'),
droidSessionsDir: join(root, 'droid-sessions'),
droidProjectsDir: join(root, 'droid-projects'),
clineSessionsDir: join(root, 'cline-sessions'),
kimiSessionsDir: join(root, 'kimi-sessions')
}
}
@@ -33,6 +33,7 @@ export type AiVaultScanOptions = {
primeAgentSessionsDir?: string
droidSessionsDir?: string
droidProjectsDir?: string
clineSessionsDir?: string
kimiSessionsDir?: string
limit?: number
unlimited?: boolean
+21
View File
@@ -647,6 +647,26 @@ describe('scanAiVaultSessions', () => {
])
)
const clineSessionId = 'cline-session'
const clineSessionDir = join(roots.clineSessionsDir, clineSessionId)
await mkdir(clineSessionDir, { recursive: true })
await writeFile(
join(clineSessionDir, `${clineSessionId}.json`),
JSON.stringify({
session_id: clineSessionId,
started_at: '2026-05-01T10:10:30.000Z',
model: 'cline-model',
cwd: '/tmp/cline'
})
)
await writeFile(
join(clineSessionDir, `${clineSessionId}.messages.json`),
JSON.stringify({
updated_at: '2026-05-01T10:10:31.000Z',
messages: [{ role: 'user', content: [{ type: 'text', text: 'Cline vault title' }] }]
})
)
// Kimi: <sessions>/wd_*/session_*/state.json + sibling agents/main/wire.jsonl,
// with the work dir resolved from the top-level session_index.jsonl.
const kimiSessionDir = join(roots.kimiSessionsDir, 'wd_app_abc', 'session_kimi-session')
@@ -738,6 +758,7 @@ describe('scanAiVaultSessions', () => {
expect(commandByAgent.get('prime-agent')).toBe(
`cd '/tmp/prime-agent' && prime-agent --resume '${primeAgentSessionFile}'`
)
expect(commandByAgent.get('cline')).toBe("cd '/tmp/cline' && cline --id 'cline-session'")
expect(commandByAgent.get('devin')).toBe("cd '/tmp/devin' && devin --resume 'devin-session'")
expect(commandByAgent.get('droid')).toBe("cd '/tmp/droid' && droid --resume 'droid-session'")
expect(commandByAgent.get('kimi')).toBe(
@@ -0,0 +1,210 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const browserMocks = vi.hoisted(() => ({
appGetPathMock: vi.fn(() => '/downloads'),
shellOpenExternalMock: vi.fn(),
browserWindowFromWebContentsMock: vi.fn(),
menuBuildFromTemplateMock: vi.fn(),
guestOffMock: vi.fn(),
guestOnMock: vi.fn(),
guestSetBackgroundThrottlingMock: vi.fn(),
guestSetWindowOpenHandlerMock: vi.fn(),
guestOpenDevToolsMock: vi.fn(),
webContentsFromIdMock: vi.fn(),
screenGetCursorScreenPointMock: vi.fn(() => ({ x: 0, y: 0 })),
openPopupWithOriginBarMock: vi.fn()
}))
vi.mock('electron', () => ({
app: { getPath: browserMocks.appGetPathMock },
BrowserWindow: { fromWebContents: browserMocks.browserWindowFromWebContentsMock },
clipboard: { writeText: vi.fn() },
shell: { openExternal: browserMocks.shellOpenExternalMock },
Menu: { buildFromTemplate: browserMocks.menuBuildFromTemplateMock },
screen: { getCursorScreenPoint: browserMocks.screenGetCursorScreenPointMock },
webContents: { fromId: browserMocks.webContentsFromIdMock }
}))
vi.mock('./popup-origin-bar-window', () => ({
openPopupWithOriginBar: browserMocks.openPopupWithOriginBarMock
}))
import { browserManager } from './browser-manager'
import type { BrowserAnnotationViewportBridgeOptions } from '../../shared/browser-annotation-viewport-bridge'
import {
rendererWebContentsId,
resetBrowserManagerMocks,
resetBrowserManagerState
} from './browser-manager-test-harness'
import {
createViewportGuestFactory,
flushViewportOps
} from './browser-manager-viewport-test-fixtures'
const { webContentsFromIdMock } = browserMocks
const makeGuest = createViewportGuestFactory(browserMocks)
const BRIDGE_OPTIONS: BrowserAnnotationViewportBridgeOptions = {
emitViewport: false,
enabled: true,
markers: [],
token: 'annotationviewporttoken'
}
function registerPage(pageId: string, guest: Record<string, unknown>): void {
webContentsFromIdMock.mockReturnValue(guest)
browserManager.attachGuestPolicies(guest as never)
browserManager.registerGuest({
browserPageId: pageId,
webContentsId: guest.id as number,
rendererWebContentsId
})
}
/** The production resolver's shape: read the registry now, not when the request was made. */
function resolveFromRegistry(pageId: string): () => Electron.WebContents | null {
return () => browserManager.getAuthorizedGuest(pageId, rendererWebContentsId)
}
describe('browserManager.setAnnotationViewportBridge', () => {
beforeEach(() => {
resetBrowserManagerMocks(browserMocks)
resetBrowserManagerState()
})
afterEach(() => {
vi.restoreAllMocks()
})
it('installs the bridge in an isolated world', async () => {
const { guest } = makeGuest(4646)
registerPage('tab-annotations', guest)
const ok = await browserManager.setAnnotationViewportBridge(
'tab-annotations',
BRIDGE_OPTIONS,
resolveFromRegistry('tab-annotations')
)
expect(ok).toBe(true)
expect(guest.executeJavaScriptInIsolatedWorld).toHaveBeenCalledWith(
expect.any(Number),
[
expect.objectContaining({
code: expect.stringContaining('__orcaBrowserAnnotationViewportBridge')
})
],
false
)
})
// Why this is the case that matters: a cross-process navigation re-registers the same page id
// with a new WebContents and leaves the retired one alive, so a bridge op that resolved its
// guest when the request arrived would inject into a page nobody is looking at — and the
// annotation badges would stop tracking the document on screen.
it('injects into the guest the page has when a queued op finally runs, not the one it was asked with', async () => {
const { guest: firstGuest } = makeGuest(5101)
let releaseFirstInjection = (): void => {}
// One shared gate for every call, so a wrongly-routed second injection still settles and the
// test fails on where it landed rather than on a timeout.
const firstInjectionGate = new Promise<void>((resolve) => {
releaseFirstInjection = () => resolve()
})
firstGuest.executeJavaScriptInIsolatedWorld = vi.fn(() => firstInjectionGate)
registerPage('tab-swap', firstGuest)
const firstDone = browserManager.setAnnotationViewportBridge(
'tab-swap',
BRIDGE_OPTIONS,
resolveFromRegistry('tab-swap')
)
await flushViewportOps()
// A second request arrives while the first still holds the chain — at this moment the page is
// still on the first guest, which is the guest a request-time resolution would capture.
const secondDone = browserManager.setAnnotationViewportBridge(
'tab-swap',
BRIDGE_OPTIONS,
resolveFromRegistry('tab-swap')
)
await flushViewportOps()
// Only now does the page swap renderer processes, while the second op is still queued.
const { guest: secondGuest } = makeGuest(5102)
registerPage('tab-swap', secondGuest)
releaseFirstInjection()
await expect(firstDone).resolves.toBe(true)
await expect(secondDone).resolves.toBe(true)
expect(secondGuest.executeJavaScriptInIsolatedWorld).toHaveBeenCalledTimes(1)
expect(firstGuest.executeJavaScriptInIsolatedWorld).toHaveBeenCalledTimes(1)
})
// Why this is the resolver's cleanup and not the bridge's: the authority that reads the registry
// is the one that can see the guest is gone. The bridge only reports the refusal.
it('refuses when its guest died while the op was queued, and the resolver drops the registration', async () => {
const { guest } = makeGuest(5103)
registerPage('tab-dies', guest)
expect(browserManager.getGuestWebContentsId('tab-dies')).toBe(5103)
webContentsFromIdMock.mockReturnValue(null)
await expect(
browserManager.setAnnotationViewportBridge(
'tab-dies',
BRIDGE_OPTIONS,
resolveFromRegistry('tab-dies')
)
).resolves.toBe(false)
// Why assert the registry and not just the answer: a stale guest has to clear every per-tab
// entry, or the page keeps a dead WebContents id that later ops resolve against.
expect(browserManager.getGuestWebContentsId('tab-dies')).toBeNull()
})
// Why this one exists: an unresolved guest is not the same as a dead one. A request addressed by
// the wrong renderer names a page that is alive and on screen, and answering it with teardown
// would cancel that page's in-flight downloads and grabs over a misaddressed message.
it('refuses a request from the wrong renderer without tearing down the healthy page it named', async () => {
const { guest } = makeGuest(5105)
registerPage('tab-mismatch', guest)
const unregisterGuest = vi.spyOn(browserManager, 'unregisterGuest')
await expect(
browserManager.setAnnotationViewportBridge('tab-mismatch', BRIDGE_OPTIONS, () =>
browserManager.getAuthorizedGuest('tab-mismatch', rendererWebContentsId + 1)
)
).resolves.toBe(false)
expect(unregisterGuest).not.toHaveBeenCalled()
expect(browserManager.getGuestWebContentsId('tab-mismatch')).toBe(5105)
expect(guest.executeJavaScriptInIsolatedWorld).not.toHaveBeenCalled()
})
it('refuses a destroyed guest without injecting into it', async () => {
const { guest } = makeGuest(5104)
registerPage('tab-destroyed', guest)
;(guest.isDestroyed as ReturnType<typeof vi.fn>).mockReturnValue(true)
await expect(
browserManager.setAnnotationViewportBridge(
'tab-destroyed',
BRIDGE_OPTIONS,
() => guest as never
)
).resolves.toBe(false)
expect(guest.executeJavaScriptInIsolatedWorld).not.toHaveBeenCalled()
})
// Why: a document page owns no browsing state, so treating its refusal as a stale page would run
// teardown against an id the browsing registry never held.
it('does not run page teardown when a document page resolves to nothing', async () => {
const unregisterGuest = vi.spyOn(browserManager, 'unregisterGuest')
await expect(
browserManager.setAnnotationViewportBridge('doc-page-1', BRIDGE_OPTIONS, () => null)
).resolves.toBe(false)
expect(unregisterGuest).not.toHaveBeenCalled()
})
})
@@ -45,6 +45,32 @@ import {
resetBrowserManagerMocks,
resetBrowserManagerState
} from './browser-manager-test-harness'
import { installDocPreviewGuestPolicy } from './doc-preview-guest-policy'
import { mintDocPreviewGrant, revokeAllDocPreviewGrants } from './doc-preview-grant-registry'
import { buildDocPreviewUrl } from '../../shared/doc-preview-scheme'
/**
* A page the document half of the registry really holds. Built rather than named: membership is
* what both doors refuse on now, so an id that merely looks like a preview's would be admitted.
*/
function registerWorkspaceDocPage(browserPageId: string): void {
const grant = mintDocPreviewGrant({
owner: { kind: 'ssh', connectionId: 'ssh-1' },
root: '/home/alice/docs',
entryRelativePath: 'index.html',
browserPageId
})
const guest = {
isFocused: () => false,
isDestroyed: () => false,
getURL: () => buildDocPreviewUrl(grant.id, grant.entryRelativePath),
on: vi.fn(),
once: vi.fn(),
setWindowOpenHandler: vi.fn(),
setWebRTCIPHandlingPolicy: vi.fn()
}
installDocPreviewGuestPolicy(guest as never, { id: rendererWebContentsId, send: vi.fn() })
}
const {
guestOffMock,
@@ -60,6 +86,7 @@ describe('browserManager', () => {
beforeEach(() => {
resetBrowserManagerMocks(browserMocks)
resetBrowserManagerState()
revokeAllDocPreviewGrants()
})
afterEach(() => {
@@ -92,6 +119,77 @@ describe('browserManager', () => {
expect(browserManager.getSessionProfileIdForTab('browser-1')).toBe('work')
})
// Why both doors: one id in both halves of the registry would make the tool door answer with a
// document guest for a page the reader is browsing in.
it.each(['registerGuest', 'registerOffscreenGuest'] as const)(
'refuses %s for a page the document registry already holds',
(entryPoint) => {
const guest = {
id: 129,
isDestroyed: vi.fn(() => false),
getType: vi.fn(() => 'webview'),
setBackgroundThrottling: guestSetBackgroundThrottlingMock,
setWindowOpenHandler: guestSetWindowOpenHandlerMock,
on: guestOnMock,
off: guestOffMock,
openDevTools: guestOpenDevToolsMock
}
webContentsFromIdMock.mockReturnValue(guest)
browserManager.attachGuestPolicies(guest as never)
const browserPageId = 'doc-page-1'
registerWorkspaceDocPage(browserPageId)
if (entryPoint === 'registerGuest') {
expect(
browserManager.registerGuest({
browserPageId,
webContentsId: guest.id,
rendererWebContentsId
})
).toBe(false)
} else {
expect(
browserManager.registerOffscreenGuest({ browserPageId, webContentsId: guest.id })
).toBe(false)
}
expect(browserManager.getGuestWebContentsId(browserPageId)).toBeNull()
}
)
// Why this answer is load-bearing: the headless backend destroys its window on false, so a true
// for a guest that is already gone would leave a page id registered onto nothing.
it.each(['missing', 'destroyed'] as const)(
'refuses registerOffscreenGuest when the named guest is %s',
(guestState) => {
webContentsFromIdMock.mockReturnValue(
guestState === 'missing' ? null : { id: 137, isDestroyed: vi.fn(() => true) }
)
expect(
browserManager.registerOffscreenGuest({ browserPageId: 'offscreen-1', webContentsId: 137 })
).toBe(false)
expect(browserManager.getGuestWebContentsId('offscreen-1')).toBeNull()
}
)
// Why the exit door needs the same check: a document page withdraws by revoking its grant, so its
// id here is misaddressed — and unregistering opens by evicting whatever grab that id names.
it('refuses unregisterGuest for a page the document registry holds', () => {
registerWorkspaceDocPage('doc-page-2')
const cancelGrabOp = vi.spyOn(browserManager, 'cancelGrabOp')
browserManager.unregisterGuest('doc-page-2')
expect(cancelGrabOp).not.toHaveBeenCalled()
// The presence half: the same door does evict a browsing page's grab.
browserManager.unregisterGuest('browser-page-1')
expect(cancelGrabOp).toHaveBeenCalledWith('browser-page-1', 'evicted')
cancelGrabOp.mockRestore()
})
it('blocks non-web guest navigations after attach', () => {
const guest = {
isDestroyed: vi.fn(() => false),
@@ -0,0 +1,250 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const browserMocks = vi.hoisted(() => ({
appGetPathMock: vi.fn(() => '/downloads'),
shellOpenExternalMock: vi.fn(),
browserWindowFromWebContentsMock: vi.fn(),
menuBuildFromTemplateMock: vi.fn(),
guestOffMock: vi.fn(),
guestOnMock: vi.fn(),
guestSetBackgroundThrottlingMock: vi.fn(),
guestSetWindowOpenHandlerMock: vi.fn(),
guestOpenDevToolsMock: vi.fn(),
webContentsFromIdMock: vi.fn(),
screenGetCursorScreenPointMock: vi.fn(() => ({ x: 0, y: 0 })),
openPopupWithOriginBarMock: vi.fn()
}))
vi.mock('electron', () => ({
app: { getPath: browserMocks.appGetPathMock },
BrowserWindow: { fromWebContents: browserMocks.browserWindowFromWebContentsMock },
clipboard: { writeText: vi.fn() },
shell: { openExternal: browserMocks.shellOpenExternalMock },
Menu: { buildFromTemplate: browserMocks.menuBuildFromTemplateMock },
screen: { getCursorScreenPoint: browserMocks.screenGetCursorScreenPointMock },
webContents: { fromId: browserMocks.webContentsFromIdMock }
}))
vi.mock('./popup-origin-bar-window', () => ({
openPopupWithOriginBar: browserMocks.openPopupWithOriginBarMock
}))
import { browserManager } from './browser-manager'
import { resetBrowserManagerMocks, resetBrowserManagerState } from './browser-manager-test-harness'
import { getWorkspaceDocPageGuest } from './doc-preview-guest-policy'
import { mintDocPreviewGrant, revokeAllDocPreviewGrants } from './doc-preview-grant-registry'
import { buildDocPreviewUrl } from '../../shared/doc-preview-scheme'
type GuestFake = {
id: number
url: string
listeners: Map<string, ((...args: never[]) => void)[]>
windowOpenHandler: ((details: { url: string; frameName: string }) => unknown) | null
webRtcPolicy: string | null
isDestroyed: () => boolean
isFocused: () => boolean
getURL: () => string
getType: () => string
setBackgroundThrottling: (value: boolean) => void
setWebRTCIPHandlingPolicy: (policy: string) => void
setWindowOpenHandler: (handler: (details: { url: string; frameName: string }) => unknown) => void
executeJavaScriptInIsolatedWorld: ReturnType<typeof vi.fn>
debugger: {
isAttached: () => boolean
attach: ReturnType<typeof vi.fn>
sendCommand: ReturnType<typeof vi.fn>
}
on: (event: string, listener: (...args: never[]) => void) => void
once: (event: string, listener: (...args: never[]) => void) => void
off: (event: string, listener: (...args: never[]) => void) => void
}
function createGuest(id: number, url: string): GuestFake {
const listeners = new Map<string, ((...args: never[]) => void)[]>()
const guest: GuestFake = {
id,
url,
listeners,
windowOpenHandler: null,
webRtcPolicy: null,
isDestroyed: () => false,
isFocused: () => true,
getURL: () => guest.url,
getType: () => 'webview',
setBackgroundThrottling: vi.fn(),
setWebRTCIPHandlingPolicy: (policy: string) => {
guest.webRtcPolicy = policy
},
setWindowOpenHandler: (handler) => {
guest.windowOpenHandler = handler
},
executeJavaScriptInIsolatedWorld: vi.fn(async () => undefined),
debugger: {
isAttached: () => true,
attach: vi.fn(),
sendCommand: vi.fn(async () => undefined)
},
on: (event, listener) => {
listeners.set(event, [...(listeners.get(event) ?? []), listener])
},
once: (event, listener) => {
listeners.set(event, [...(listeners.get(event) ?? []), listener])
},
off: (event, listener) => {
listeners.set(
event,
(listeners.get(event) ?? []).filter((entry) => entry !== listener)
)
}
}
return guest
}
function listenerCount(guest: GuestFake, event: string): number {
return guest.listeners.get(event)?.length ?? 0
}
/** Drives the guest's own will-navigate listeners and reports whether they refused. */
function navigateTo(guest: GuestFake, url: string): boolean {
let prevented = false
const event = { preventDefault: () => (prevented = true) } as never
for (const listener of guest.listeners.get('will-navigate') ?? []) {
;(listener as (event: unknown, url: string) => void)(event, url)
}
return prevented
}
const host = { id: 5001, send: vi.fn() } as unknown as Electron.WebContents
beforeEach(() => {
resetBrowserManagerMocks(browserMocks)
resetBrowserManagerState()
revokeAllDocPreviewGrants()
browserMocks.guestSetBackgroundThrottlingMock.mockReturnValue(undefined)
})
describe('guest policy profiles', () => {
function attachPreviewGuest(id = 301): {
guest: GuestFake
grantId: string
browserPageId: string
} {
const browserPageId = `doc-page-${id}`
const grant = mintDocPreviewGrant({
owner: { kind: 'ssh', connectionId: 'ssh-1' },
root: '/home/alice/docs',
entryRelativePath: 'index.html',
browserPageId
})
const guest = createGuest(id, buildDocPreviewUrl(grant.id, 'index.html'))
browserManager.attachGuestPolicies(guest as never, null, { profile: 'workspace-doc', host })
return { guest, grantId: grant.id, browserPageId }
}
// The presence half of every absence below: a browsing guest observably takes all of it through
// the same method, so a profile that fenced nothing — or an attach path that stopped installing
// anything at all — cannot pass these by being uniformly empty.
it('gives a browsing guest link routing, popups and anti-detection', () => {
const guest = createGuest(300, 'https://example.com/')
browserManager.attachGuestPolicies(guest as never)
expect(listenerCount(guest, 'dom-ready')).toBe(1)
expect(listenerCount(guest, 'frame-created')).toBe(1)
expect(listenerCount(guest, 'did-create-window')).toBe(1)
expect(guest.debugger.sendCommand).toHaveBeenCalled()
expect(navigateTo(guest, 'https://elsewhere.example/')).toBe(false)
})
it('gives a workspace-document guest none of it', () => {
const { guest } = attachPreviewGuest()
expect(listenerCount(guest, 'dom-ready')).toBe(0)
expect(listenerCount(guest, 'frame-created')).toBe(0)
expect(listenerCount(guest, 'did-create-window')).toBe(0)
expect(guest.debugger.sendCommand).not.toHaveBeenCalled()
expect(guest.executeJavaScriptInIsolatedWorld).not.toHaveBeenCalled()
})
it('holds a workspace-document guest inside the grant it is showing', () => {
const { guest, grantId } = attachPreviewGuest()
expect(navigateTo(guest, buildDocPreviewUrl(grantId, 'other.html'))).toBe(false)
expect(navigateTo(guest, 'https://example.com/')).toBe(true)
expect(guest.webRtcPolicy).toBe('disable_non_proxied_udp')
})
it('denies every window a workspace-document guest asks for', () => {
const { guest } = attachPreviewGuest()
expect(guest.windowOpenHandler?.({ url: 'https://example.com/', frameName: '' })).toEqual({
action: 'deny'
})
})
// Why teardown and not just install: registration and teardown both key on the guest having been
// policy-attached, so a profile that installs outside that bookkeeping leaves the id marked
// attached forever and stays answerable to tools after the surface is gone.
it('tears a workspace-document guest down through the same policy cleanup', () => {
const { guest, browserPageId } = attachPreviewGuest(302)
expect(getWorkspaceDocPageGuest(browserPageId, host.id)).toBe(guest as never)
for (const listener of guest.listeners.get('destroyed') ?? []) {
listener()
}
expect(getWorkspaceDocPageGuest(browserPageId, host.id)).toBeNull()
// The manager's own bookkeeping was reached too: a second attach is refused while the id is
// still marked policy-attached, and accepted once its teardown has run.
browserManager.attachGuestPolicies(guest as never)
expect(listenerCount(guest, 'dom-ready')).toBe(1)
})
// The seam the whole split rests on: one public door answers for both halves, and each page id
// resolves in exactly one of them. Asserted against the real manager, because the IPC census
// test has to mock it.
describe('the one door across both halves of the registry', () => {
const BROWSING_PAGE_ID = 'browser-page-1'
function registerBrowsingGuest(id = 400): GuestFake {
const guest = createGuest(id, 'https://example.com/')
browserMocks.webContentsFromIdMock.mockReturnValue(guest)
browserManager.attachGuestPolicies(guest as never)
browserManager.registerGuest({
browserPageId: BROWSING_PAGE_ID,
workspaceId: 'workspace-1',
worktreeId: 'wt-1',
webContentsId: id,
rendererWebContentsId: host.id
})
return guest
}
it('answers each page with the guest of its own half', () => {
const browsing = registerBrowsingGuest()
const { guest: document, browserPageId } = attachPreviewGuest()
expect(browserManager.getAuthorizedGuest(BROWSING_PAGE_ID, host.id)).toBe(browsing as never)
expect(browserManager.getAuthorizedGuest(browserPageId, host.id)).toBe(document as never)
})
// Why this is the containment claim and not a lookup detail: page management, agent commands,
// download routing and certificate attribution all read the browsing map directly, so a
// document page being absent from it is what fences them without a guard of their own.
it('keeps a document page out of the browsing map entirely', () => {
registerBrowsingGuest()
const { browserPageId } = attachPreviewGuest()
expect(browserManager.getGuestWebContentsId(browserPageId)).toBeNull()
expect([...browserManager.getWebContentsIdByTabId().keys()]).toEqual([BROWSING_PAGE_ID])
})
})
it('attaches a guest once whatever profile it was asked for', () => {
const { guest } = attachPreviewGuest(303)
browserManager.attachGuestPolicies(guest as never)
expect(listenerCount(guest, 'dom-ready')).toBe(0)
expect(listenerCount(guest, 'will-navigate')).toBe(1)
})
})
@@ -882,34 +882,5 @@ describe('browserManager', () => {
expect(debuggerAttach).toHaveBeenCalledWith('1.3')
expect(debuggerSendCommand).not.toHaveBeenCalled()
})
it('installs annotation viewport bridge in an isolated world', async () => {
const { guest } = makeGuest(4646)
webContentsFromIdMock.mockReturnValue(guest)
browserManager.attachGuestPolicies(guest as never)
browserManager.registerGuest({
browserPageId: 'tab-annotations',
webContentsId: guest.id as number,
rendererWebContentsId
})
const ok = await browserManager.setAnnotationViewportBridge('tab-annotations', {
emitViewport: false,
enabled: true,
markers: [],
token: 'annotationviewporttoken'
})
expect(ok).toBe(true)
expect(guest.executeJavaScriptInIsolatedWorld).toHaveBeenCalledWith(
expect.any(Number),
[
expect.objectContaining({
code: expect.stringContaining('__orcaBrowserAnnotationViewportBridge')
})
],
false
)
})
})
})
+91 -15
View File
@@ -63,6 +63,11 @@ import {
BROWSER_ANNOTATION_VIEWPORT_BRIDGE_WORLD_ID,
buildBrowserAnnotationViewportBridgeScript
} from '../../shared/browser-annotation-viewport-bridge'
import {
getWorkspaceDocPageGuest,
installDocPreviewGuestPolicy,
isWorkspaceDocPageId
} from './doc-preview-guest-policy'
import type { KeybindingOverrides } from '../../shared/keybindings'
import {
BrowserCertificateTrustController,
@@ -157,6 +162,15 @@ type PopupOwnerContext = {
browserTabId: string
rootGuestWebContentsId: number
}
/**
* What a guest is allowed to be. A browsing guest is the web — popups, clicked-link routing and
* anti-detection all apply. A workspace-document guest renders one granted document and gets none
* of that; `host` is the renderer that minted its grant, and the only sink for what it reports.
*/
export type BrowserGuestPolicy =
| { profile: 'browsing' }
| { profile: 'workspace-doc'; host: Electron.WebContents }
const BROWSING_GUEST_POLICY: BrowserGuestPolicy = { profile: 'browsing' }
type PendingMainFrameNavigation = {
currentUrl: string
supersededUrls: string[]
@@ -671,12 +685,20 @@ export class BrowserManager {
attachGuestPolicies(
guest: Electron.WebContents,
inheritedOwnerContext: PopupOwnerContext | null = null
inheritedOwnerContext: PopupOwnerContext | null = null,
policy: BrowserGuestPolicy = BROWSING_GUEST_POLICY
): void {
if (this.policyAttachedGuestIds.has(guest.id)) {
return
}
this.policyAttachedGuestIds.add(guest.id)
// Why one door with a profile rather than a second installer beside it: whether a guest was
// policy-attached at all is what registration and teardown both key on, so a guest that took
// another path into the app is invisible to both.
if (policy.profile === 'workspace-doc') {
this.attachWorkspaceDocGuestPolicies(guest, policy.host)
return
}
if (inheritedOwnerContext) {
this.popupOwnerContextByGuestId.set(guest.id, inheritedOwnerContext)
}
@@ -1016,6 +1038,30 @@ export class BrowserManager {
})
}
/**
* A workspace document is not the web: no popups, no link routing, no anti-detection, and no
* navigation bookkeeping for chrome it does not have. What it does share with a browsing guest is
* this method's teardown, so a retired preview drops its listeners on the same path.
*/
private attachWorkspaceDocGuestPolicies(
guest: Electron.WebContents,
host: Electron.WebContents
): void {
const disposeDocPolicy = installDocPreviewGuestPolicy(guest, host)
const handleDestroyed = (): void => {
this.cleanupGuestPolicyAttachment(guest.id)
}
guest.on('destroyed', handleDestroyed)
this.policyCleanupByGuestId.set(guest.id, () => {
disposeDocPolicy()
try {
guest.off('destroyed', handleDestroyed)
} catch {
// guest may already be destroyed
}
})
}
// Why: navigator.userAgent (read by Google's auth JS) reflects the WebContents UA,
// not the request header, so the header-level Firefox switch in setupClientHintsOverride
// must be matched here per navigation or the two layers disagree — itself a bot tell.
@@ -1318,7 +1364,9 @@ export class BrowserManager {
rendererWebContentsId
}: BrowserGuestRegistration): boolean {
const browserTabId = browserPageId ?? legacyBrowserTabId
if (!browserTabId) {
// Why refuse rather than overwrite: the two halves of the registry must stay disjoint, or one
// id resolves in both and the tool door silently prefers the document guest over the page.
if (!browserTabId || isWorkspaceDocPageId(browserTabId)) {
return false
}
// Why: on guest-surface swap, cancel any grab bound to the old guest's listeners so it doesn't strand on a stale webContents.
@@ -1377,6 +1425,12 @@ export class BrowserManager {
}
unregisterGuest(browserTabId: string): void {
// Why the check on the exit door too: a document page withdraws by revoking its grant, never
// through here, so its id arriving is misaddressed — and the cancel below would evict that
// preview's live grab on the strength of it.
if (isWorkspaceDocPageId(browserTabId)) {
return
}
// Why: teardown mid-grab must cancel it so the renderer gets a signal, not a dangling Promise.
this.cancelGrabOp(browserTabId, 'evicted')
@@ -1444,10 +1498,15 @@ export class BrowserManager {
sessionProfileId?: string | null
userAgentMode?: BrowserSessionUserAgentMode
webContentsId: number
}): void {
}): boolean {
// Why the same check on both registration doors: one id resolving in both halves is the exact
// confusion the split registries exist to prevent.
if (isWorkspaceDocPageId(browserPageId)) {
return false
}
const guest = webContents.fromId(webContentsId)
if (!guest || guest.isDestroyed()) {
return
return false
}
// Why: offscreen pages have no renderer webview listeners, so main owns their load-failure lifecycle.
this.offscreenGuestIds.add(webContentsId)
@@ -1468,6 +1527,7 @@ export class BrowserManager {
this.worktreeIdByTabId.set(browserPageId, worktreeId)
}
this.certificateTrustController?.onGuestRegistered(webContentsId, browserPageId)
return true
}
unregisterAll(): void {
@@ -1843,12 +1903,13 @@ export class BrowserManager {
async setAnnotationViewportBridge(
browserTabId: string,
options: BrowserAnnotationViewportBridgeOptions
options: BrowserAnnotationViewportBridgeOptions,
resolveGuest: () => Electron.WebContents | null
): Promise<boolean> {
const prev = this.annotationViewportBridgeOpsByTabId.get(browserTabId) ?? Promise.resolve()
const next = prev
.catch(() => {})
.then(() => this.doSetAnnotationViewportBridgeImpl(browserTabId, options))
.then(() => this.doSetAnnotationViewportBridgeImpl(options, resolveGuest))
this.annotationViewportBridgeOpsByTabId.set(browserTabId, next)
try {
return await next
@@ -1859,18 +1920,22 @@ export class BrowserManager {
}
}
// Why the caller resolves the guest: the same bridge serves browsing pages and workspace
// documents, which live in different halves of the page registry.
// Why a resolver and not the guest itself: this op may have waited behind another one, and a
// cross-process navigation meanwhile swaps the tab's contents without destroying the old one —
// injecting into the guest the request named would bridge a page nobody is looking at.
// Why no tab id: with teardown gone this reaches only the guest the resolver hands back, and
// taking an id it cannot act on would invite the next reader to act on it.
private async doSetAnnotationViewportBridgeImpl(
browserTabId: string,
options: BrowserAnnotationViewportBridgeOptions
options: BrowserAnnotationViewportBridgeOptions,
resolveGuest: () => Electron.WebContents | null
): Promise<boolean> {
const webContentsId = this.webContentsIdByTabId.get(browserTabId)
if (!webContentsId) {
return false
}
const guest = webContents.fromId(webContentsId)
// Why no teardown here: the resolver already unregisters a page whose guest died, and the only
// case it uniquely leaves is an ownership mismatch on a healthy page — where tearing down would
// cancel that page's in-flight downloads and grabs over a request that was merely misaddressed.
const guest = resolveGuest()
if (!guest || guest.isDestroyed()) {
// Why: a stale guest must clear every per-tab registry entry, not just the WebContents maps.
this.unregisterGuest(browserTabId)
return false
}
@@ -1977,10 +2042,21 @@ export class BrowserManager {
// --- Browser Context Grab — main-owned operations ---
/** Validate that the sender owns browserTabId; returns the guest WebContents or null. */
/**
* The guest a request from `senderWebContentsId` may act on, across both halves of the page
* registry. This is the only door taught about workspace-document guests: they are kept out of
* the browsing maps entirely, so page management, agent commands, download routing and
* certificate attribution all miss them without a guard of their own — and a reader who opens a
* tool on the document in front of them still gets an answer.
*/
getAuthorizedGuest(
browserTabId: string,
senderWebContentsId: number
): Electron.WebContents | null {
const docGuest = getWorkspaceDocPageGuest(browserTabId, senderWebContentsId)
if (docGuest) {
return docGuest
}
const registeredRenderer = this.rendererWebContentsIdByTabId.get(browserTabId)
if (registeredRenderer == null || registeredRenderer !== senderWebContentsId) {
return null
@@ -0,0 +1,185 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { BrowserSessionProfile } from '../../shared/browser-workspace-types'
const mocks = vi.hoisted(() => ({
handleGuestWillDownload: vi.fn(),
noticeDocPreviewDownloadBlocked: vi.fn()
}))
type WillDownloadListener = (
event: { preventDefault: () => void },
item: { id: string },
webContents: { id: number }
) => void
type FakeSession = {
listeners: WillDownloadListener[]
on: ReturnType<typeof vi.fn>
removeListener: ReturnType<typeof vi.fn>
getUserAgent: () => string
setUserAgent: ReturnType<typeof vi.fn>
setPermissionRequestHandler: ReturnType<typeof vi.fn>
setPermissionCheckHandler: ReturnType<typeof vi.fn>
setDisplayMediaRequestHandler: ReturnType<typeof vi.fn>
}
const sessionsByPartition = new Map<string, FakeSession>()
function fakeSession(): FakeSession {
const listeners: WillDownloadListener[] = []
return {
listeners,
on: vi.fn((event: string, listener: WillDownloadListener) => {
if (event === 'will-download') {
listeners.push(listener)
}
}),
removeListener: vi.fn((event: string, listener: WillDownloadListener) => {
if (event !== 'will-download') {
return
}
const index = listeners.indexOf(listener)
if (index !== -1) {
listeners.splice(index, 1)
}
}),
getUserAgent: () => 'Mozilla/5.0 Orca',
setUserAgent: vi.fn(),
setPermissionRequestHandler: vi.fn(),
setPermissionCheckHandler: vi.fn(),
setDisplayMediaRequestHandler: vi.fn()
}
}
vi.mock('electron', () => ({
session: {
fromPartition: (partition: string) => {
const existing = sessionsByPartition.get(partition)
if (existing) {
return existing
}
const created = fakeSession()
sessionsByPartition.set(partition, created)
return created
}
}
}))
vi.mock('./browser-manager', () => ({
browserManager: {
handleGuestWillDownload: mocks.handleGuestWillDownload,
installCertificateRequestGuard: vi.fn(),
removeCertificateRequestGuard: vi.fn(),
notifyPermissionDenied: vi.fn()
}
}))
vi.mock('./doc-preview-download-block-notice', () => ({
noticeDocPreviewDownloadBlocked: mocks.noticeDocPreviewDownloadBlocked
}))
vi.mock('./browser-media-access', () => ({
hasSystemMediaAccess: () => false,
requestSystemMediaAccess: async () => false
}))
vi.mock('./browser-session-ua', () => ({
cleanElectronUserAgent: (userAgent: string) => userAgent,
setupClientHintsOverride: vi.fn()
}))
vi.mock('./browser-session-user-agent-mode', () => ({
setBrowserSessionUserAgentMode: vi.fn()
}))
vi.mock('./browser-webauthn-access', () => ({
allowsBrowserWebAuthnPermission: () => false,
clearBrowserWebAuthnAccessHandlers: vi.fn(),
installBrowserWebAuthnAccessHandlers: vi.fn()
}))
type PartitionPolicyInstaller = (
profile: BrowserSessionProfile,
options?: { downloads?: 'route' | 'deny' }
) => void
// Why imported per test rather than at the top: the installer remembers which partitions it has
// already configured in module state, so a shared import would make the second test's install a
// no-op and leave it reading the first test's listener.
async function loadInstaller(): Promise<PartitionPolicyInstaller> {
const module = await import('./browser-session-partition-policies')
return module.installBrowserSessionPartitionPolicies
}
function profileFor(partition: string): BrowserSessionProfile {
return {
id: partition,
scope: 'isolated',
partition,
label: partition,
source: null,
userAgentMode: 'clean'
}
}
/** Fires the partition's real `will-download` listener and reports what it decided. */
function fireWillDownload(partition: string): { cancelled: boolean } {
const sess = sessionsByPartition.get(partition)
if (!sess || sess.listeners.length !== 1) {
throw new Error(`expected exactly one will-download listener on ${partition}`)
}
let cancelled = false
sess.listeners[0]({ preventDefault: () => (cancelled = true) }, { id: 'item-1' }, { id: 42 })
return { cancelled }
}
beforeEach(() => {
vi.clearAllMocks()
sessionsByPartition.clear()
vi.resetModules()
})
describe('partition download policy', () => {
// The presence half: without it, a deny assertion passes for a partition that installed no
// listener at all, and would keep passing if the whole download path were removed.
it('routes a download on a partition that did not ask for the deny', async () => {
const install = await loadInstaller()
install(profileFor('persist:browsing-1'))
expect(fireWillDownload('persist:browsing-1').cancelled).toBe(false)
expect(mocks.handleGuestWillDownload).toHaveBeenCalledWith(
expect.objectContaining({ guestWebContentsId: 42 })
)
})
it('cancels a download on a partition that asked for the deny, routing nothing', async () => {
const install = await loadInstaller()
install(profileFor('orca-doc-preview'), { downloads: 'deny' })
expect(fireWillDownload('orca-doc-preview').cancelled).toBe(true)
expect(mocks.handleGuestWillDownload).not.toHaveBeenCalled()
})
// Why in the same run as the routing test above: a refusal the reader cannot see is a pressed
// button that does nothing, and a notice on the routing partition would announce a download that
// is about to arrive normally.
it('tells the reader about the refusal, and only on the partition that refused', async () => {
const install = await loadInstaller()
install(profileFor('orca-doc-preview'), { downloads: 'deny' })
install(profileFor('persist:browsing-1'))
fireWillDownload('persist:browsing-1')
expect(mocks.noticeDocPreviewDownloadBlocked).not.toHaveBeenCalled()
fireWillDownload('orca-doc-preview')
expect(mocks.noticeDocPreviewDownloadBlocked).toHaveBeenCalledWith(
expect.objectContaining({ id: 42 })
)
})
// Why both partitions in one run: the listener is module state shared across sessions, so a deny
// installed for one partition must not follow the next partition that installs after it.
it('keeps each partition on its own decision', async () => {
const install = await loadInstaller()
install(profileFor('orca-doc-preview'), { downloads: 'deny' })
install(profileFor('persist:browsing-1'))
expect(fireWillDownload('orca-doc-preview').cancelled).toBe(true)
expect(fireWillDownload('persist:browsing-1').cancelled).toBe(false)
expect(mocks.handleGuestWillDownload).toHaveBeenCalledTimes(1)
})
})
@@ -11,6 +11,7 @@ import {
clearBrowserWebAuthnAccessHandlers,
installBrowserWebAuthnAccessHandlers
} from './browser-webauthn-access'
import { noticeDocPreviewDownloadBlocked } from './doc-preview-download-block-notice'
// Why: one shared installer keeps every partition's deny-by-default permission/download policies from drifting apart.
const configuredPartitions = new Set<string>()
@@ -22,6 +23,23 @@ const handleWillDownload = (
browserManager.handleGuestWillDownload({ guestWebContentsId: webContents.id, item })
}
/**
* Why a second listener instead of a branch inside the shared one: `will-download` is a session
* event that names no partition, so the only place the decision can be keyed by partition is which
* listener that partition's session got. A workspace-document guest has no page of its own to
* attribute a download to, so routing one lands it in this desktop's Downloads folder under a
* remote-authored name that nothing in the UI accounts for.
*/
const handleDeniedWillDownload = (
event: Electron.Event,
_item: Electron.DownloadItem,
webContents: Electron.WebContents
): void => {
event.preventDefault()
// The page gets nothing back; the reader gets a sentence, or a pressed button just does nothing.
noticeDocPreviewDownloadBlocked(webContents)
}
function resolvePermissionNoticeUrl(
webContents: Electron.WebContents,
details: Electron.PermissionRequest | undefined
@@ -37,7 +55,13 @@ function resolvePermissionNoticeUrl(
}
}
export function installBrowserSessionPartitionPolicies(profile: BrowserSessionProfile): void {
/** `route` hands the item to the owning page's download flow; `deny` cancels it before it starts. */
export type BrowserPartitionDownloadPolicy = 'route' | 'deny'
export function installBrowserSessionPartitionPolicies(
profile: BrowserSessionProfile,
options?: { downloads?: BrowserPartitionDownloadPolicy }
): void {
const { partition } = profile
const sess = session.fromPartition(partition)
setBrowserSessionUserAgentMode(sess, profile.userAgentMode ?? 'clean')
@@ -106,7 +130,11 @@ export function installBrowserSessionPartitionPolicies(profile: BrowserSessionPr
callback({ video: undefined, audio: undefined })
})
sess.removeListener('will-download', handleWillDownload)
sess.on('will-download', handleWillDownload)
sess.removeListener('will-download', handleDeniedWillDownload)
sess.on(
'will-download',
options?.downloads === 'deny' ? handleDeniedWillDownload : handleWillDownload
)
configuredPartitions.add(partition)
}
@@ -115,6 +143,7 @@ export function clearBrowserSessionPartitionPolicies(partition: string, sess: Se
configuredPartitions.delete(partition)
browserManager.removeCertificateRequestGuard(sess)
sess.removeListener('will-download', handleWillDownload)
sess.removeListener('will-download', handleDeniedWillDownload)
clearBrowserWebAuthnAccessHandlers(sess)
sess.setPermissionRequestHandler(null)
sess.setPermissionCheckHandler(null)
@@ -0,0 +1,107 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
publishDocPreviewFailure: vi.fn(),
boundGrantIdByGuest: new Map<object, string>()
}))
vi.mock('./doc-preview-failure-notice', () => ({
publishDocPreviewFailure: mocks.publishDocPreviewFailure
}))
vi.mock('./doc-preview-guest-policy', () => ({
readDocPreviewGuestBoundGrantId: (guest: object) => mocks.boundGrantIdByGuest.get(guest) ?? null
}))
const GRANT_ID = 'a'.repeat(32)
const OTHER_GRANT_ID = 'b'.repeat(32)
/** Only the identity matters: the module asks the guest registry what grant this contents holds. */
function guestBoundTo(grantId: string | null): Electron.WebContents {
const guest = {} as Electron.WebContents
if (grantId !== null) {
mocks.boundGrantIdByGuest.set(guest, grantId)
}
return guest
}
async function loadNotifier(): Promise<(guest: Electron.WebContents) => void> {
const module = await import('./doc-preview-download-block-notice')
return module.noticeDocPreviewDownloadBlocked
}
beforeEach(() => {
vi.clearAllMocks()
mocks.boundGrantIdByGuest.clear()
// Why per test: the module remembers which grants it has already told the reader about.
vi.resetModules()
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-08-27T00:00:00.000Z'))
})
afterEach(() => {
vi.useRealTimers()
})
describe('noticeDocPreviewDownloadBlocked', () => {
it('tells the shell which preview had a download refused', async () => {
const notice = await loadNotifier()
notice(guestBoundTo(GRANT_ID))
expect(mocks.publishDocPreviewFailure).toHaveBeenCalledWith({
grantId: GRANT_ID,
reason: 'download-blocked'
})
})
// Why: a document can ask in a loop. The reader learns nothing from the second notice, and every
// attempt would otherwise cross the IPC boundary and re-render the strip.
it('says it once however often the document asks', async () => {
const notice = await loadNotifier()
const guest = guestBoundTo(GRANT_ID)
notice(guest)
notice(guest)
vi.advanceTimersByTime(1_500)
notice(guest)
expect(mocks.publishDocPreviewFailure).toHaveBeenCalledTimes(1)
})
it('says it again for an attempt long after the last one', async () => {
const notice = await loadNotifier()
const guest = guestBoundTo(GRANT_ID)
notice(guest)
vi.advanceTimersByTime(2_500)
notice(guest)
expect(mocks.publishDocPreviewFailure).toHaveBeenCalledTimes(2)
})
// Why not one throttle for the whole app: two previews are two readers, and silencing the second
// because the first just refused something leaves that press unexplained.
it('throttles each preview on its own', async () => {
const notice = await loadNotifier()
notice(guestBoundTo(GRANT_ID))
notice(guestBoundTo(OTHER_GRANT_ID))
expect(mocks.publishDocPreviewFailure).toHaveBeenCalledTimes(2)
expect(mocks.publishDocPreviewFailure).toHaveBeenLastCalledWith({
grantId: OTHER_GRANT_ID,
reason: 'download-blocked'
})
})
// The absence half of the first test: no shell is showing this contents, so there is no preview
// to put a notice on. Without the presence tests above, this would pass on a module that never
// published anything at all.
it('says nothing for a contents no preview is bound to', async () => {
const notice = await loadNotifier()
notice(guestBoundTo(null))
expect(mocks.publishDocPreviewFailure).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,34 @@
import { publishDocPreviewFailure } from './doc-preview-failure-notice'
import { readDocPreviewGuestBoundGrantId } from './doc-preview-guest-policy'
/**
* Why the reader is told at all, when the point of the fence is that nothing happens: a press that
* produces no file and no explanation reads as Orca being broken. The notice is chrome the document
* cannot see or read back, so the refusal stays as silent to the page as it was.
*
* Why a floor between notices: a document can ask in a loop, and every attempt would otherwise
* cross the IPC boundary and re-render the strip. The reader learns nothing from the thousandth.
*/
const NOTICE_MIN_INTERVAL_MS = 2_000
const noticedAtByGrantId = new Map<string, number>()
export function noticeDocPreviewDownloadBlocked(guest: Electron.WebContents): void {
const grantId = readDocPreviewGuestBoundGrantId(guest)
// Nothing to route a notice to: no shell is showing this contents as a preview.
if (grantId === null) {
return
}
const now = Date.now()
// Doubles as the expiry check, so what survives is exactly "noticed inside the window", and a
// grant that stops downloading stops being remembered.
for (const [noticedGrantId, noticedAt] of noticedAtByGrantId) {
if (now - noticedAt >= NOTICE_MIN_INTERVAL_MS) {
noticedAtByGrantId.delete(noticedGrantId)
}
}
if (noticedAtByGrantId.has(grantId)) {
return
}
noticedAtByGrantId.set(grantId, now)
publishDocPreviewFailure({ grantId, reason: 'download-blocked' })
}
@@ -0,0 +1,70 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { DOC_PREVIEW_LOAD_FAILURE_CHANNEL } from '../../shared/doc-preview-scheme'
import { publishDocPreviewFailure, setDocPreviewFailureSink } from './doc-preview-failure-notice'
afterEach(() => {
setDocPreviewFailureSink(null)
})
describe('publishDocPreviewFailure', () => {
it('sends the grant, path, and reason on the failure channel', () => {
const send = vi.fn()
setDocPreviewFailureSink({ send })
publishDocPreviewFailure({
grantId: 'a'.repeat(32),
relativePath: 'index.html',
reason: 'too-large'
})
expect(send).toHaveBeenCalledWith(DOC_PREVIEW_LOAD_FAILURE_CHANNEL, {
grantId: 'a'.repeat(32),
relativePath: 'index.html',
reason: 'too-large'
})
})
// Why: reads can outlive the window that asked for them; a missing sink must not throw inside
// the protocol handler.
it('is a no-op with no sink registered', () => {
expect(() =>
publishDocPreviewFailure({
grantId: 'b'.repeat(32),
relativePath: 'index.html',
reason: 'unreadable'
})
).not.toThrow()
})
it('sends nothing into a destroyed window', () => {
const send = vi.fn()
setDocPreviewFailureSink({ send, isDestroyed: () => true })
publishDocPreviewFailure({
grantId: 'c'.repeat(32),
relativePath: 'index.html',
reason: 'unreadable'
})
expect(send).not.toHaveBeenCalled()
})
// Why: a WebContents can be torn down between the liveness check and the send.
it('swallows a throwing sink and stops using it', () => {
const send = vi.fn(() => {
throw new Error('Object has been destroyed')
})
setDocPreviewFailureSink({ send })
const failure = {
grantId: 'd'.repeat(32),
relativePath: 'index.html',
reason: 'unreadable' as const
}
expect(() => publishDocPreviewFailure(failure)).not.toThrow()
publishDocPreviewFailure(failure)
expect(send).toHaveBeenCalledTimes(1)
})
})
@@ -0,0 +1,34 @@
import {
DOC_PREVIEW_LOAD_FAILURE_CHANNEL,
type DocPreviewFailure
} from '../../shared/doc-preview-scheme'
type DocPreviewFailureSink = {
send: (channel: string, payload: DocPreviewFailure) => void
isDestroyed?: () => boolean
}
let failureSink: DocPreviewFailureSink | null = null
export function setDocPreviewFailureSink(sink: DocPreviewFailureSink | null): void {
failureSink = sink
}
/**
* The preview shell cannot read the guest's HTTP status, and a 4xx body renders as
* if it were the document. Pushing the reason lets the shell replace that with a
* localized notice for the failure the user actually hit.
*/
export function publishDocPreviewFailure(failure: DocPreviewFailure): void {
const sink = failureSink
// Why: a read can outlive the window that asked for it, and sending into torn-down
// WebContents throws — an unreadable asset must not take the protocol handler with it.
if (!sink || sink.isDestroyed?.()) {
return
}
try {
sink.send(DOC_PREVIEW_LOAD_FAILURE_CHANNEL, failure)
} catch {
failureSink = null
}
}
@@ -0,0 +1,333 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
callRuntimeEnvironment: vi.fn(),
readFile: vi.fn(),
realpath: vi.fn(),
requireSshFilesystemProvider: vi.fn()
}))
vi.mock('../ipc/runtime-environment-transport-routing', () => ({
callRuntimeEnvironment: mocks.callRuntimeEnvironment
}))
vi.mock('../persistence', () => ({ getCanonicalUserDataPath: () => '/user-data' }))
vi.mock('../providers/ssh-filesystem-dispatch', () => ({
requireSshFilesystemProvider: mocks.requireSshFilesystemProvider
}))
import { FileReadCapExceededError } from '../ssh/ssh-filesystem-stream-reader'
import { docPreviewContentType, readDocPreviewFile } from './doc-preview-file-reader'
import { mintDocPreviewGrant, revokeAllDocPreviewGrants } from './doc-preview-grant-registry'
function sshGrant(): ReturnType<typeof mintDocPreviewGrant> {
return mintDocPreviewGrant({
owner: { kind: 'ssh', connectionId: 'ssh-1' },
root: '/home/alice/docs',
entryRelativePath: 'index.html',
browserPageId: 'page-1'
})
}
function runtimeGrant(root = '/srv/repo/docs'): ReturnType<typeof mintDocPreviewGrant> {
return mintDocPreviewGrant({
owner: {
kind: 'runtime',
environmentId: 'env-1',
worktreeSelector: 'id:wt-1',
worktreeRoot: '/srv/repo'
},
root,
entryRelativePath: 'index.html',
browserPageId: 'page-1'
})
}
beforeEach(() => {
vi.clearAllMocks()
revokeAllDocPreviewGrants()
// Why: an unsymlinked host canonicalizes to the path it was given.
mocks.realpath.mockImplementation((path: string) => Promise.resolve(path))
mocks.requireSshFilesystemProvider.mockReturnValue({
readFile: mocks.readFile,
realpath: mocks.realpath
})
})
describe('docPreviewContentType', () => {
it('maps document and asset extensions, defaulting to octet-stream', () => {
expect(docPreviewContentType('index.html')).toBe('text/html; charset=utf-8')
expect(docPreviewContentType('assets/app.CSS')).toBe('text/css; charset=utf-8')
expect(docPreviewContentType('assets/logo.png')).toBe('image/png')
expect(docPreviewContentType('data.bin')).toBe('application/octet-stream')
})
})
describe('readDocPreviewFile — ssh owner', () => {
it('reads text through the SSH filesystem provider', async () => {
mocks.readFile.mockResolvedValue({ content: '<h1>hi</h1>', isBinary: false })
const outcome = await readDocPreviewFile(sshGrant(), 'index.html')
expect(mocks.requireSshFilesystemProvider).toHaveBeenCalledWith('ssh-1')
expect(mocks.readFile).toHaveBeenCalledWith('/home/alice/docs/index.html')
expect(outcome).toEqual({
ok: true,
bytes: Buffer.from('<h1>hi</h1>', 'utf8'),
contentType: 'text/html; charset=utf-8'
})
})
it('decodes a base64 binary asset', async () => {
const png = Buffer.from([0x89, 0x50, 0x4e, 0x47])
mocks.readFile.mockResolvedValue({ content: png.toString('base64'), isBinary: true })
const outcome = await readDocPreviewFile(sshGrant(), 'assets/logo.png')
expect(outcome).toEqual({ ok: true, bytes: png, contentType: 'image/png' })
})
// Why: the SSH reader rejects an over-cap file rather than clamping it, so a completed read is
// always whole and needs no truncation flag.
it('serves a whole SSH read that carries no truncation flag', async () => {
mocks.readFile.mockResolvedValue({ content: '<h1>whole</h1>', isBinary: false })
expect(await readDocPreviewFile(sshGrant(), 'index.html')).toMatchObject({ ok: true })
})
// Why: the SSH read path only serves images and PDFs as bytes, so a font is refused there by
// design — the failure must name the file type, not a stale server.
it('reports a file type the host will not send as unsupported-asset', async () => {
mocks.readFile.mockResolvedValue({ content: '', isBinary: true })
const outcome = await readDocPreviewFile(sshGrant(), 'assets/font.woff2')
expect(outcome).toMatchObject({ ok: false, status: 415, reason: 'unsupported-asset' })
})
// Why: a host that still named the type read a 0-byte file, so 0 bytes is the honest answer —
// reporting it as a refused format would be a failure the workspace never reported.
it('serves an empty file the host still typed instead of calling it unsupported', async () => {
mocks.readFile.mockResolvedValue({ content: '', isBinary: true, mimeType: 'image/png' })
const outcome = await readDocPreviewFile(sshGrant(), 'assets/logo.png')
expect(outcome).toEqual({ ok: true, bytes: Buffer.alloc(0), contentType: 'image/png' })
})
// Why: containment above is lexical, and the SSH read RPC enforces no root of its own, so a
// symlink inside the grant would otherwise read anything the account can reach.
it('404s a path that canonicalizes outside the grant root', async () => {
mocks.realpath.mockImplementation((path: string) =>
Promise.resolve(path === '/home/alice/docs/escape.html' ? '/etc/shadow' : path)
)
const outcome = await readDocPreviewFile(sshGrant(), 'escape.html')
expect(outcome).toMatchObject({ ok: false, status: 404 })
expect(mocks.readFile).not.toHaveBeenCalled()
})
it('reads the canonical path once containment holds', async () => {
mocks.realpath.mockImplementation((path: string) =>
Promise.resolve(path === '/home/alice/docs/link.html' ? '/home/alice/docs/real.html' : path)
)
mocks.readFile.mockResolvedValue({ content: '<h1>real</h1>', isBinary: false })
expect(await readDocPreviewFile(sshGrant(), 'link.html')).toMatchObject({ ok: true })
expect(mocks.readFile).toHaveBeenCalledWith('/home/alice/docs/real.html')
})
// Why: a symlinked root is legitimate; containment must be judged on what both sides resolve to.
it('keeps serving a grant whose own root is a symlink', async () => {
mocks.realpath.mockImplementation((path: string) =>
Promise.resolve(path.replace('/home/alice/docs', '/mnt/data/docs'))
)
mocks.readFile.mockResolvedValue({ content: '<h1>hi</h1>', isBinary: false })
expect(await readDocPreviewFile(sshGrant(), 'index.html')).toMatchObject({ ok: true })
expect(mocks.readFile).toHaveBeenCalledWith('/mnt/data/docs/index.html')
})
it('404s when the host cannot canonicalize the path at all', async () => {
mocks.realpath.mockRejectedValue(new Error('no such file'))
expect(await readDocPreviewFile(sshGrant(), 'index.html')).toMatchObject({
ok: false,
status: 404
})
expect(mocks.readFile).not.toHaveBeenCalled()
})
it('404s a path outside the grant root without touching the provider', async () => {
const outcome = await readDocPreviewFile(sshGrant(), '../../etc/passwd')
expect(outcome).toMatchObject({ ok: false, status: 404 })
expect(mocks.requireSshFilesystemProvider).not.toHaveBeenCalled()
})
it('reports an over-cap SSH file as too large rather than unreadable', async () => {
mocks.readFile.mockRejectedValue(new FileReadCapExceededError('exceeds client cap'))
expect(await readDocPreviewFile(sshGrant(), 'huge.html')).toMatchObject({
ok: false,
status: 413
})
})
it('404s when the provider read fails', async () => {
mocks.readFile.mockRejectedValue(new Error('no such file'))
expect(await readDocPreviewFile(sshGrant(), 'missing.html')).toMatchObject({
ok: false,
status: 404
})
})
})
describe('readDocPreviewFile — paired runtime owner', () => {
it('reads text over worktree-relative files.read', async () => {
mocks.callRuntimeEnvironment.mockResolvedValue({
ok: true,
result: { content: '<h1>remote</h1>', truncated: false, byteLength: 15 }
})
const outcome = await readDocPreviewFile(runtimeGrant(), 'index.html')
expect(mocks.callRuntimeEnvironment).toHaveBeenCalledWith(
'/user-data',
'env-1',
'files.read',
{ worktree: 'id:wt-1', relativePath: 'docs/index.html' },
15_000
)
expect(outcome).toEqual({
ok: true,
bytes: Buffer.from('<h1>remote</h1>', 'utf8'),
contentType: 'text/html; charset=utf-8'
})
})
it('falls back to the base64 preview RPC for a binary asset', async () => {
const png = Buffer.from([0x89, 0x50, 0x4e, 0x47])
mocks.callRuntimeEnvironment
.mockResolvedValueOnce({
ok: false,
error: { code: 'runtime_error', message: 'binary_file' }
})
.mockResolvedValueOnce({
ok: true,
result: {
content: png.toString('base64'),
isBinary: true,
isImage: true,
mimeType: 'image/png'
}
})
const outcome = await readDocPreviewFile(runtimeGrant(), 'assets/logo.png')
expect(mocks.callRuntimeEnvironment).toHaveBeenNthCalledWith(
2,
'/user-data',
'env-1',
'files.readPreview',
{ worktree: 'id:wt-1', relativePath: 'docs/assets/logo.png' },
15_000
)
expect(outcome).toEqual({ ok: true, bytes: png, contentType: 'image/png' })
})
it('degrades on an old server whose empty binary preview carries no metadata', async () => {
mocks.callRuntimeEnvironment
.mockResolvedValueOnce({
ok: false,
error: { code: 'runtime_error', message: 'binary_file' }
})
.mockResolvedValueOnce({ ok: true, result: { content: '', isBinary: true } })
expect(await readDocPreviewFile(runtimeGrant(), 'assets/logo.png')).toMatchObject({
ok: false,
status: 415,
reason: 'unsupported-asset'
})
})
it('serves an empty paired asset the host typed rather than reporting a refusal', async () => {
mocks.callRuntimeEnvironment
.mockResolvedValueOnce({
ok: false,
error: { code: 'runtime_error', message: 'binary_file' }
})
.mockResolvedValueOnce({
ok: true,
result: { content: '', isBinary: true, isImage: true, mimeType: 'image/png' }
})
expect(await readDocPreviewFile(runtimeGrant(), 'assets/logo.png')).toEqual({
ok: true,
bytes: Buffer.alloc(0),
contentType: 'image/png'
})
})
// Why: files.read clamps text at the host cap and only says so in `truncated`; serving the
// clamped bytes renders a document that silently stops halfway.
it('refuses a truncated text read instead of serving the clamped bytes', async () => {
mocks.callRuntimeEnvironment.mockResolvedValue({
ok: true,
result: { content: '<h1>half of', truncated: true, byteLength: 40_000_000 }
})
const outcome = await readDocPreviewFile(runtimeGrant(), 'index.html')
expect(outcome).toMatchObject({ ok: false, status: 413 })
expect(outcome).not.toMatchObject({ ok: true })
})
it('serves a read the host reports as complete', async () => {
mocks.callRuntimeEnvironment.mockResolvedValue({
ok: true,
result: { content: '<h1>all</h1>', truncated: false, byteLength: 12 }
})
expect(await readDocPreviewFile(runtimeGrant(), 'index.html')).toMatchObject({ ok: true })
})
// Why: the binary RPC has no `truncated` field — it rejects an over-cap asset with this error.
it('reports the host rejecting an over-cap binary as too large', async () => {
mocks.callRuntimeEnvironment
.mockResolvedValueOnce({
ok: false,
error: { code: 'runtime_error', message: 'binary_file' }
})
.mockResolvedValueOnce({
ok: false,
error: { code: 'runtime_error', message: 'file_too_large' }
})
expect(await readDocPreviewFile(runtimeGrant(), 'assets/huge.png')).toMatchObject({
ok: false,
status: 413
})
})
it('does not treat an unrelated RPC failure as a binary fallback', async () => {
mocks.callRuntimeEnvironment.mockResolvedValue({
ok: false,
error: { code: 'runtime_error', message: 'permission_denied' }
})
expect(await readDocPreviewFile(runtimeGrant(), 'index.html')).toMatchObject({
ok: false,
status: 404
})
expect(mocks.callRuntimeEnvironment).toHaveBeenCalledOnce()
})
it('404s a document outside the worktree, which files.read cannot address', async () => {
const outcome = await readDocPreviewFile(runtimeGrant('/tmp/agent-docs'), 'index.html')
expect(outcome).toMatchObject({ ok: false, status: 404 })
expect(mocks.callRuntimeEnvironment).not.toHaveBeenCalled()
})
})
+198
View File
@@ -0,0 +1,198 @@
import { extname } from 'node:path'
import type {
RuntimeFilePreviewResult,
RuntimeFileReadResult
} from '../../shared/runtime-file-contracts'
import type { DocPreviewFileFailureReason } from '../../shared/doc-preview-scheme'
import { callRuntimeEnvironment } from '../ipc/runtime-environment-transport-routing'
import { FileReadCapExceededError } from '../ssh/ssh-filesystem-stream-reader'
import { getCanonicalUserDataPath } from '../persistence'
import { requireSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch'
import {
resolveCanonicalDocPreviewPath,
resolveDocPreviewTargetPath,
toRuntimeWorktreeRelativePath,
type DocPreviewGrant
} from './doc-preview-grant-registry'
const DOC_PREVIEW_READ_TIMEOUT_MS = 15_000
/** Why not "needs a newer server": the SSH read path only ever serves images and PDFs as bytes, so
* a font is refused there by design, not by version. Name the file type, not the host's age. */
const UNSERVABLE_ASSET_PREVIEW_MESSAGE = 'This workspace cannot send this file type to a preview.'
/** `files.read` clamps text at the host's cap and reports it; serving the clamped bytes would
* render a silently half-finished document. */
const TRUNCATED_PREVIEW_MESSAGE = 'This document is too large for the server to send in full.'
/** The paired host rejects an over-cap asset outright instead of clamping it. */
const RUNTIME_TOO_LARGE_ERROR = 'file_too_large'
/** Both owners refuse an over-cap file; only their error shapes differ. */
function isTooLargeReadError(error: unknown): boolean {
return (
error instanceof FileReadCapExceededError ||
(error instanceof Error && error.message === RUNTIME_TOO_LARGE_ERROR)
)
}
export type DocPreviewReadOutcome =
| { ok: true; bytes: Buffer; contentType: string }
| { ok: false; status: number; reason: DocPreviewFileFailureReason; message: string }
const DOC_PREVIEW_CONTENT_TYPES: Record<string, string> = {
'.html': 'text/html; charset=utf-8',
'.htm': 'text/html; charset=utf-8',
'.css': 'text/css; charset=utf-8',
'.js': 'text/javascript; charset=utf-8',
'.mjs': 'text/javascript; charset=utf-8',
'.json': 'application/json; charset=utf-8',
'.txt': 'text/plain; charset=utf-8',
'.md': 'text/plain; charset=utf-8',
'.csv': 'text/plain; charset=utf-8',
'.svg': 'image/svg+xml',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.gif': 'image/gif',
'.webp': 'image/webp',
'.bmp': 'image/bmp',
'.ico': 'image/x-icon',
'.pdf': 'application/pdf',
'.woff': 'font/woff',
'.woff2': 'font/woff2',
'.ttf': 'font/ttf',
'.otf': 'font/otf'
}
export function docPreviewContentType(relativePath: string): string {
return (
DOC_PREVIEW_CONTENT_TYPES[extname(relativePath).toLowerCase()] ?? 'application/octet-stream'
)
}
type PreviewFileBytes = {
content: string
isBinary: boolean
truncated?: boolean
/** Set by every owner that agreed to serve the bytes, so it also survives a 0-byte file. */
mimeType?: string
}
function toOutcome(source: PreviewFileBytes, contentType: string): DocPreviewReadOutcome {
if (source.truncated) {
return { ok: false, status: 413, reason: 'too-large', message: TRUNCATED_PREVIEW_MESSAGE }
}
if (!source.isBinary) {
return { ok: true, bytes: Buffer.from(source.content, 'utf8'), contentType }
}
if (source.content) {
return { ok: true, bytes: Buffer.from(source.content, 'base64'), contentType }
}
// Why: an empty binary body is two different answers. A host that still named the file's type
// read a 0-byte file, and 0 bytes is what it should serve; a host that named no type declined
// the format outright and has nothing to send.
return source.mimeType
? { ok: true, bytes: Buffer.alloc(0), contentType }
: {
ok: false,
status: 415,
reason: 'unsupported-asset',
message: UNSERVABLE_ASSET_PREVIEW_MESSAGE
}
}
async function readRuntimeDocPreviewFile(
environmentId: string,
worktreeSelector: string,
relativePath: string
): Promise<PreviewFileBytes> {
const userDataPath = getCanonicalUserDataPath()
const response = await callRuntimeEnvironment(
userDataPath,
environmentId,
'files.read',
{ worktree: worktreeSelector, relativePath },
DOC_PREVIEW_READ_TIMEOUT_MS
)
if (response.ok) {
const result = response.result as RuntimeFileReadResult
return { content: result.content, isBinary: false, truncated: result.truncated === true }
}
// Why: files.read rejects binaries with a typed error; the base64 preview RPC serves
// images and fonts the same way it does for markdown previews. Match the exact
// message so an unrelated failure can't spoof the fallback.
if (response.error.message !== 'binary_file') {
throw new Error(response.error.message)
}
const previewResponse = await callRuntimeEnvironment(
userDataPath,
environmentId,
'files.readPreview',
{ worktree: worktreeSelector, relativePath },
DOC_PREVIEW_READ_TIMEOUT_MS
)
if (!previewResponse.ok) {
throw new Error(previewResponse.error.message)
}
const preview = previewResponse.result as RuntimeFilePreviewResult
// Why: readPreview never clamps — it rejects an over-cap asset — so its body is whole or absent.
return {
content: preview.content,
isBinary: preview.isBinary,
...(preview.mimeType ? { mimeType: preview.mimeType } : {})
}
}
function notFoundOutcome(message = 'Not found'): DocPreviewReadOutcome {
return { ok: false, status: 404, reason: 'unreadable', message }
}
/** Reads one in-grant path over the same channel the editor uses for that owner. */
export async function readDocPreviewFile(
grant: DocPreviewGrant,
relativePath: string
): Promise<DocPreviewReadOutcome> {
const absolutePath = resolveDocPreviewTargetPath(grant, relativePath)
if (!absolutePath) {
return notFoundOutcome()
}
const contentType = docPreviewContentType(relativePath)
try {
if (grant.owner.kind === 'ssh') {
const provider = requireSshFilesystemProvider(grant.owner.connectionId)
// Why: the SSH read RPC enforces no root of its own, so containment has to survive a symlink
// before the read — the lexical check above only proves the requested path looked contained.
const canonicalPath = await resolveCanonicalDocPreviewPath(grant, absolutePath, (path) =>
provider.realpath(path)
)
if (!canonicalPath) {
return notFoundOutcome()
}
// Why: the SSH reader rejects an over-cap file outright, so its result is never partial.
return toOutcome(await provider.readFile(canonicalPath), contentType)
}
const worktreeRelativePath = toRuntimeWorktreeRelativePath(
grant.owner.worktreeRoot,
absolutePath
)
if (!worktreeRelativePath) {
// Why: files.read is worktree-scoped, so a doc outside the worktree has no client-side channel.
return notFoundOutcome()
}
// Why no realpath pass here: the host resolves this path through resolveAuthorizedPath, which
// canonicalizes and re-checks the worktree root server-side before reading.
return toOutcome(
await readRuntimeDocPreviewFile(
grant.owner.environmentId,
grant.owner.worktreeSelector,
worktreeRelativePath
),
contentType
)
} catch (error) {
return isTooLargeReadError(error)
? { ok: false, status: 413, reason: 'too-large', message: TRUNCATED_PREVIEW_MESSAGE }
: notFoundOutcome(error instanceof Error ? error.message : undefined)
}
}
@@ -0,0 +1,164 @@
import { beforeEach, describe, expect, it } from 'vitest'
import {
getDocPreviewGrant,
mintDocPreviewGrant,
resolveCanonicalDocPreviewPath,
resolveDocPreviewTargetPath,
revokeAllDocPreviewGrants,
revokeDocPreviewGrant,
toRuntimeWorktreeRelativePath,
type DocPreviewGrant
} from './doc-preview-grant-registry'
const sshOwner = { kind: 'ssh', connectionId: 'ssh-1' } as const
function mintPosixGrant(root = '/srv/repo/docs'): DocPreviewGrant {
return mintDocPreviewGrant({
owner: sshOwner,
root,
entryRelativePath: 'index.html',
browserPageId: 'page-1'
})
}
beforeEach(() => {
revokeAllDocPreviewGrants()
})
describe('doc preview grants', () => {
it('mints unguessable ids and looks them up', () => {
const first = mintPosixGrant()
const second = mintPosixGrant()
expect(first.id).toMatch(/^[0-9a-f]{32}$/)
expect(first.id).not.toBe(second.id)
expect(getDocPreviewGrant(first.id)).toBe(first)
})
it('returns nothing for an unknown or revoked grant', () => {
const grant = mintPosixGrant()
expect(getDocPreviewGrant('0'.repeat(32))).toBeNull()
expect(revokeDocPreviewGrant(grant.id)).toBe(true)
expect(getDocPreviewGrant(grant.id)).toBeNull()
expect(revokeDocPreviewGrant(grant.id)).toBe(false)
})
})
describe('resolveDocPreviewTargetPath', () => {
it('resolves paths inside the grant root', () => {
const grant = mintPosixGrant()
expect(resolveDocPreviewTargetPath(grant, 'index.html')).toBe('/srv/repo/docs/index.html')
expect(resolveDocPreviewTargetPath(grant, 'assets/logo.png')).toBe(
'/srv/repo/docs/assets/logo.png'
)
})
it('refuses parent traversal, absolute escapes and empty paths', () => {
const grant = mintPosixGrant()
expect(resolveDocPreviewTargetPath(grant, '../secret.env')).toBeNull()
expect(resolveDocPreviewTargetPath(grant, 'assets/../../secret.env')).toBeNull()
expect(resolveDocPreviewTargetPath(grant, '..')).toBeNull()
expect(resolveDocPreviewTargetPath(grant, '')).toBeNull()
expect(resolveDocPreviewTargetPath(grant, 'a//b')).toBeNull()
})
it('refuses backslash and NUL segments that could re-split on the owning host', () => {
const grant = mintPosixGrant()
expect(resolveDocPreviewTargetPath(grant, '..\\secret.env')).toBeNull()
expect(resolveDocPreviewTargetPath(grant, 'index.html\0.png')).toBeNull()
})
// Why this is a traversal test and not a containment test: every request path that names a
// sibling directory has to climb out of the root first, so the `..` segment guard answers it
// before the prefix check runs. Sibling containment is exercised where it is reachable —
// against a canonicalized path, below.
it('refuses a sibling directory by refusing the traversal that reaches it', () => {
const grant = mintPosixGrant()
expect(resolveDocPreviewTargetPath(grant, '../docs-private/secret.html')).toBeNull()
})
it('keeps a Windows drive root addressable instead of turning it drive-relative', () => {
const grant = mintDocPreviewGrant({
owner: sshOwner,
root: 'C:\\',
entryRelativePath: 'index.html',
browserPageId: 'page-1'
})
expect(resolveDocPreviewTargetPath(grant, 'index.html')).toBe('C:\\index.html')
})
it('follows the owning host path flavor rather than this process platform', () => {
const windowsGrant = mintDocPreviewGrant({
owner: sshOwner,
root: 'C:\\srv\\repo\\docs',
entryRelativePath: 'index.html',
browserPageId: 'page-1'
})
expect(resolveDocPreviewTargetPath(windowsGrant, 'assets/logo.png')).toBe(
'C:\\srv\\repo\\docs\\assets\\logo.png'
)
expect(resolveDocPreviewTargetPath(windowsGrant, '../secret.env')).toBeNull()
})
it('normalizes a trailing separator on the root', () => {
const grant = mintDocPreviewGrant({
owner: sshOwner,
root: '/srv/repo/docs/',
entryRelativePath: 'index.html',
browserPageId: 'page-1'
})
expect(resolveDocPreviewTargetPath(grant, 'index.html')).toBe('/srv/repo/docs/index.html')
expect(resolveDocPreviewTargetPath(grant, '../secret.env')).toBeNull()
})
})
describe('resolveCanonicalDocPreviewPath', () => {
// Why here and not above: a canonical path is the one input that can name a sibling directory
// without traversing — the host resolved a symlink to it — so this is where the prefix check
// is the only thing standing between the grant and `/srv/repo/docs-private`.
it('refuses a canonical path in a sibling directory that shares the root prefix', async () => {
const grant = mintPosixGrant()
await expect(
resolveCanonicalDocPreviewPath(grant, '/srv/repo/docs/report.html', async (path) =>
path === grant.root ? path : '/srv/repo/docs-private/secret.html'
)
).resolves.toBeNull()
})
it('answers the canonical path when it stays inside the canonical root', async () => {
const grant = mintPosixGrant()
await expect(
resolveCanonicalDocPreviewPath(grant, '/srv/repo/docs/report.html', async (path) => path)
).resolves.toBe('/srv/repo/docs/report.html')
})
})
describe('toRuntimeWorktreeRelativePath', () => {
it('produces a worktree-relative path for files inside the worktree', () => {
expect(toRuntimeWorktreeRelativePath('/srv/repo', '/srv/repo/docs/index.html')).toBe(
'docs/index.html'
)
})
it('rejects paths outside the worktree, which files.read cannot address', () => {
expect(toRuntimeWorktreeRelativePath('/srv/repo', '/tmp/agent/report.html')).toBeNull()
expect(toRuntimeWorktreeRelativePath('/srv/repo', '/srv/repo')).toBeNull()
})
it('uses Windows semantics for a Windows worktree root', () => {
expect(toRuntimeWorktreeRelativePath('C:\\srv\\repo', 'C:\\srv\\repo\\docs\\index.html')).toBe(
'docs/index.html'
)
expect(toRuntimeWorktreeRelativePath('C:\\srv\\repo', 'D:\\other\\index.html')).toBeNull()
})
})
@@ -0,0 +1,208 @@
import { randomBytes } from 'node:crypto'
import { posix, win32 } from 'node:path'
import { isWindowsAbsolutePathLike } from '../../shared/cross-platform-path'
/**
* A preview grant is the only authority that turns an `orca-preview://` request
* into bytes: it names the host that owns the file and the single directory
* subtree requests may resolve inside. No grant, no bytes.
*/
export type DocPreviewOwner =
| { kind: 'ssh'; connectionId: string }
| {
kind: 'runtime'
environmentId: string
/** Selector the runtime resolves `files.read` against. */
worktreeSelector: string
/** Worktree root on the runtime host; `files.read` only accepts paths inside it. */
worktreeRoot: string
}
export type DocPreviewGrant = {
id: string
owner: DocPreviewOwner
/** Containing directory of the opened document, on the owning host. */
root: string
/** Path of the opened document relative to `root`. */
entryRelativePath: string
/**
* Browser page the reader opened this document in. Main registers the guest under it once the
* guest commits to the grant, so the surface a tool names is the page the reader is looking at
* and not the grant, which a re-mint replaces underneath the same page.
*/
browserPageId: string
}
const grantsById = new Map<string, DocPreviewGrant>()
function pathFlavorFor(root: string): typeof posix | typeof win32 {
return isWindowsAbsolutePathLike(root) ? win32 : posix
}
function normalizeRootPath(root: string): string {
const flavor = pathFlavorFor(root)
const normalized =
flavor === win32 ? flavor.normalize(root.replace(/\//g, '\\')) : flavor.normalize(root)
// Why: `C:\` is the whole root, and trimming its separator would make win32.join answer the
// drive-relative `C:x`, which resolves against the host's cwd instead of inside the grant.
if (flavor === win32 && /^[a-zA-Z]:\\$/.test(normalized)) {
return normalized
}
// Why: a trailing separator would make the containment prefix check accept a sibling directory.
return normalized.length > 1 && normalized.endsWith(flavor.sep)
? normalized.slice(0, -1)
: normalized
}
export function mintDocPreviewGrant(params: {
owner: DocPreviewOwner
root: string
entryRelativePath: string
browserPageId: string
}): DocPreviewGrant {
const grant: DocPreviewGrant = {
id: randomBytes(16).toString('hex'),
owner: params.owner,
root: normalizeRootPath(params.root),
entryRelativePath: params.entryRelativePath.replace(/\\/g, '/'),
browserPageId: params.browserPageId
}
grantsById.set(grant.id, grant)
return grant
}
export function getDocPreviewGrant(grantId: string): DocPreviewGrant | null {
return grantsById.get(grantId) ?? null
}
/**
* Why anything listens at all: a grant is the only thing that names a preview's lifetime. State
* elsewhere in main keyed by a preview's tool target — grab intent, a queued grab chain — has no
* other signal telling it the surface is gone, and would otherwise accrete one entry per grant
* for the life of the process.
*/
const revocationListeners = new Set<(grant: DocPreviewGrant) => void>()
/** Why the whole grant and not its id: it is already gone from the registry when listeners run. */
export function onDocPreviewGrantRevoked(listener: (grant: DocPreviewGrant) => void): () => void {
revocationListeners.add(listener)
return () => revocationListeners.delete(listener)
}
function notifyRevoked(grant: DocPreviewGrant): void {
for (const listener of revocationListeners) {
listener(grant)
}
}
export function revokeDocPreviewGrant(grantId: string): boolean {
canonicalRootByGrantId.delete(grantId)
const grant = grantsById.get(grantId)
if (!grant) {
return false
}
grantsById.delete(grantId)
notifyRevoked(grant)
return true
}
export function revokeAllDocPreviewGrants(): void {
canonicalRootByGrantId.clear()
const revoked = [...grantsById.values()]
grantsById.clear()
for (const grant of revoked) {
notifyRevoked(grant)
}
}
function hasUnsafeSegment(segments: string[]): boolean {
return segments.some(
(segment) =>
segment.length === 0 ||
segment === '.' ||
segment === '..' ||
segment.includes('\0') ||
segment.includes('\\')
)
}
/**
* Resolves a request path to an absolute path on the owning host, or null when
* it would escape the grant's root. Path flavor follows the root (the owning
* host may be Windows while this client is not), never `process.platform`.
*/
export function resolveDocPreviewTargetPath(
grant: DocPreviewGrant,
relativePath: string
): string | null {
const segments = relativePath.split('/').filter((segment, index, all) => {
// Why: keep empty segments visible to the safety check except a single trailing one from `dir/`.
return !(segment === '' && index === all.length - 1)
})
if (segments.length === 0 || hasUnsafeSegment(segments)) {
return null
}
const flavor = pathFlavorFor(grant.root)
const resolved = flavor.normalize(flavor.join(grant.root, ...segments))
return isInsideRoot(grant.root, resolved, flavor) ? resolved : null
}
function isInsideRoot(
root: string,
candidate: string,
flavor: typeof posix | typeof win32
): boolean {
const rootPrefix = root.endsWith(flavor.sep) ? root : `${root}${flavor.sep}`
return candidate.startsWith(rootPrefix)
}
/** Why: realpath is a host round-trip, and a grant's root is fixed for its lifetime. */
const canonicalRootByGrantId = new Map<string, Promise<string>>()
/**
* Second containment pass for hosts where the lexical one is not enough: a symlink
* inside the root can point anywhere, and the SSH read RPC applies no root of its
* own. Both sides are canonicalized on the owning host before the prefix re-check;
* a host that cannot canonicalize a path answers nothing.
*/
export async function resolveCanonicalDocPreviewPath(
grant: DocPreviewGrant,
absolutePath: string,
realpath: (path: string) => Promise<string>
): Promise<string | null> {
try {
let canonicalRoot = canonicalRootByGrantId.get(grant.id)
if (!canonicalRoot) {
canonicalRoot = realpath(grant.root).then(normalizeRootPath)
canonicalRootByGrantId.set(grant.id, canonicalRoot)
}
const [root, canonicalPath] = await Promise.all([canonicalRoot, realpath(absolutePath)])
const flavor = pathFlavorFor(root)
return isInsideRoot(root, canonicalPath, flavor) ? canonicalPath : null
} catch {
// Why: a root that no longer canonicalizes must not fall back to the lexical answer.
canonicalRootByGrantId.delete(grant.id)
return null
}
}
/**
* Path a runtime `files.read` can address, i.e. relative to the worktree root.
* Returns null when the grant root sits outside the worktree — the runtime file
* RPCs are worktree-scoped, so those documents are unreadable client-side.
*/
export function toRuntimeWorktreeRelativePath(
worktreeRoot: string,
absolutePath: string
): string | null {
const flavor = pathFlavorFor(worktreeRoot)
const normalizedRoot = normalizeRootPath(worktreeRoot)
const relative = flavor.relative(normalizedRoot, absolutePath)
if (!relative || relative === '..' || relative.startsWith(`..${flavor.sep}`)) {
return null
}
if (flavor === win32 && /^[a-zA-Z]:/.test(relative)) {
return null
}
return relative.replace(/\\/g, '/')
}
@@ -0,0 +1,550 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
getWorkspaceDocPageGuest,
installDocPreviewGuestPolicy,
readDocPreviewGuestBoundGrantId,
reportDocPreviewLinkClick
} from './doc-preview-guest-policy'
import { buildDocPreviewUrl } from '../../shared/doc-preview-scheme'
import {
mintDocPreviewGrant,
revokeAllDocPreviewGrants,
revokeDocPreviewGrant
} from './doc-preview-grant-registry'
type GuestHandlers = Record<string, (...args: never[]) => void>
const HOST_RENDERER_ID = 42
function installOnFakeGuest(
hostId: number = HOST_RENDERER_ID,
/** What the guest is already showing when the embedder hands it over, as a real one usually is. */
initialUrl = ''
): {
contents: object
handlers: GuestHandlers
hostId: number
isFocused: ReturnType<typeof vi.fn>
send: ReturnType<typeof vi.fn>
setWebRTCIPHandlingPolicy: ReturnType<typeof vi.fn>
windowOpenHandler: (details: { url: string }) => { action: string }
/** Why without the `destroyed` event: Chromium tears the contents down before main runs it. */
markContentsDestroyed: () => void
} {
const handlers: GuestHandlers = {}
let contentsDestroyed = false
const send = vi.fn()
const isFocused = vi.fn(() => true)
const setWebRTCIPHandlingPolicy = vi.fn()
let windowOpenHandler: (details: { url: string }) => { action: string } = () => ({
action: 'deny'
})
const register = (event: string, handler: (...args: never[]) => void): void => {
handlers[event] = handler
}
const guest = {
isFocused,
isDestroyed: () => contentsDestroyed,
getURL: () => initialUrl,
on: vi.fn(register),
once: vi.fn(register),
setWindowOpenHandler: vi.fn((handler: (details: { url: string }) => { action: string }) => {
windowOpenHandler = handler
}),
setWebRTCIPHandlingPolicy
}
installDocPreviewGuestPolicy(guest as never, { id: hostId, send })
return {
contents: guest,
handlers,
hostId,
isFocused,
send,
setWebRTCIPHandlingPolicy,
windowOpenHandler: (details) => windowOpenHandler(details),
markContentsDestroyed: () => {
contentsDestroyed = true
}
}
}
type FakeGuest = ReturnType<typeof installOnFakeGuest>
function startMainFrameNavigation(guest: FakeGuest, url: string): void {
guest.handlers['did-start-navigation']?.({ url, isMainFrame: true } as never)
}
/** A guest already showing a document, which is the only state a link can be pressed in. */
function boundGuest(): { grant: ReturnType<typeof mintGrant>; guest: FakeGuest } {
const grant = mintGrant()
const guest = installOnFakeGuest()
startMainFrameNavigation(guest, buildDocPreviewUrl(grant.id, 'index.html'))
return { grant, guest }
}
function reportClick(guest: FakeGuest, url: string): void {
reportDocPreviewLinkClick(guest.contents as never, url)
}
// Why a fresh page per grant: the registry is keyed by the page now, so two grants sharing one
// would have the second silently replace the first rather than stand beside it.
let nextDocPageOrdinal = 0
function mintGrant(): ReturnType<typeof mintDocPreviewGrant> {
nextDocPageOrdinal += 1
return mintDocPreviewGrant({
owner: { kind: 'ssh', connectionId: 'ssh-1' },
root: '/home/alice/docs',
entryRelativePath: 'index.html',
browserPageId: `doc-page-${nextDocPageOrdinal}`
})
}
beforeEach(() => {
vi.clearAllMocks()
revokeAllDocPreviewGrants()
})
describe('doc preview guest policy', () => {
it('allows relative navigation within the bound grant', () => {
const { grant, guest } = boundGuest()
const preventDefault = vi.fn()
guest.handlers['will-navigate']?.(
{ preventDefault } as never,
buildDocPreviewUrl(grant.id, 'guide.html') as never
)
expect(preventDefault).not.toHaveBeenCalled()
expect(guest.send).not.toHaveBeenCalled()
})
// Why an unlatched guest is refused rather than trusted: the renderer-set src is
// browser-initiated and never reaches will-navigate, so a navigation arriving before the latch is
// one the guest started for itself.
it('blocks a navigation the guest starts before a document has bound it', () => {
const grant = mintGrant()
const guest = installOnFakeGuest()
const preventDefault = vi.fn()
guest.handlers['will-navigate']?.(
{ preventDefault } as never,
buildDocPreviewUrl(grant.id, 'index.html') as never
)
expect(preventDefault).toHaveBeenCalledOnce()
expect(guest.send).not.toHaveBeenCalled()
})
it('blocks navigation into a different live grant once bound', () => {
const { guest } = boundGuest()
const otherGrant = mintGrant()
const preventDefault = vi.fn()
guest.handlers['will-navigate']?.(
{ preventDefault } as never,
buildDocPreviewUrl(otherGrant.id, 'index.html') as never
)
expect(preventDefault).toHaveBeenCalledOnce()
})
it('blocks a revoked grant even when it matches the bound id', () => {
const { grant, guest } = boundGuest()
revokeAllDocPreviewGrants()
const preventDefault = vi.fn()
guest.handlers['will-navigate']?.(
{ preventDefault } as never,
buildDocPreviewUrl(grant.id, 'guide.html') as never
)
expect(preventDefault).toHaveBeenCalledOnce()
})
it('blocks an external navigation without offering it to the renderer', () => {
const { guest } = boundGuest()
const preventDefault = vi.fn()
guest.handlers['will-navigate']?.({ preventDefault } as never, 'https://example.com/' as never)
expect(preventDefault).toHaveBeenCalledOnce()
expect(guest.send).not.toHaveBeenCalled()
})
it('blocks a file: navigation', () => {
const { guest } = boundGuest()
const preventDefault = vi.fn()
guest.handlers['will-navigate']?.({ preventDefault } as never, 'file:///etc/passwd' as never)
expect(preventDefault).toHaveBeenCalledOnce()
expect(guest.send).not.toHaveBeenCalled()
})
it('applies the same rule to redirects', () => {
const { guest } = boundGuest()
const preventDefault = vi.fn()
guest.handlers['will-redirect']?.({ preventDefault } as never, 'https://evil.test/' as never)
expect(preventDefault).toHaveBeenCalledOnce()
expect(guest.send).not.toHaveBeenCalled()
})
it('denies every popup and routes none of them', () => {
const { guest } = boundGuest()
expect(guest.windowOpenHandler({ url: 'https://example.com/docs' })).toEqual({
action: 'deny'
})
expect(guest.windowOpenHandler({ url: 'file:///etc/passwd' })).toEqual({ action: 'deny' })
expect(guest.send).not.toHaveBeenCalled()
})
// Why: will-navigate never fires for a subframe, so an <iframe src="https://…"> would load
// off-machine even though the top frame cannot.
it('blocks a subframe navigating outside the grant, without opening a tab for it', () => {
const { guest } = boundGuest()
const preventDefault = vi.fn()
guest.handlers['will-frame-navigate']?.({
preventDefault,
url: 'https://tracker.test/pixel',
isMainFrame: false
} as never)
expect(preventDefault).toHaveBeenCalledOnce()
expect(guest.send).not.toHaveBeenCalled()
})
it('lets a subframe load an in-grant asset', () => {
const { grant, guest } = boundGuest()
const preventDefault = vi.fn()
guest.handlers['will-frame-navigate']?.({
preventDefault,
url: buildDocPreviewUrl(grant.id, 'chart.html'),
isMainFrame: false
} as never)
expect(preventDefault).not.toHaveBeenCalled()
})
// Why this group exists: the external-link route ends in a real browser tab with full network, so
// a document that can read its grant and reach that route can exfiltrate it. Only a reader's own
// press on a link may, and only the guest's preload can tell that a press was one.
describe('the trusted-click route out of the preview', () => {
// The headline. The earlier design read a recent-input timestamp, so a script that navigated
// shortly after any genuine press was routed out as if the press had asked for it. Here the
// navigation sinks route nothing at all, so when the input happened cannot matter.
it('routes nothing for a scripted location change or window.open, whenever input happened', () => {
const { guest } = boundGuest()
const preventDefault = vi.fn()
// Genuine input the guest would report, delivered immediately before the document acts.
guest.handlers['input-event']?.({} as never, { type: 'mouseDown' } as never)
guest.handlers['before-input-event']?.({} as never, { type: 'keyDown' } as never)
guest.handlers['will-navigate']?.(
{ preventDefault } as never,
'https://attacker.test/?d=secret' as never
)
const popup = guest.windowOpenHandler({ url: 'https://attacker.test/?d=secret' })
expect(preventDefault).toHaveBeenCalledOnce()
expect(popup).toEqual({ action: 'deny' })
expect(guest.send).not.toHaveBeenCalled()
// Why assert the absence of the listeners too: with them gone there is no timing a document
// could hit, rather than a window it merely failed to hit in this test.
expect(guest.handlers['input-event']).toBeUndefined()
expect(guest.handlers['before-input-event']).toBeUndefined()
})
it('routes a reported click on an external link exactly once', () => {
const { guest } = boundGuest()
reportClick(guest, 'https://example.com/docs')
expect(guest.send).toHaveBeenCalledExactlyOnceWith('docPreview:externalLink', {
url: 'https://example.com/docs'
})
})
it('drops a click reported while the guest is not the contents the reader is looking at', () => {
const { guest } = boundGuest()
guest.isFocused.mockReturnValue(false)
reportClick(guest, 'https://example.com/docs')
expect(guest.send).not.toHaveBeenCalled()
})
it('drops a click reported by a sender that is not a preview guest', () => {
const { guest } = boundGuest()
reportDocPreviewLinkClick({ isFocused: () => true } as never, 'https://example.com/docs')
expect(guest.send).not.toHaveBeenCalled()
})
it('drops a click reported by a guest that never bound a grant', () => {
mintGrant()
const guest = installOnFakeGuest()
reportClick(guest, 'https://example.com/docs')
expect(guest.send).not.toHaveBeenCalled()
})
it("drops a click once the guest's bound grant is revoked", () => {
const { guest } = boundGuest()
revokeAllDocPreviewGrants()
reportClick(guest, 'https://example.com/docs')
expect(guest.send).not.toHaveBeenCalled()
})
it.each([
'file:///etc/passwd',
'javascript:fetch("https://attacker.test")',
'orca-preview://a/b',
'/Users/alice/secrets.txt',
''
])('drops a reported click on %s, which is not the web', (url) => {
const { guest } = boundGuest()
reportClick(guest, url)
expect(guest.send).not.toHaveBeenCalled()
})
it('stops routing for a guest that has been destroyed', () => {
const { guest } = boundGuest()
guest.handlers['destroyed']?.()
reportClick(guest, 'https://example.com/docs')
expect(guest.send).not.toHaveBeenCalled()
})
})
// Why: a peer connection is UDP straight off the network stack — the response CSP and the
// session's request filter both miss it, so this is the only place it can be refused.
it('denies the guest non-proxied WebRTC UDP at attach', () => {
const guest = installOnFakeGuest()
expect(guest.setWebRTCIPHandlingPolicy).toHaveBeenCalledWith('disable_non_proxied_udp')
})
// Why: latching from a subframe would let an in-document iframe decide which grant the guest
// belongs to, and every later main-frame check would be measured against that.
it('binds the guest from the main frame only', () => {
const grant = mintGrant()
const otherGrant = mintGrant()
const guest = installOnFakeGuest()
guest.handlers['did-start-navigation']?.({
url: buildDocPreviewUrl(otherGrant.id, 'index.html'),
isMainFrame: false
} as never)
startMainFrameNavigation(guest, buildDocPreviewUrl(grant.id, 'index.html'))
const preventDefault = vi.fn()
guest.handlers['will-navigate']?.(
{ preventDefault } as never,
buildDocPreviewUrl(otherGrant.id, 'index.html') as never
)
expect(preventDefault).toHaveBeenCalledOnce()
})
describe('tool authorization', () => {
it('answers the hosting renderer for a guest bound to a live grant', () => {
const { grant, guest } = boundGuest()
expect(getWorkspaceDocPageGuest(grant.browserPageId, HOST_RENDERER_ID)).toBe(guest.contents)
})
it('refuses a renderer that does not host the preview', () => {
const { grant } = boundGuest()
expect(getWorkspaceDocPageGuest(grant.browserPageId, HOST_RENDERER_ID + 1)).toBeNull()
})
// Why this is the ordinary case and not an edge one: the embedder hands the guest over after
// it has already started loading its src, so the navigation that binds most previews to their
// grant happens before any listener here exists. Missing it leaves the tools unable to name
// the guest for as long as the reader stays on the page they opened.
it('binds a guest that is already showing its document when the policy installs', () => {
const grant = mintGrant()
const guest = installOnFakeGuest(HOST_RENDERER_ID, buildDocPreviewUrl(grant.id, 'index.html'))
expect(getWorkspaceDocPageGuest(grant.browserPageId, HOST_RENDERER_ID)).toBe(guest.contents)
})
// The same miss, one event later: the load started before the listener and commits after it.
it('binds a guest whose first navigation only reaches the commit', () => {
const grant = mintGrant()
const guest = installOnFakeGuest()
guest.handlers['did-navigate']?.(
{} as never,
buildDocPreviewUrl(grant.id, 'index.html') as never
)
expect(getWorkspaceDocPageGuest(grant.browserPageId, HOST_RENDERER_ID)).toBe(guest.contents)
})
// Why: the src commit is what proves this guest is showing that grant. Before it, the id names
// a document nothing has been asked to render.
it('refuses a guest that has not committed a document yet', () => {
const grant = mintGrant()
installOnFakeGuest()
expect(getWorkspaceDocPageGuest(grant.browserPageId, HOST_RENDERER_ID)).toBeNull()
})
// Why: revoking is how a closed tab withdraws its preview, and it happens while the guest is
// still being torn down — a tool must stop answering at revoke, not at destroy.
it('stops answering once the grant is revoked', () => {
const { grant } = boundGuest()
revokeDocPreviewGrant(grant.id)
expect(getWorkspaceDocPageGuest(grant.browserPageId, HOST_RENDERER_ID)).toBeNull()
})
it('drops the guest when it is destroyed', () => {
const { grant, guest } = boundGuest()
guest.handlers['destroyed']?.()
expect(getWorkspaceDocPageGuest(grant.browserPageId, HOST_RENDERER_ID)).toBeNull()
})
// Why this is separate from the destroy event: the contents die before main runs that listener,
// so in that window the registration still looks live and only this check refuses it.
it('refuses a guest whose contents died before the destroy event arrived', () => {
const { grant, guest } = boundGuest()
guest.markContentsDestroyed()
expect(getWorkspaceDocPageGuest(grant.browserPageId, HOST_RENDERER_ID)).toBeNull()
})
it('answers nothing for a grant no preview ever rendered', () => {
const grant = mintGrant()
expect(getWorkspaceDocPageGuest(grant.browserPageId, HOST_RENDERER_ID)).toBeNull()
})
// Why this is what makes three latch call sites safe: install, did-start-navigation and
// did-navigate all feed the same latch, so without the no-rebind guard the second and third
// would let a later navigation move a bound guest onto another grant — and a tool asking for
// that grant would be handed a guest showing someone else's document.
it('never rebinds a bound guest onto a second grant', () => {
const { grant, guest } = boundGuest()
const other = mintGrant()
startMainFrameNavigation(guest, buildDocPreviewUrl(other.id, 'index.html'))
guest.handlers['did-navigate']?.(
{} as never,
buildDocPreviewUrl(other.id, 'index.html') as never
)
expect(getWorkspaceDocPageGuest(other.browserPageId, HOST_RENDERER_ID)).toBeNull()
expect(getWorkspaceDocPageGuest(grant.browserPageId, HOST_RENDERER_ID)).toBe(guest.contents)
})
// Why the replacement is registered before the old guest's teardown runs: a re-mint attaches
// the new guest first, and Chromium runs the outgoing guest's destroyed listener afterwards. A
// teardown that deleted by page alone would unregister the guest the reader is now looking at.
it('leaves a re-minted preview registered when the guest it replaced tears down', () => {
const first = mintGrant()
const outgoing = installOnFakeGuest(
HOST_RENDERER_ID,
buildDocPreviewUrl(first.id, 'index.html')
)
const remint = mintDocPreviewGrant({
owner: { kind: 'ssh', connectionId: 'ssh-1' },
root: '/home/alice/docs',
entryRelativePath: 'index.html',
browserPageId: first.browserPageId
})
const incoming = installOnFakeGuest(
HOST_RENDERER_ID,
buildDocPreviewUrl(remint.id, 'index.html')
)
expect(getWorkspaceDocPageGuest(first.browserPageId, HOST_RENDERER_ID)).toBe(
incoming.contents
)
outgoing.handlers['destroyed']?.()
expect(getWorkspaceDocPageGuest(first.browserPageId, HOST_RENDERER_ID)).toBe(
incoming.contents
)
})
// Why the grant has to be live at the latch and not only at each later request: registering the
// page a dead grant names would put a guest that can read nothing into the registry the tool
// door answers from, under a page a live grant may later want.
it('registers nothing for a guest showing a grant that is already revoked', () => {
const grant = mintGrant()
revokeDocPreviewGrant(grant.id)
const guest = installOnFakeGuest(HOST_RENDERER_ID, buildDocPreviewUrl(grant.id, 'index.html'))
expect(getWorkspaceDocPageGuest(grant.browserPageId, HOST_RENDERER_ID)).toBeNull()
// The presence half: the same install does register when the grant is still live.
const live = mintGrant()
installOnFakeGuest(HOST_RENDERER_ID, buildDocPreviewUrl(live.id, 'index.html'))
expect(getWorkspaceDocPageGuest(live.browserPageId, HOST_RENDERER_ID)).not.toBeNull()
expect(guest.contents).toBeDefined()
})
// Why both directions: two previews open at once must not be able to drive each other's guest.
it('keeps two live previews on their own guests', () => {
const first = boundGuest()
const second = boundGuest()
expect(getWorkspaceDocPageGuest(first.grant.browserPageId, HOST_RENDERER_ID)).toBe(
first.guest.contents
)
expect(getWorkspaceDocPageGuest(second.grant.browserPageId, HOST_RENDERER_ID)).toBe(
second.guest.contents
)
})
})
// Session events name the contents and nothing else, so this is how a fence firing on the whole
// partition works out which preview — if any — the reader should be told about.
describe('naming the grant a contents is showing', () => {
it('answers the bound grant for a live preview guest', () => {
const { grant, guest } = boundGuest()
expect(readDocPreviewGuestBoundGrantId(guest.contents as never)).toBe(grant.id)
})
it('answers nothing for a contents that is not a preview guest', () => {
boundGuest()
expect(readDocPreviewGuestBoundGrantId({} as never)).toBeNull()
})
it('answers nothing for a preview guest that has committed no document', () => {
const guest = installOnFakeGuest()
expect(readDocPreviewGuestBoundGrantId(guest.contents as never)).toBeNull()
})
it('answers nothing once the guest is gone', () => {
const { guest } = boundGuest()
guest.handlers['destroyed']?.()
expect(readDocPreviewGuestBoundGrantId(guest.contents as never)).toBeNull()
})
})
})
@@ -0,0 +1,247 @@
import {
DOC_PREVIEW_EXTERNAL_LINK_CHANNEL,
parseDocPreviewUrl
} from '../../shared/doc-preview-scheme'
import { normalizeExternalBrowserUrl } from '../../shared/browser-url'
import { enforceBrowserRouteWebRtcPolicy } from './browser-route-webrtc-policy'
import { getDocPreviewGrant } from './doc-preview-grant-registry'
/** The trusted renderer hosting the preview: the sink for link reports and the only sender allowed to drive tools on it. */
type PreviewHostRenderer = {
id: number
send: (channel: string, payload: { url: string }) => void
}
type PreviewGuestRegistration = {
host: PreviewHostRenderer
readBoundGrantId: () => string | null
isFocused: () => boolean
}
/**
* Why a registry: the click report arrives on an IPC channel, where the only thing main holds is
* the sender. Without this, "is this a live preview guest, and which grant is it bound to" has no
* answer, and any WebContents that learned the channel name would be routed out.
*/
const previewGuests = new WeakMap<object, PreviewGuestRegistration>()
/**
* The workspace-document half of the browser page registry, keyed by the page the reader opened
* the document in. It is deliberately a separate map from the browsing one: page management,
* agent commands, download routing and certificate attribution all read that map directly, in
* more places than a guard could be remembered in, so a document guest is simply not in it. The
* one door taught about both is `browserManager.getAuthorizedGuest`, because acting on the guest
* the reader is looking at is the single operation that legitimately spans them.
*/
const docGuestsByPageId = new Map<
string,
{ guest: Electron.WebContents; hostId: number; grantId: string }
>()
const registrationListeners = new Set<(browserPageId: string) => void>()
/** Why anything listens: a tool request can beat the guest's own attach, and the wait it parks in lives in the IPC layer. */
export function onWorkspaceDocGuestRegistered(
listener: (browserPageId: string) => void
): () => void {
registrationListeners.add(listener)
return () => registrationListeners.delete(listener)
}
/**
* Resolve the document guest a browser tool should act on. Grants the guest nothing — it answers
* only whether the trusted renderer asking is the one hosting a live, grant-bound preview on that
* page. Reached only through `browserManager.getAuthorizedGuest`.
*/
export function getWorkspaceDocPageGuest(
browserPageId: string,
senderWebContentsId: number
): Electron.WebContents | null {
const registration = docGuestsByPageId.get(browserPageId)
if (!registration || registration.hostId !== senderWebContentsId) {
return null
}
// Why: revocation is how a closed tab withdraws its preview, and it happens before the guest is torn down.
if (!getDocPreviewGrant(registration.grantId)) {
return null
}
if (registration.guest.isDestroyed()) {
docGuestsByPageId.delete(browserPageId)
return null
}
return registration.guest
}
/** True when the page renders a workspace document, so the browsing registry must never hold it. */
export function isWorkspaceDocPageId(browserPageId: string): boolean {
return docGuestsByPageId.has(browserPageId)
}
/**
* The grant a live preview guest is bound to, or null for any other WebContents — the same identity
* question `reportDocPreviewLinkClick` asks, for a session event that hands over only the contents.
*/
export function readDocPreviewGuestBoundGrantId(guest: Electron.WebContents): string | null {
return previewGuests.get(guest)?.readBoundGrantId() ?? null
}
/**
* The preview guest renders a workspace document, not the web: it may only move within its own
* grant, and nothing it does by itself leaves the preview. The one route out is
* `reportDocPreviewLinkClick`, which answers for a click the reader really made.
*
* Reached only through `browserManager.attachGuestPolicies` under the workspace-doc profile, so
* every guest in the app is policy-attached by one door whatever it renders. Returns the disposal
* that door stores: without it a retired guest keeps its listeners and stays answerable through the
* grant-keyed authority until the WebContents itself is collected.
*/
export function installDocPreviewGuestPolicy(
guest: Electron.WebContents,
host: PreviewHostRenderer
): () => void {
// Why: the first commit is the renderer-set src, already admitted by will-attach-webview;
// latching it pins every later navigation to that one grant.
let boundGrantId: string | null = null
let boundPageId: string | null = null
const latchGrantFromUrl = (rawUrl: string): void => {
if (boundGrantId !== null) {
return
}
const grantId = parseDocPreviewUrl(rawUrl)?.grantId ?? null
// Why the grant must still be live: registering under the page a dead grant names would put a
// guest nothing can read into the registry the tool door answers from.
const grant = grantId === null ? null : getDocPreviewGrant(grantId)
if (!grant) {
return
}
boundGrantId = grant.id
boundPageId = grant.browserPageId
docGuestsByPageId.set(boundPageId, { guest, hostId: host.id, grantId: grant.id })
for (const listener of registrationListeners) {
listener(boundPageId)
}
}
previewGuests.set(guest, {
host,
readBoundGrantId: () => boundGrantId,
isFocused: () => guest.isFocused()
})
const forgetGuest = (): void => {
previewGuests.delete(guest)
// Why the identity check: a re-mint registers the replacement under the same page before this
// guest's own teardown runs, and deleting by key alone would unregister the live one.
if (boundPageId !== null && docGuestsByPageId.get(boundPageId)?.guest === guest) {
docGuestsByPageId.delete(boundPageId)
}
}
guest.once('destroyed', forgetGuest)
const isAllowedPreviewNavigation = (rawUrl: string): boolean => {
const target = parseDocPreviewUrl(rawUrl)
if (!target || !getDocPreviewGrant(target.grantId)) {
return false
}
// Why the latch is required and not just consistent: the renderer-set src is browser-initiated,
// so will-navigate never fires for it. Anything reaching here before the latch is the guest
// moving itself, which no grant has admitted yet.
return boundGrantId !== null && target.grantId === boundGrantId
}
/**
* Why deny-only, with nothing routed: a navigation the guest starts cannot be attributed to the
* reader. The document may read its whole grant over `connect-src 'self'`, so routing an
* unattributable URL to a real browser tab with full network is how those bytes would get out.
*/
const navigationGuard = (event: Electron.Event, url: string): void => {
if (isAllowedPreviewNavigation(url)) {
return
}
event.preventDefault()
}
// Why here and not only on navigation events: the guest is already loading its src by the time
// the embedder hands it to us, so the only navigation most previews ever make has already
// started. Latching what it is on now is what binds the usual preview to its grant at all.
latchGrantFromUrl(guest.getURL())
const latchFromMainFrameNavigation = (details: { isMainFrame: boolean; url: string }): void => {
// Why: only the top document defines which grant this guest belongs to. Latching from a
// subframe would let an in-document iframe rebind the guest to another grant.
if (!details.isMainFrame) {
return
}
latchGrantFromUrl(details.url)
}
// Why a committed URL too: a navigation that started before this listener existed still commits
// after it, and a preview that never navigates again would otherwise stay bound to nothing.
const latchFromCommittedUrl = (_event: Electron.Event, url: string): void =>
latchGrantFromUrl(url)
// Why: will-navigate never fires for a subframe, so without this an <iframe src="https://…">
// inside a previewed document would load off-machine even though the top frame cannot.
const frameNavigationGuard = (details: {
isMainFrame: boolean
url: string
preventDefault: () => void
}): void => {
if (details.isMainFrame || isAllowedPreviewNavigation(details.url)) {
return
}
details.preventDefault()
}
guest.on('did-start-navigation', latchFromMainFrameNavigation)
guest.on('did-navigate', latchFromCommittedUrl)
guest.on('will-navigate', navigationGuard)
guest.on('will-redirect', navigationGuard)
guest.on('will-frame-navigate', frameNavigationGuard)
// Why deny with nothing routed: previews own no native child windows, and a popup the document
// asked for is the document asking, not the reader. A link the reader presses is intercepted
// before Chromium ever considers a popup.
guest.setWindowOpenHandler(() => ({ action: 'deny' }))
// Why a second fence for one API: WebRTC opens UDP straight from the network stack, so neither the
// response CSP nor the session's request filter ever sees it. This is the only layer that can.
enforceBrowserRouteWebRtcPolicy(guest, () => {})
return () => {
forgetGuest()
if (guest.isDestroyed()) {
return
}
guest.off('destroyed', forgetGuest)
guest.off('did-start-navigation', latchFromMainFrameNavigation)
guest.off('did-navigate', latchFromCommittedUrl)
guest.off('will-navigate', navigationGuard)
guest.off('will-redirect', navigationGuard)
guest.off('will-frame-navigate', frameNavigationGuard)
}
}
function isWebUrl(url: string): boolean {
return url.startsWith('http://') || url.startsWith('https://')
}
/**
* The only way a URL leaves a preview. Every condition is load-bearing: the sender must be a live
* preview guest still bound to a grant, it must be the contents the reader is looking at, and the
* target must be the web. Anything else is dropped without a trace the document could observe.
*/
export function reportDocPreviewLinkClick(sender: Electron.WebContents, rawUrl: string): void {
const registration = previewGuests.get(sender)
if (!registration) {
return
}
const boundGrantId = registration.readBoundGrantId()
if (boundGrantId === null || !getDocPreviewGrant(boundGrantId)) {
return
}
// Why focus and not just the preload's trusted-click check: that check runs inside the guest, so
// it holds only while the guest renderer does. Focus is the half main can verify for itself.
if (!registration.isFocused()) {
return
}
const externalUrl = normalizeExternalBrowserUrl(rawUrl)
if (!externalUrl || !isWebUrl(externalUrl)) {
return
}
registration.host.send(DOC_PREVIEW_EXTERNAL_LINK_CHANNEL, { url: externalUrl })
}
@@ -0,0 +1,336 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
readDocPreviewFile: vi.fn(),
installBrowserSessionPartitionPolicies: vi.fn()
}))
function createFakeSession(): {
protocol: { isProtocolHandled: () => boolean; handle: ReturnType<typeof vi.fn> }
webRequest: { onBeforeRequest: ReturnType<typeof vi.fn> }
} {
return {
protocol: { isProtocolHandled: () => false, handle: vi.fn() },
webRequest: { onBeforeRequest: vi.fn() }
}
}
// Why one session per partition and a distinct default: installing the handler on the default
// session instead of the preview session is otherwise invisible — every read would come back
// from the same object.
const previewSession = createFakeSession()
const defaultSession = createFakeSession()
vi.mock('electron', () => ({
protocol: { registerSchemesAsPrivileged: vi.fn() },
session: {
get defaultSession() {
return defaultSession
},
fromPartition: (partition: string) => {
if (partition !== 'orca-doc-preview') {
throw new Error(`unexpected partition ${partition}`)
}
return previewSession
}
}
}))
vi.mock('./doc-preview-file-reader', () => ({ readDocPreviewFile: mocks.readDocPreviewFile }))
vi.mock('./browser-session-partition-policies', () => ({
installBrowserSessionPartitionPolicies: mocks.installBrowserSessionPartitionPolicies
}))
import { protocol } from 'electron'
import {
getDocPreviewSession,
handleDocPreviewRequest,
installDocPreviewProtocolHandler,
isAllowedDocPreviewRequestUrl,
isDocPreviewSession,
registerDocPreviewSchemePrivileges
} from './doc-preview-protocol'
import {
mintDocPreviewGrant,
revokeAllDocPreviewGrants,
revokeDocPreviewGrant
} from './doc-preview-grant-registry'
import {
buildDocPreviewUrl,
DOC_PREVIEW_LOAD_FAILURE_CHANNEL
} from '../../shared/doc-preview-scheme'
import { setDocPreviewFailureSink } from './doc-preview-failure-notice'
function mintGrant(): ReturnType<typeof mintDocPreviewGrant> {
return mintDocPreviewGrant({
owner: { kind: 'ssh', connectionId: 'ssh-1' },
root: '/home/alice/docs',
entryRelativePath: 'index.html',
browserPageId: 'page-1'
})
}
beforeEach(() => {
vi.clearAllMocks()
revokeAllDocPreviewGrants()
setDocPreviewFailureSink(null)
mocks.readDocPreviewFile.mockResolvedValue({
ok: true,
bytes: Buffer.from('<h1>hi</h1>', 'utf8'),
contentType: 'text/html; charset=utf-8'
})
})
describe('handleDocPreviewRequest', () => {
it('serves an in-grant document with no-store so reload re-reads the workspace', async () => {
const grant = mintGrant()
const response = await handleDocPreviewRequest(
new Request(buildDocPreviewUrl(grant.id, 'index.html'))
)
expect(response.status).toBe(200)
expect(response.headers.get('Content-Type')).toBe('text/html; charset=utf-8')
expect(response.headers.get('Cache-Control')).toBe('no-store')
expect(await response.text()).toBe('<h1>hi</h1>')
expect(mocks.readDocPreviewFile).toHaveBeenCalledWith(grant, 'index.html')
})
it('falls back to the granted entry document for a root request', async () => {
const grant = mintGrant()
await handleDocPreviewRequest(new Request(`orca-preview://${grant.id}/`))
expect(mocks.readDocPreviewFile).toHaveBeenCalledWith(grant, 'index.html')
})
it('decodes percent-encoded segments before resolving', async () => {
const grant = mintGrant()
await handleDocPreviewRequest(new Request(buildDocPreviewUrl(grant.id, 'a b/c#d.html')))
expect(mocks.readDocPreviewFile).toHaveBeenCalledWith(grant, 'a b/c#d.html')
})
it('404s an unknown grant without reading anything', async () => {
const response = await handleDocPreviewRequest(
new Request(`orca-preview://${'0'.repeat(32)}/index.html`)
)
expect(response.status).toBe(404)
expect(mocks.readDocPreviewFile).not.toHaveBeenCalled()
})
it('404s a revoked grant', async () => {
const grant = mintGrant()
revokeDocPreviewGrant(grant.id)
const response = await handleDocPreviewRequest(
new Request(buildDocPreviewUrl(grant.id, 'index.html'))
)
expect(response.status).toBe(404)
expect(mocks.readDocPreviewFile).not.toHaveBeenCalled()
})
it('404s a malformed grant id', async () => {
const response = await handleDocPreviewRequest(new Request('orca-preview://not-a-grant/x.html'))
expect(response.status).toBe(404)
expect(mocks.readDocPreviewFile).not.toHaveBeenCalled()
})
it('propagates the reader status for an unservable asset', async () => {
const grant = mintGrant()
mocks.readDocPreviewFile.mockResolvedValue({
ok: false,
status: 415,
reason: 'unsupported-asset',
message: 'cannot send this file type'
})
const response = await handleDocPreviewRequest(
new Request(buildDocPreviewUrl(grant.id, 'a.woff2'))
)
expect(response.status).toBe(415)
expect(await response.text()).toBe('cannot send this file type')
})
// Why: previewed documents are agent-authored, so an outbound request is an exfiltration channel
// for everything else the grant can read.
it('serves documents under a self-only content security policy', async () => {
const grant = mintGrant()
const response = await handleDocPreviewRequest(
new Request(buildDocPreviewUrl(grant.id, 'index.html'))
)
const policy = response.headers.get('Content-Security-Policy') ?? ''
expect(policy).toContain("default-src 'self'")
expect(policy).toContain("connect-src 'self'")
expect(policy).toContain("object-src 'none'")
expect(policy).toContain("img-src 'self' data:")
expect(policy).not.toContain('https:')
// Why assert an absence: `webrtc 'block'` is the obvious directive to reach for here and this
// Chromium does not implement it — it logs "Unrecognized Content-Security-Policy directive
// 'webrtc'" and gathers candidates regardless. Adding it back would document a fence that is
// not there; the guest's IP-handling policy is what actually refuses.
expect(policy).not.toContain('webrtc')
})
// Why: the guest paints a 4xx body as if it were the document, so the shell only learns the
// reason from this push.
it('pushes the failure reason for the requested path', async () => {
const send = vi.fn()
setDocPreviewFailureSink({ send })
const grant = mintGrant()
mocks.readDocPreviewFile.mockResolvedValue({
ok: false,
status: 413,
reason: 'too-large',
message: 'too large'
})
await handleDocPreviewRequest(new Request(buildDocPreviewUrl(grant.id, 'index.html')))
expect(send).toHaveBeenCalledWith(DOC_PREVIEW_LOAD_FAILURE_CHANNEL, {
grantId: grant.id,
relativePath: 'index.html',
reason: 'too-large'
})
})
it('pushes nothing when the document is served', async () => {
const send = vi.fn()
setDocPreviewFailureSink({ send })
const grant = mintGrant()
await handleDocPreviewRequest(new Request(buildDocPreviewUrl(grant.id, 'index.html')))
expect(send).not.toHaveBeenCalled()
})
// Why: a grant revoked underneath a live guest 404s like a missing file, and without this push
// the shell leaves the handler's "Not found" body on screen as if it were the document.
it('pushes a failure for a revoked grant so the shell can replace the 404 body', async () => {
const send = vi.fn()
const grant = mintGrant()
revokeDocPreviewGrant(grant.id)
setDocPreviewFailureSink({ send })
await handleDocPreviewRequest(new Request(buildDocPreviewUrl(grant.id, 'index.html')))
expect(send).toHaveBeenCalledWith(DOC_PREVIEW_LOAD_FAILURE_CHANNEL, {
grantId: grant.id,
relativePath: 'index.html',
reason: 'unreadable'
})
})
})
describe('installDocPreviewProtocolHandler', () => {
// Why the default session is named here: it is the session every other Electron API reaches for
// by default, and handling the scheme there would serve preview bytes to ordinary browsing.
it('handles the scheme on the preview session and nowhere else', () => {
installDocPreviewProtocolHandler()
expect(previewSession.protocol.handle).toHaveBeenCalledWith(
'orca-preview',
handleDocPreviewRequest
)
expect(previewSession.webRequest.onBeforeRequest).toHaveBeenCalled()
expect(defaultSession.protocol.handle).not.toHaveBeenCalled()
expect(defaultSession.webRequest.onBeforeRequest).not.toHaveBeenCalled()
})
it('cancels every request the preview session should never carry', () => {
installDocPreviewProtocolHandler()
const filter = previewSession.webRequest.onBeforeRequest.mock.calls.at(-1)?.[0] as (
details: { url: string },
callback: (response: { cancel: boolean }) => void
) => void
const cancelled = (url: string): boolean => {
let response: { cancel: boolean } | null = null
filter({ url }, (value) => {
response = value
})
return (response as { cancel: boolean } | null)?.cancel === true
}
expect(cancelled('https://cdn.example.com/tracker.js')).toBe(true)
expect(cancelled(`orca-preview://${'a'.repeat(32)}/index.html`)).toBe(false)
})
// Why: preview guests are webviews like any other, so they must not skip the deny-by-default
// permission and display-media policy every browser partition gets.
it('applies the shared browser partition policies to the preview session', () => {
installDocPreviewProtocolHandler()
expect(mocks.installBrowserSessionPartitionPolicies).toHaveBeenCalledWith(
expect.objectContaining({ partition: 'orca-doc-preview', userAgentMode: 'clean' }),
expect.anything()
)
})
// Why downloads and nothing else: the browser download flow attributes a file to the page that
// asked for it, and a previewed document is no page. Routed, it would write remote-authored bytes
// into this desktop's Downloads folder with no prompt and no tab to name as the source.
it('asks for downloads to be denied on the preview partition', () => {
installDocPreviewProtocolHandler()
expect(mocks.installBrowserSessionPartitionPolicies).toHaveBeenCalledWith(expect.anything(), {
downloads: 'deny'
})
})
})
describe('registerDocPreviewSchemePrivileges', () => {
// Why the whole literal and not a subset: every privilege this scheme does not claim is one the
// document cannot use to escape it. `allowServiceWorkers` would outlive the tab that was granted
// the read, and `bypassCSP` would undo the self-only policy the handler serves with.
it('claims exactly the privileges the preview document needs', () => {
registerDocPreviewSchemePrivileges()
expect(protocol.registerSchemesAsPrivileged).toHaveBeenCalledWith([
{
scheme: 'orca-preview',
privileges: {
standard: true,
secure: true,
supportFetchAPI: true,
corsEnabled: true,
stream: true
}
}
])
})
})
describe('isAllowedDocPreviewRequestUrl', () => {
// Why: the session refuses to carry the request at all, so a CSP bypass in one element type
// still reaches nothing off-machine.
it('admits in-document schemes and refuses everything that leaves the machine', () => {
expect(isAllowedDocPreviewRequestUrl(`orca-preview://${'a'.repeat(32)}/index.html`)).toBe(true)
expect(isAllowedDocPreviewRequestUrl('devtools://devtools/bundled/inspector.html')).toBe(true)
expect(isAllowedDocPreviewRequestUrl('data:image/png;base64,AAA')).toBe(true)
expect(isAllowedDocPreviewRequestUrl('blob:orca-preview://abc/123')).toBe(true)
expect(isAllowedDocPreviewRequestUrl('https://cdn.example.com/app.css')).toBe(false)
expect(isAllowedDocPreviewRequestUrl('http://127.0.0.1:9999/exfil')).toBe(false)
expect(isAllowedDocPreviewRequestUrl('ws://evil.example.com/socket')).toBe(false)
expect(isAllowedDocPreviewRequestUrl('file:///etc/passwd')).toBe(false)
})
})
describe('isDocPreviewSession', () => {
it('claims no session until the preview session has been materialized', () => {
expect(isDocPreviewSession({} as never)).toBe(false)
})
it('matches only the memoized preview session', () => {
const created = getDocPreviewSession()
expect(isDocPreviewSession(created)).toBe(true)
expect(isDocPreviewSession({} as never)).toBe(false)
expect(getDocPreviewSession()).toBe(created)
})
})
+144
View File
@@ -0,0 +1,144 @@
import { protocol, session } from 'electron'
import {
DOC_PREVIEW_PARTITION,
DOC_PREVIEW_SCHEME,
parseDocPreviewUrl
} from '../../shared/doc-preview-scheme'
import { installBrowserSessionPartitionPolicies } from './browser-session-partition-policies'
import { readDocPreviewFile } from './doc-preview-file-reader'
import { publishDocPreviewFailure } from './doc-preview-failure-notice'
import { getDocPreviewGrant } from './doc-preview-grant-registry'
/** Must run before `app.whenReady()`; Electron freezes the privileged scheme table at ready. */
export function registerDocPreviewSchemePrivileges(): void {
protocol.registerSchemesAsPrivileged([
{
scheme: DOC_PREVIEW_SCHEME,
privileges: {
standard: true,
secure: true,
supportFetchAPI: true,
corsEnabled: true,
stream: true
}
}
])
}
let docPreviewSession: Electron.Session | null = null
/** Non-persistent session, so preview bytes never land in a browsing profile's storage. */
export function getDocPreviewSession(): Electron.Session {
docPreviewSession ??= session.fromPartition(DOC_PREVIEW_PARTITION)
return docPreviewSession
}
/** Pure identity check: every guest attach consults it, and none should materialize a session. */
export function isDocPreviewSession(candidate: Electron.Session): boolean {
return docPreviewSession !== null && candidate === docPreviewSession
}
/**
* Product decision, not a hardening default: previewed documents are agent-authored, so any
* outbound request they can make is an exfiltration channel for whatever else the page can read.
* Self-contained documents — inline CSS/JS/SVG and in-grant assets — render in full; a CDN
* stylesheet, font, script, or analytics beacon deliberately does not load.
*/
const DOC_PREVIEW_CONTENT_SECURITY_POLICY = [
"default-src 'self'",
"script-src 'self' 'unsafe-inline'",
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data:",
"font-src 'self' data:",
"connect-src 'self'",
"frame-src 'self'",
"object-src 'none'"
// Why no `webrtc 'block'` here, though a peer connection is exactly the outbound channel this
// policy is meant to close: Chromium 43-era answers that directive with "Unrecognized
// Content-Security-Policy directive 'webrtc'" and gathers candidates anyway, so listing it would
// read as a fence while fencing nothing. The guest's IP-handling policy is the one that holds —
// see `installDocPreviewGuestPolicy`.
].join('; ')
function notFound(message: string): Response {
return new Response(message, {
status: 404,
headers: { 'Content-Type': 'text/plain; charset=utf-8' }
})
}
export async function handleDocPreviewRequest(request: Request): Promise<Response> {
const target = parseDocPreviewUrl(request.url)
if (!target) {
return notFound('Not found')
}
const grant = getDocPreviewGrant(target.grantId)
if (!grant) {
// Why: a revoked or unknown grant is indistinguishable from a missing file by design — but the
// shell still needs to know, or the guest paints this body where the document should be.
publishDocPreviewFailure({
grantId: target.grantId,
relativePath: target.relativePath,
reason: 'unreadable'
})
return notFound('Not found')
}
const relativePath = target.relativePath || grant.entryRelativePath
const outcome = await readDocPreviewFile(grant, relativePath)
if (!outcome.ok) {
publishDocPreviewFailure({ grantId: target.grantId, relativePath, reason: outcome.reason })
return new Response(outcome.message, {
status: outcome.status,
headers: { 'Content-Type': 'text/plain; charset=utf-8' }
})
}
return new Response(new Uint8Array(outcome.bytes), {
status: 200,
headers: {
'Content-Type': outcome.contentType,
'Content-Security-Policy': DOC_PREVIEW_CONTENT_SECURITY_POLICY,
// Why: reload must re-read the workspace disk, so nothing may be cached.
'Cache-Control': 'no-store'
}
})
}
/** `data:`/`blob:` never leave the document; every other scheme would reach off-machine. */
export function isAllowedDocPreviewRequestUrl(url: string): boolean {
return (
url.startsWith(`${DOC_PREVIEW_SCHEME}://`) ||
url.startsWith('devtools://') ||
url.startsWith('data:') ||
url.startsWith('blob:')
)
}
export function installDocPreviewProtocolHandler(): void {
const previewSession = getDocPreviewSession()
if (previewSession.protocol.isProtocolHandled(DOC_PREVIEW_SCHEME)) {
return
}
previewSession.protocol.handle(DOC_PREVIEW_SCHEME, handleDocPreviewRequest)
// Why: the response CSP is the document's own promise to obey; this is the session refusing to
// carry the request at all, so a CSP bypass in one element type still reaches nothing.
previewSession.webRequest.onBeforeRequest((details, callback) => {
callback({ cancel: !isAllowedDocPreviewRequestUrl(details.url) })
})
// Why: preview guests are webviews like any other, so they inherit the same deny-by-default
// permission, display-media and user-agent policy every browser partition gets.
installBrowserSessionPartitionPolicies(
{
id: DOC_PREVIEW_PARTITION,
scope: 'isolated',
partition: DOC_PREVIEW_PARTITION,
label: 'Document preview',
source: null,
userAgentMode: 'clean'
},
// Why downloads are the one policy that does not carry over: the browser download flow needs a
// page to attribute the file to, and a previewed document is not one. Routed here it would
// reserve a name in this desktop's Downloads folder and write remote-authored bytes into it
// with nothing in the UI naming the tab that asked, and no prompt in front of it.
{ downloads: 'deny' }
)
}
@@ -2,7 +2,7 @@ import { EventEmitter } from 'node:events'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
windows: [] as { webContents: MockWebContents }[],
windows: [] as MockBrowserWindow[],
BrowserWindow: vi.fn(),
finishLoads: true
}))
@@ -50,6 +50,41 @@ vi.mock('./browser-session-registry', () => ({
}))
import { OffscreenBrowserBackend } from './offscreen-browser-backend'
import { installDocPreviewGuestPolicy, isWorkspaceDocPageId } from './doc-preview-guest-policy'
import { mintDocPreviewGrant } from './doc-preview-grant-registry'
import { buildDocPreviewUrl } from '../../shared/doc-preview-scheme'
/** The real door's answer, so the backend is tested against the refusal it will actually get. */
function registerOffscreenGuestLikeBrowserManager({
browserPageId
}: {
browserPageId: string
}): boolean {
return !isWorkspaceDocPageId(browserPageId)
}
/**
* A page the document half of the registry really owns. Built rather than named, because the door
* refuses on registry membership now — a made-up id would be admitted and prove nothing.
*/
function registerWorkspaceDocPage(browserPageId: string): void {
const grant = mintDocPreviewGrant({
owner: { kind: 'ssh', connectionId: 'ssh-1' },
root: '/home/alice/docs',
entryRelativePath: 'index.html',
browserPageId
})
const guest = {
isFocused: () => false,
isDestroyed: () => false,
getURL: () => buildDocPreviewUrl(grant.id, grant.entryRelativePath),
on: vi.fn(),
once: vi.fn(),
setWindowOpenHandler: vi.fn(),
setWebRTCIPHandlingPolicy: vi.fn()
}
installDocPreviewGuestPolicy(guest as never, { id: 1, send: vi.fn() })
}
describe('OffscreenBrowserBackend lifecycle', () => {
beforeEach(() => {
@@ -73,7 +108,7 @@ describe('OffscreenBrowserBackend lifecycle', () => {
vi.useFakeTimers()
mocks.finishLoads = false
const browserManager = {
registerOffscreenGuest: vi.fn(),
registerOffscreenGuest: vi.fn(registerOffscreenGuestLikeBrowserManager),
unregisterGuest: vi.fn()
}
const backend = new OffscreenBrowserBackend(browserManager as never)
@@ -94,9 +129,36 @@ describe('OffscreenBrowserBackend lifecycle', () => {
vi.useRealTimers()
})
// Why the unregister assertion and not just the destroy: a refused id is one the document half
// of the registry owns, and the teardown hook would cancel that preview's work on the way out.
it('destroys a window whose registration was refused without unregistering the id', async () => {
const browserManager = {
registerOffscreenGuest: vi.fn(registerOffscreenGuestLikeBrowserManager),
unregisterGuest: vi.fn()
}
const backend = new OffscreenBrowserBackend(browserManager as never)
registerWorkspaceDocPage('doc-page-1')
await expect(
backend.createTab({
browserPageId: 'doc-page-1',
url: 'https://example.com',
worktreeId: 'wt'
})
).rejects.toThrow('was refused')
expect(mocks.windows[0].isDestroyed()).toBe(true)
expect(browserManager.unregisterGuest).not.toHaveBeenCalled()
// Why shutdown and not the map: a refused id left behind is invisible until teardown walks it
// and hands that id to the other authority after all.
await backend.destroyAll()
expect(browserManager.unregisterGuest).not.toHaveBeenCalled()
})
it('unregisters a closing page before awaiting owner retirement', async () => {
const browserManager = {
registerOffscreenGuest: vi.fn(),
registerOffscreenGuest: vi.fn(registerOffscreenGuestLikeBrowserManager),
unregisterGuest: vi.fn()
}
let releaseOwnerRetirement!: () => void
@@ -126,7 +188,7 @@ describe('OffscreenBrowserBackend lifecycle', () => {
it('preserves a replacement page when the old window finishes closing', async () => {
const browserManager = {
registerOffscreenGuest: vi.fn(),
registerOffscreenGuest: vi.fn(registerOffscreenGuestLikeBrowserManager),
unregisterGuest: vi.fn()
}
let releaseOwnerRetirement!: () => void
@@ -152,7 +214,7 @@ describe('OffscreenBrowserBackend lifecycle', () => {
it('retires the helper when an offscreen renderer is destroyed unexpectedly', async () => {
const browserManager = {
registerOffscreenGuest: vi.fn(),
registerOffscreenGuest: vi.fn(registerOffscreenGuestLikeBrowserManager),
unregisterGuest: vi.fn()
}
const onPageClosed = vi.fn(async () => {})
@@ -167,7 +229,7 @@ describe('OffscreenBrowserBackend lifecycle', () => {
it('cleans every helper owner during backend shutdown', async () => {
const browserManager = {
registerOffscreenGuest: vi.fn(),
registerOffscreenGuest: vi.fn(registerOffscreenGuestLikeBrowserManager),
unregisterGuest: vi.fn()
}
const onPageClosed = vi.fn(async () => {})
@@ -186,7 +248,7 @@ describe('OffscreenBrowserBackend lifecycle', () => {
it('rejects a concurrent create while shutdown is draining owned pages', async () => {
const browserManager = {
registerOffscreenGuest: vi.fn(),
registerOffscreenGuest: vi.fn(registerOffscreenGuestLikeBrowserManager),
unregisterGuest: vi.fn()
}
let releaseOwnerRetirement!: () => void
@@ -215,7 +277,7 @@ describe('OffscreenBrowserBackend lifecycle', () => {
it('joins owner retirement started by an unexpected renderer destroy', async () => {
const browserManager = {
registerOffscreenGuest: vi.fn(),
registerOffscreenGuest: vi.fn(registerOffscreenGuestLikeBrowserManager),
unregisterGuest: vi.fn()
}
let releaseOwnerRetirement!: () => void
@@ -244,7 +306,7 @@ describe('OffscreenBrowserBackend lifecycle', () => {
it('bounds concurrent helper retirements during shutdown', async () => {
const browserManager = {
registerOffscreenGuest: vi.fn(),
registerOffscreenGuest: vi.fn(registerOffscreenGuestLikeBrowserManager),
unregisterGuest: vi.fn()
}
let activeRetirements = 0
@@ -284,7 +346,10 @@ describe('OffscreenBrowserBackend lifecycle', () => {
})
it('closes the page even when daemon retirement throws', async () => {
const browserManager = { registerOffscreenGuest: vi.fn(), unregisterGuest: vi.fn() }
const browserManager = {
registerOffscreenGuest: vi.fn(registerOffscreenGuestLikeBrowserManager),
unregisterGuest: vi.fn()
}
const backend = new OffscreenBrowserBackend(browserManager as never, {
getAgentBrowserBridge: () => ({
onPageClosed: vi.fn(async () => {
+27 -17
View File
@@ -66,9 +66,33 @@ export class OffscreenBrowserBackend implements BrowserBackend {
this.windowsByPageId.set(browserPageId, win)
// Why: if the offscreen window is destroyed out from under us (crash, app
// teardown), drop the registry entry so commands fail cleanly instead of
// resolving a dead WebContents.
// Why: register the guest and return immediately so the new tab appears
// without waiting for the page to finish loading. Previously createTab
// awaited the full navigation, so clicking "New Browser Tab" did nothing for
// up to a second on real URLs. The page loads asynchronously and streams
// once it paints; a failed load leaves the (usable) tab open, matching how a
// normal browser tab survives a failed navigation.
const registered = this.browserManager.registerOffscreenGuest({
browserPageId,
worktreeId: params.worktreeId,
sessionProfileId: profile?.id ?? null,
userAgentMode: profile?.userAgentMode,
webContentsId: win.webContents.id
})
if (!registered) {
// Why destroy rather than carry on: the window already exists but carries none of the guest
// policies registration installs, so leaving it would navigate an unvalidated URL with no
// policy on it and hand back a page id nothing can drive. The renderer door aborts its mount
// the same way; this is that abort.
this.windowsByPageId.delete(browserPageId)
win.destroy()
throw new Error(`Browser page ${browserPageId} was refused`)
}
// Why only once registration took: this teardown unregisters the page id, and a refused id is
// one the other authority may own — cancelling its work would be the confusion we just refused.
// Why at all: if the window dies out from under us (crash, app teardown), drop the registry
// entry so commands fail cleanly instead of resolving a dead WebContents.
win.webContents.once('destroyed', () => {
// Explicit close removes the page first and performs awaited cleanup;
// only an unexpected destruction still owns the bridge retirement here.
@@ -80,20 +104,6 @@ export class OffscreenBrowserBackend implements BrowserBackend {
this.browserManager.unregisterGuest(browserPageId)
})
// Why: register the guest and return immediately so the new tab appears
// without waiting for the page to finish loading. Previously createTab
// awaited the full navigation, so clicking "New Browser Tab" did nothing for
// up to a second on real URLs. The page loads asynchronously and streams
// once it paints; a failed load leaves the (usable) tab open, matching how a
// normal browser tab survives a failed navigation.
this.browserManager.registerOffscreenGuest({
browserPageId,
worktreeId: params.worktreeId,
sessionProfileId: profile?.id ?? null,
userAgentMode: profile?.userAgentMode,
webContentsId: win.webContents.id
})
const url = params.url || 'about:blank'
void this.loadUrl(win, url).catch((error) => {
console.warn(
+6 -4
View File
@@ -539,12 +539,14 @@ describe('ClaudeHookService.installRemote', () => {
expect(script!.indexOf('printf "{}\\n"')).toBe(
script!.indexOf('#!/bin/sh') + '#!/bin/sh\n'.length
)
// Why: payload is piped to curl via stdin (`payload@-`) so it never lands
// on the curl command line (EDR oversized-command-line false positive),
// matching the Windows curl.exe hook post.
// Why: payload stays on stdin, while metadata headers avoid URL-encoded IDS signatures.
expect(script).toContain('printf \'%s\' "$payload" | curl')
expect(script).toContain('-H "Content-Type: application/json"')
expect(script).toContain('orca_hook_metadata=$(printf')
expect(script).toContain('unset ORCA_AGENT_HOOK_TRANSPORT')
expect(script).toContain('-H "X-Orca-Agent-Hook-Meta: ${orca_hook_metadata}"')
expect(script).toContain('--data-binary @-')
expect(script).toContain('--data-urlencode "payload@-"')
expect(script).not.toContain('--data-urlencode "payload=${payload}"')
expect(fs.modes.get('/home/dev/.orca/agent-hooks/claude-hook.sh')).toBe(0o755)
// Why: no remote statusLine — this path serves SSH remotes and WSL guests, whose relay
// listener doesn't route /statusline/claude and whose accounts aren't attributable locally.
+6 -13
View File
@@ -9,6 +9,7 @@ import {
writeManagedScript,
type HooksConfig
} from '../agent-hooks/installer-utils'
import { buildPosixAgentHookPostCommand } from '../agent-hooks/hook-post-command'
import {
readHooksJsonRemote,
writeHooksJsonRemote,
@@ -114,25 +115,17 @@ function getManagedScript(
// Why: refresh endpoint coordinates for PTYs surviving an Orca restart.
// Why: suppress parse errors so they neither leak nor trip outer set -e.
'if [ -n "$ORCA_AGENT_HOOK_ENDPOINT" ] && [ -r "$ORCA_AGENT_HOOK_ENDPOINT" ]; then',
' unset ORCA_AGENT_HOOK_TRANSPORT',
' . "$ORCA_AGENT_HOOK_ENDPOINT" 2>/dev/null || :',
'fi',
'if [ -z "$ORCA_AGENT_HOOK_PORT" ] || [ -z "$ORCA_AGENT_HOOK_TOKEN" ] || [ -z "$ORCA_PANE_KEY" ]; then',
' spool_hook_event',
' exit 0',
'fi',
// Why: post form fields because path-bearing payloads are unsafe in hand-built JSON.
// Why: pipe payload to curl stdin to keep large output off the command line.
'printf \'%s\' "$payload" | curl -sS -X POST "http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/claude" \\',
' --connect-timeout 0.5 --max-time 1.5 \\',
' -H "Content-Type: application/x-www-form-urlencoded" \\',
' -H "X-Orca-Agent-Hook-Token: ${ORCA_AGENT_HOOK_TOKEN}" \\',
' --data-urlencode "paneKey=${ORCA_PANE_KEY}" \\',
' --data-urlencode "tabId=${ORCA_TAB_ID}" \\',
' --data-urlencode "launchToken=${ORCA_AGENT_LAUNCH_TOKEN}" \\',
' --data-urlencode "worktreeId=${ORCA_WORKTREE_ID}" \\',
' --data-urlencode "env=${ORCA_AGENT_HOOK_ENV}" \\',
' --data-urlencode "version=${ORCA_AGENT_HOOK_VERSION}" \\',
' --data-urlencode "payload@-" >/dev/null 2>&1 || spool_hook_event',
// Why: keep full hook JSON off the command line and avoid IDS-friendly URL-encoded paths.
...buildPosixAgentHookPostCommand('claude').map((line, index, lines) =>
index === lines.length - 1 ? `${line} >/dev/null 2>&1 || spool_hook_event` : line
),
'exit 0',
''
].join('\n')
@@ -108,4 +108,36 @@ describe('runExclusivelyForCodexTrustConfig', () => {
await queued
expect(secondStarted).toBe(true)
})
it('coalesces WSL UNC aliases without folding the case-sensitive Linux path', async () => {
const aliasGate = deferred()
let aliasStarted = false
const blockedAlias = runExclusivelyForCodexTrustConfig(
String.raw`\\wsl.localhost\Ubuntu\home\Alice\.codex\config.toml`,
() => aliasGate.promise
)
const queuedAlias = runExclusivelyForCodexTrustConfig(
String.raw`\\wsl$\ubuntu\home\Alice\.codex\config.toml`,
() => {
aliasStarted = true
return Promise.resolve()
}
)
await Promise.resolve()
expect(aliasStarted).toBe(false)
let distinctStarted = false
await runExclusivelyForCodexTrustConfig(
String.raw`\\wsl.localhost\Ubuntu\home\alice\.codex\config.toml`,
async () => {
distinctStarted = true
}
)
expect(distinctStarted).toBe(true)
aliasGate.resolve()
await blockedAlias
await queuedAlias
expect(aliasStarted).toBe(true)
})
})
@@ -16,6 +16,7 @@ import {
type CodexTrustEntry
} from './config-toml-trust'
import {
CodexHookService,
_internals,
createCodexWslRuntimeHookInstallPlan,
type CodexWslRuntimeHookInstallPlan
@@ -82,7 +83,67 @@ function expectedManagedCommand(scriptPath: string): string {
}
describe('Codex WSL runtime hook install', () => {
it('plans WSL hook files with Linux command and trust paths', async () => {
it('coalesces aliases of one runtime home without blocking independent homes', async () => {
const service = new CodexHookService()
const releases: (() => void)[] = []
const started: string[] = []
vi.spyOn(service, 'installForRuntimeHome').mockImplementation(async (runtimeHomePath) => {
started.push(runtimeHomePath!)
await new Promise<void>((resolve) => releases.push(resolve))
return null
})
const firstHome = '\\\\wsl$\\Ubuntu\\home\\Alice\\.local\\share\\orca\\codex-runtime-home\\home'
const alias = firstHome.replace('\\\\wsl$', '\\\\wsl.localhost')
const independent = firstHome.replace('\\Alice\\', '\\Bob\\')
const first = service.installForRuntimeHomeSerialized(firstHome)
const second = service.installForRuntimeHomeSerialized(alias)
const third = service.installForRuntimeHomeSerialized(independent)
await vi.waitFor(() => expect(started).toEqual([firstHome, independent]))
expect(second).toBe(first)
releases.splice(0).forEach((release) => release())
await Promise.all([first, second, third])
expect(started).toEqual([firstHome, independent])
})
it('does not coalesce one drive-backed home across WSL distros', async () => {
const service = new CodexHookService()
const started: string[] = []
vi.spyOn(service, 'installForRuntimeHome').mockImplementation(async (_runtimeHome, target) => {
started.push(target?.wslDistro ?? '')
return null
})
const home = 'D:\\wsl-home\\.local\\share\\orca\\codex-runtime-home\\home'
await Promise.all([
service.installForRuntimeHomeSerialized(home, { runtime: 'wsl', wslDistro: 'Ubuntu' }),
service.installForRuntimeHomeSerialized(home, { runtime: 'wsl', wslDistro: 'Debian' })
])
expect(started).toEqual(['Ubuntu', 'Debian'])
})
it('keeps case-distinct Linux runtime homes on separate queues', async () => {
const service = new CodexHookService()
const started: string[] = []
vi.spyOn(service, 'installForRuntimeHome').mockImplementation(async (runtimeHomePath) => {
started.push(runtimeHomePath!)
return null
})
const upper =
'\\\\wsl.localhost\\Ubuntu\\home\\Alice\\.local\\share\\orca\\codex-runtime-home\\home'
const lower = upper.replace('\\Alice\\', '\\alice\\')
await Promise.all([
service.installForRuntimeHomeSerialized(upper),
service.installForRuntimeHomeSerialized(lower)
])
expect(started).toEqual([upper, lower])
})
it('plans WSL hook files with Linux command and trust paths', () => {
const runtimeHome =
'\\\\wsl.localhost\\Ubuntu\\home\\alice\\.local\\share\\orca\\codex-runtime-home\\home'
@@ -275,11 +336,15 @@ describe('Codex WSL runtime hook install', () => {
it('generates a POSIX hook that bridges WSL loopback failures through Windows curl', async () => {
const script = _internals.getManagedScript('posix')
expect(script).toContain('load_hook_endpoint()')
expect(script).toContain('unset ORCA_AGENT_HOOK_TRANSPORT')
expect(script).toContain('"set ORCA_AGENT_HOOK_TOKEN="*)')
expect(script).toContain('post_codex_hook()')
expect(script).toContain('is_wsl_runtime()')
expect(script).toContain('WSL_DISTRO_NAME')
expect(script).toContain('windows_curl=$(command -v curl.exe 2>/dev/null || true)')
expect(script).toContain('-H "Content-Type: application/json"')
expect(script).toContain('-H "X-Orca-Agent-Hook-Meta-Encoding: base64"')
expect(script).toContain('--data-binary @-')
expect(script).toContain('--data-urlencode "payload@-"')
expect(script).toContain('if post_codex_hook curl >/dev/null 2>&1; then')
expect(script).toContain('post_codex_hook "$windows_curl" 3 5 >/dev/null 2>&1 || true')
@@ -302,6 +367,7 @@ describe('Codex WSL runtime hook install', () => {
'set ORCA_AGENT_HOOK_TOKEN=fresh-token',
'set ORCA_AGENT_HOOK_ENV=development',
'set ORCA_AGENT_HOOK_VERSION=1',
'set ORCA_AGENT_HOOK_TRANSPORT=raw-json-v1',
''
].join('\r\n'),
'utf-8'
@@ -333,6 +399,7 @@ describe('Codex WSL runtime hook install', () => {
const posted = readFileSync(capturePath, 'utf-8')
expect(posted).toContain('http://127.0.0.1:43210/hook/codex')
expect(posted).toContain('X-Orca-Agent-Hook-Token: fresh-token')
expect(posted).toContain('Content-Type: application/json')
expect(posted).not.toContain('stale-token')
}
)
+47 -18
View File
@@ -19,6 +19,7 @@ import {
writeManagedScript,
type HookDefinition
} from '../agent-hooks/installer-utils'
import { buildPosixAgentHookPostCommand } from '../agent-hooks/hook-post-command'
import { refreshManagedScriptIfPresent } from '../agent-hooks/managed-hook-script-refresh'
import { resolveHooksJsonWritePath } from '../agent-hooks/hook-config-write-path'
import { writeFileAtomically } from '../codex-accounts/fs-utils'
@@ -823,6 +824,7 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string {
// Why: sourcing refreshes PORT/TOKEN/ENV/VERSION from the current Orca so a surviving PTY keeps reporting after a restart (see claude/hook-service.ts).
'load_hook_endpoint() {',
' endpoint_path="$1"',
' unset ORCA_AGENT_HOOK_TRANSPORT',
' case "$endpoint_path" in',
' *.cmd)',
// Why: Windows passes endpoint.cmd into WSL via WSLENV; parse only Orca's known assignments since cmd.exe `set` lines aren't shell syntax.
@@ -834,6 +836,7 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string {
' "set ORCA_AGENT_HOOK_TOKEN="*) ORCA_AGENT_HOOK_TOKEN=${endpoint_line#*=} ;;',
' "set ORCA_AGENT_HOOK_ENV="*) ORCA_AGENT_HOOK_ENV=${endpoint_line#*=} ;;',
' "set ORCA_AGENT_HOOK_VERSION="*) ORCA_AGENT_HOOK_VERSION=${endpoint_line#*=} ;;',
' "set ORCA_AGENT_HOOK_TRANSPORT="*) ORCA_AGENT_HOOK_TRANSPORT=${endpoint_line#*=} ;;',
' esac',
' done < "$endpoint_path"',
' ;;',
@@ -853,20 +856,11 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string {
' curl_bin="$1"',
' connect_timeout="${2:-0.5}"',
' max_time="${3:-1.5}"',
// Why: worktreeId embeds a path, so hand-building JSON in shell is unsafe with quotes/newlines; post raw payload plus metadata as form fields instead.
// Why: pipe payload to curl's stdin (`payload@-`) not an inline arg, so tens-of-KB tool output stays off the command line (EDR false positives).
' printf \'%s\' "$payload" | "$curl_bin" -sS -X POST "http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/codex" \\',
' --connect-timeout "$connect_timeout" --max-time "$max_time" \\',
' --noproxy "127.0.0.1" \\',
' -H "Content-Type: application/x-www-form-urlencoded" \\',
' -H "X-Orca-Agent-Hook-Token: ${ORCA_AGENT_HOOK_TOKEN}" \\',
' --data-urlencode "paneKey=${ORCA_PANE_KEY}" \\',
' --data-urlencode "tabId=${ORCA_TAB_ID}" \\',
' --data-urlencode "launchToken=${ORCA_AGENT_LAUNCH_TOKEN}" \\',
' --data-urlencode "worktreeId=${ORCA_WORKTREE_ID}" \\',
' --data-urlencode "env=${ORCA_AGENT_HOOK_ENV}" \\',
' --data-urlencode "version=${ORCA_AGENT_HOOK_VERSION}" \\',
' --data-urlencode "payload@-"',
// Why: keep full hook JSON off the command line and avoid URL-encoding paths/commands into IDS-friendly traversal signatures.
...buildPosixAgentHookPostCommand('codex', {
curlCommand: '"$curl_bin"',
indent: ' '
}).map((line) => ` ${line}`),
'}',
'is_wsl_runtime() {',
' [ -n "$WSL_DISTRO_NAME" ] && return 0',
@@ -1081,6 +1075,7 @@ export class CodexHookService {
}
private readonly wslReconciliationGeneration = new Map<string, number>()
private readonly wslInstallsInFlight = new Map<string, Promise<AgentHookInstallStatus | null>>()
private supersedeWslReconciliation(runtimeHomePath: string | null | undefined): number {
if (!runtimeHomePath) {
@@ -1177,6 +1172,30 @@ export class CodexHookService {
}
}
installForRuntimeHomeSerialized(
runtimeHomePath: string | null | undefined,
target?: CodexWslRuntimeHookTarget
): Promise<AgentHookInstallStatus | null> {
if (!runtimeHomePath) {
return Promise.resolve(null)
}
const targetKey = target?.runtime === 'wsl' ? target.wslDistro?.trim().toLowerCase() : ''
const key = `${getWslReconciliationKey(runtimeHomePath)}\0${targetKey ?? ''}`
const active = this.wslInstallsInFlight.get(key)
if (active) {
return active
}
const install = this.installForRuntimeHome(runtimeHomePath, target)
this.wslInstallsInFlight.set(key, install)
const clear = (): void => {
if (this.wslInstallsInFlight.get(key) === install) {
this.wslInstallsInFlight.delete(key)
}
}
void install.then(clear, clear)
return install
}
refreshRuntimeUserHooksForRuntimeHome(
runtimeHomePath: string | null | undefined,
target?: CodexWslRuntimeHookTarget
@@ -1492,7 +1511,13 @@ export class CodexHookService {
options?.codexHomeDir?.replace(/\/$/, '') ?? `${remoteHome.replace(/\/$/, '')}/.codex`
const remoteConfigPath = `${codexHomeBase}/hooks.json`
const remoteTomlPath = `${codexHomeBase}/config.toml`
const remoteScriptPath = `${remoteHome.replace(/\/$/, '')}/.orca/agent-hooks/codex-hook.sh`
// Redirected WSL homes must use the same script location and command shape
// as the runtime installer; two representations of one hooks.json race
// into stale trust keys. Plain SSH keeps its guest-home script contract.
const redirectedCodexHome = options?.codexHomeDir?.replace(/\/$/, '')
const remoteScriptPath = redirectedCodexHome
? `${redirectedCodexHome}/.orca/agent-hooks/codex-hook.sh`
: `${remoteHome.replace(/\/$/, '')}/.orca/agent-hooks/codex-hook.sh`
try {
const config = await readHooksJsonRemote(sftp, remoteConfigPath)
if (!config) {
@@ -1505,7 +1530,9 @@ export class CodexHookService {
}
}
const command = wrapPosixHookCommand(remoteScriptPath)
const command = redirectedCodexHome
? wrapReadablePosixHookCommand(remoteScriptPath)
: wrapPosixHookCommand(remoteScriptPath)
const nextHooks = { ...config.hooks }
const managedEvents = new Set<string>(CODEX_EVENTS)
const isManagedCommand = createManagedCommandMatcher('codex-hook.sh')
@@ -1529,11 +1556,13 @@ export class CodexHookService {
const definition: HookDefinition = {
hooks: [buildManagedCommandHook(command)]
}
nextHooks[eventName] = [...cleaned, definition]
nextHooks[eventName] = redirectedCodexHome
? [definition, ...cleaned]
: [...cleaned, definition]
trustEntries.push({
sourcePath: remoteConfigPath,
eventLabel: CODEX_EVENT_LABEL[eventName],
groupIndex: cleaned.length,
groupIndex: redirectedCodexHome ? 0 : cleaned.length,
handlerIndex: 0,
command,
timeoutSec: MANAGED_HOOK_TIMEOUT_SECONDS
@@ -2,10 +2,25 @@ import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
const { wslRealpathMock } = vi.hoisted(() => ({
wslRealpathMock: vi.fn(async (path: string) => path)
}))
vi.mock('node:fs/promises', () => ({ realpath: wslRealpathMock }))
import {
prepareManagedCodexHomeBeforeShellLaunch,
resolveManagedCodexShellPreflightHome
} from './managed-home-shell-preflight'
import {
prepareManagedWslCodexHomeBeforeShellLaunch,
resolveManagedWslCodexShellPreflightTarget
} from './managed-wsl-home-shell-preflight'
import {
_internals as managedWslHomeRegistryInternals,
recordManagedWslCodexHome
} from './managed-wsl-codex-home-registry'
const roots: string[] = []
@@ -16,6 +31,8 @@ function makeRoot(): string {
}
afterEach(() => {
wslRealpathMock.mockClear()
managedWslHomeRegistryInternals.clearRecordedManagedWslCodexHomes()
for (const root of roots.splice(0)) {
rmSync(root, { recursive: true, force: true })
}
@@ -160,3 +177,123 @@ describe('managed Codex shell preflight', () => {
).toBeNull()
})
})
describe('managed WSL Codex shell preflight', () => {
const home = '/home/jin/.local/share/orca/codex-runtime-home/home'
const runtimeHome =
'\\\\wsl.localhost\\Ubuntu-24.04\\home\\jin\\.local\\share\\orca\\codex-runtime-home\\home'
const env = {
CODEX_HOME: home,
ORCA_CODEX_HOME: home,
WSL_DISTRO_NAME: 'Ubuntu-24.04'
}
it('targets the pane-selected managed home through its UNC twin', () => {
recordManagedWslCodexHome('Ubuntu-24.04', runtimeHome)
expect(resolveManagedWslCodexShellPreflightTarget(env)).toEqual({
runtimeHomePath: runtimeHome,
wslDistro: 'Ubuntu-24.04'
})
})
it('reconstructs a validated managed home when a restarted runtime has no record', () => {
expect(resolveManagedWslCodexShellPreflightTarget(env)).toEqual({
runtimeHomePath: runtimeHome,
wslDistro: 'Ubuntu-24.04'
})
})
it('installs once through the WSL runtime-home lane while hooks are enabled', async () => {
recordManagedWslCodexHome('Ubuntu-24.04', runtimeHome)
const status = {
agent: 'codex' as const,
state: 'installed' as const,
configPath: 'config.toml',
managedHooksPresent: true,
detail: null
}
const install = vi.fn(() => status)
await expect(
prepareManagedWslCodexHomeBeforeShellLaunch({ env, hooksEnabled: true, install })
).resolves.toBe(status)
expect(install).toHaveBeenCalledExactlyOnceWith(runtimeHome, {
runtime: 'wsl',
wslDistro: 'Ubuntu-24.04'
})
await expect(
prepareManagedWslCodexHomeBeforeShellLaunch({ env, hooksEnabled: false, install })
).resolves.toBeNull()
expect(install).toHaveBeenCalledTimes(1)
})
it.each([
[
'a user home',
{ ...env, CODEX_HOME: '/home/jin/.codex', ORCA_CODEX_HOME: '/home/jin/.codex' }
],
['unequal routing markers', { ...env, ORCA_CODEX_HOME: `${home}-other` }],
[
'a parent traversal',
{
...env,
CODEX_HOME: `/home/jin/../jin${home.slice('/home/jin'.length)}`,
ORCA_CODEX_HOME: `/home/jin/../jin${home.slice('/home/jin'.length)}`
}
],
[
'a dot segment',
{
...env,
CODEX_HOME: `/home/./jin${home.slice('/home/jin'.length)}`,
ORCA_CODEX_HOME: `/home/./jin${home.slice('/home/jin'.length)}`
}
],
[
'a host path',
{ ...env, CODEX_HOME: 'C:\\Users\\jin\\.codex', ORCA_CODEX_HOME: 'C:\\Users\\jin\\.codex' }
],
['a missing distro', { ...env, WSL_DISTRO_NAME: '' }],
['a distro path escape', { ...env, WSL_DISTRO_NAME: 'Ubuntu\\..\\host' }],
[
'a repeated separator',
{
...env,
CODEX_HOME: home.replace('/jin/', '/jin//'),
ORCA_CODEX_HOME: home.replace('/jin/', '/jin//')
}
]
])('rejects %s', (_label, candidate) => {
recordManagedWslCodexHome('Ubuntu-24.04', runtimeHome)
expect(resolveManagedWslCodexShellPreflightTarget(candidate)).toBeNull()
})
it('preserves a recorded runtime spelling for a managed account home', () => {
const directHome = '/home/jin/.local/share/orca/codex-accounts/account-1/home'
const directRuntimeHome =
'\\\\wsl$\\Ubuntu-24.04\\home\\jin\\.local\\share\\orca\\codex-accounts\\account-1\\home'
recordManagedWslCodexHome('Ubuntu-24.04', directRuntimeHome)
expect(
resolveManagedWslCodexShellPreflightTarget({
CODEX_HOME: directHome,
ORCA_CODEX_HOME: directHome,
WSL_DISTRO_NAME: 'ubuntu-24.04'
})
).toEqual({ runtimeHomePath: directRuntimeHome, wslDistro: 'ubuntu-24.04' })
})
it('never authorizes a WSL system home even when it was offered by a PTY lane', () => {
const systemHome = '/home/jin/.codex'
recordManagedWslCodexHome('Ubuntu-24.04', '\\\\wsl.localhost\\Ubuntu-24.04\\home\\jin\\.codex')
expect(
resolveManagedWslCodexShellPreflightTarget({
CODEX_HOME: systemHome,
ORCA_CODEX_HOME: systemHome,
WSL_DISTRO_NAME: 'Ubuntu-24.04'
})
).toBeNull()
})
})
@@ -0,0 +1,115 @@
import { win32 as pathWin32 } from 'node:path'
import {
foldWslUncPathCaseInsensitiveParts,
parseWslUncPath,
toWindowsWslPath
} from '../../shared/wsl-paths'
type ManagedWslCodexHome = {
runtimeHomePath: string
linuxHomePath: string
}
const managedHomesByDistro = new Map<string, Map<string, ManagedWslCodexHome>>()
function distroKey(distro: string): string {
return distro.trim().toLowerCase()
}
export function isAbsolutePosixPathWithoutDotSegments(value: string): boolean {
const segments = value.split('/').slice(1)
return (
value.startsWith('/') &&
!value.startsWith('//') &&
!value.includes('\\') &&
segments.every((segment) => segment && segment !== '.' && segment !== '..')
)
}
function isOrcaManagedWslCodexHome(linuxHomePath: string): boolean {
const segments = linuxHomePath.split('/').filter(Boolean)
const orcaIndex = segments.findIndex(
(segment, index) =>
segment === 'orca' && segments[index - 1] === 'share' && segments[index - 2] === '.local'
)
if (orcaIndex === -1) {
return false
}
const tail = segments.slice(orcaIndex + 1)
return (
(tail.length === 2 && tail[0] === 'codex-runtime-home' && tail[1] === 'home') ||
(tail.length === 3 && tail[0] === 'codex-accounts' && Boolean(tail[1]) && tail[2] === 'home')
)
}
function linuxHomeForRuntimePath(runtimeHomePath: string, distro: string): string | null {
const wsl = parseWslUncPath(runtimeHomePath)
if (wsl) {
return wsl.distro.toLowerCase() === distroKey(distro) ? wsl.linuxPath : null
}
const drive = runtimeHomePath.match(/^([A-Za-z]):[/\\](.*)$/)
return drive ? `/mnt/${drive[1].toLowerCase()}/${drive[2].replace(/\\/g, '/')}` : null
}
export function recordManagedWslCodexHome(distro: string, runtimeHomePath: string): void {
const normalizedDistro = distro.trim()
const linuxHomePath = linuxHomeForRuntimePath(runtimeHomePath, normalizedDistro)
if (
!normalizedDistro ||
/[\\/\r\n]/.test(normalizedDistro) ||
!linuxHomePath ||
!isAbsolutePosixPathWithoutDotSegments(linuxHomePath) ||
!isOrcaManagedWslCodexHome(linuxHomePath)
) {
return
}
const homes = managedHomesByDistro.get(distroKey(normalizedDistro)) ?? new Map()
homes.set(linuxHomePath, { runtimeHomePath, linuxHomePath })
managedHomesByDistro.set(distroKey(normalizedDistro), homes)
}
export function resolveRecordedManagedWslCodexHome(
distro: string,
linuxHomePath: string
): string | null {
if (
!distro.trim() ||
/[\\/\r\n]/.test(distro) ||
!isAbsolutePosixPathWithoutDotSegments(linuxHomePath)
) {
return null
}
return managedHomesByDistro.get(distroKey(distro))?.get(linuxHomePath)?.runtimeHomePath ?? null
}
export function resolveManagedWslCodexHome(distro: string, linuxHomePath: string): string | null {
if (
!distro.trim() ||
/[\\/\r\n]/.test(distro) ||
!isAbsolutePosixPathWithoutDotSegments(linuxHomePath) ||
!isOrcaManagedWslCodexHome(linuxHomePath)
) {
return null
}
return (
resolveRecordedManagedWslCodexHome(distro, linuxHomePath) ??
toWindowsWslPath(linuxHomePath, distro)
)
}
export function wslRuntimeHomePathsEqual(left: string | undefined, right: string): boolean {
if (!left) {
return false
}
const leftWsl = foldWslUncPathCaseInsensitiveParts(left)
const rightWsl = foldWslUncPathCaseInsensitiveParts(right)
if (leftWsl || rightWsl) {
return leftWsl !== null && leftWsl === rightWsl
}
return pathWin32.normalize(left).toLowerCase() === pathWin32.normalize(right).toLowerCase()
}
export const _internals = {
clearRecordedManagedWslCodexHomes: () => managedHomesByDistro.clear(),
isOrcaManagedWslCodexHome
}
@@ -0,0 +1,75 @@
import { realpath } from 'node:fs/promises'
import type { AgentHookInstallStatus } from '../../shared/agent-hook-types'
import { withTimeout } from '../../shared/promise-timeout-fallback'
import type { CodexWslRuntimeHookTarget } from './codex-wsl-hook-install-plan'
import { codexHookService } from './hook-service'
import {
isAbsolutePosixPathWithoutDotSegments,
resolveManagedWslCodexHome,
wslRuntimeHomePathsEqual
} from './managed-wsl-codex-home-registry'
type WslShellPreflightEnvironment = {
CODEX_HOME?: string
ORCA_CODEX_HOME?: string
WSL_DISTRO_NAME?: string
}
const WSL_MANAGED_HOME_REALPATH_TIMEOUT_MS = 5_000
export type ManagedWslCodexShellPreflightTarget = {
runtimeHomePath: string
wslDistro: string
}
export function resolveManagedWslCodexShellPreflightTarget(
env: WslShellPreflightEnvironment
): ManagedWslCodexShellPreflightTarget | null {
const codexHome = env.CODEX_HOME?.trim()
const orcaCodexHome = env.ORCA_CODEX_HOME?.trim()
const wslDistro = env.WSL_DISTRO_NAME?.trim()
if (
!codexHome ||
codexHome !== orcaCodexHome ||
!wslDistro ||
/[\\/\r\n]/.test(wslDistro) ||
!isAbsolutePosixPathWithoutDotSegments(codexHome)
) {
return null
}
const runtimeHomePath = resolveManagedWslCodexHome(wslDistro, codexHome)
return runtimeHomePath ? { runtimeHomePath, wslDistro } : null
}
export async function prepareManagedWslCodexHomeBeforeShellLaunch(args: {
env: WslShellPreflightEnvironment
hooksEnabled: boolean
install?: (
runtimeHomePath: string,
target: CodexWslRuntimeHookTarget
) => AgentHookInstallStatus | Promise<AgentHookInstallStatus | null> | null
}): Promise<AgentHookInstallStatus | null> {
if (!args.hooksEnabled) {
return null
}
const target = resolveManagedWslCodexShellPreflightTarget(args.env)
if (!target) {
return null
}
const realHome = await withTimeout(
realpath(target.runtimeHomePath),
WSL_MANAGED_HOME_REALPATH_TIMEOUT_MS,
null
)
if (!realHome || !wslRuntimeHomePathsEqual(realHome, target.runtimeHomePath)) {
return null
}
const install =
args.install ??
((home: string, hookTarget: CodexWslRuntimeHookTarget) =>
codexHookService.installForRuntimeHomeSerialized(home, hookTarget))
return await install(target.runtimeHomePath, {
runtime: 'wsl',
wslDistro: target.wslDistro
})
}
+16
View File
@@ -23,6 +23,8 @@ import {
import { installDeviceAttributesResponder } from './startup-device-attributes-responder'
import type { TerminalSnapshot, TerminalModes } from './types'
import type { TerminalOscLinkRange } from '../../shared/terminal-osc-link-ranges'
import type { TerminalCursorContext } from '../../shared/terminal-composer-draft'
import { readTerminalCursorLineContext } from './terminal-cursor-line-context'
export type HeadlessEmulatorOptions = {
cols: number
@@ -302,6 +304,20 @@ export class HeadlessEmulator {
return lines
}
getVisibleBufferRange(): { start: number; endExclusive: number; totalLength: number } {
const buffer = this.terminal.buffer.active
const start = buffer.viewportY
return {
start,
endExclusive: Math.min(buffer.length, start + this.terminal.rows),
totalLength: buffer.length
}
}
getCursorLineContext(rowsAbove = this.terminal.rows): TerminalCursorContext | null {
return readTerminalCursorLineContext(this.terminal, rowsAbove)
}
getBufferTailLines(limit: number): string[] {
const buffer = this.terminal.buffer.active
const start = Math.max(0, buffer.length - Math.max(0, Math.floor(limit)))
@@ -0,0 +1,71 @@
import { Terminal } from '@xterm/headless'
import { describe, expect, it } from 'vitest'
import { detectTerminalComposerDraft } from '../../shared/terminal-composer-draft'
import { HeadlessEmulator } from './headless-emulator'
import { readTerminalCursorLineContext } from './terminal-cursor-line-context'
function writeSync(terminal: Terminal, data: string): void {
const core = (terminal as unknown as { _core: { writeSync(data: string): void } })._core
core.writeSync(data)
}
describe('readTerminalCursorLineContext', () => {
it.each([
{ cols: 19, cursorRowTail: 'proceed with the ', continuation: 'release' },
{ cols: 18, cursorRowTail: 'proceed with the', continuation: ' release' }
])(
'preserves a space at a dimmed soft-wrap boundary with $cols columns',
({ cols, cursorRowTail, continuation }) => {
const terminal = new Terminal({ cols, rows: 6, allowProposedApi: true })
writeSync(
terminal,
`${'─'.repeat(cols)}\r\n❯ \x1b7\x1b[2mproceed with the release\x1b[22m\x1b8`
)
const context = readTerminalCursorLineContext(terminal, 16)
expect(context?.rawAfterCursor).toBe(cursorRowTail)
expect(context?.rowsBelow).toEqual([continuation, '', '', ''])
expect(context?.rowsBelowWrapped).toEqual([true, false, false, false])
expect(detectTerminalComposerDraft(context)?.text).toBe('proceed with the release')
terminal.dispose()
}
)
it('preserves a typed space before the cursor moves onto a wrapped row', () => {
const terminal = new Terminal({ cols: 19, rows: 6, allowProposedApi: true })
writeSync(terminal, '───────────────────\r\n❯ proceed with the release')
const context = readTerminalCursorLineContext(terminal, 16)
expect(context?.typedRows).toEqual(['───────────────────', '❯ proceed with the ', 'release'])
expect(context?.rowsWrapped).toEqual([false, false, true])
expect(detectTerminalComposerDraft(context)?.text).toBe('proceed with the release')
terminal.dispose()
})
it('recognizes a colored context-only Codex status footer', () => {
const terminal = new Terminal({ cols: 80, rows: 8, allowProposedApi: true })
writeSync(
terminal,
'\x1b[1m›\x1b[22m \x1b7review the change\r\n \r\n\x1b[38;2;242;181;144mContext 0% used\x1b[0m\x1b8'
)
const context = readTerminalCursorLineContext(terminal, 16)
expect(detectTerminalComposerDraft(context)?.text).toBe('review the change')
terminal.dispose()
})
it('finds a composer prompt more than 16 wrapped rows above the cursor', async () => {
const draft = `proceed ${'with '.repeat(65)}release`
const emulator = new HeadlessEmulator({ cols: 19, rows: 30 })
await emulator.write(`${'─'.repeat(19)}\r\n❯ ${draft}`)
const context = emulator.getCursorLineContext()
expect(context?.rows.length).toBeGreaterThan(17)
expect(detectTerminalComposerDraft(context)?.text).toBe(draft)
emulator.dispose()
})
})
@@ -0,0 +1,99 @@
import type { IBufferLine, Terminal } from '@xterm/headless'
import type { TerminalCursorContext } from '../../shared/terminal-composer-draft'
function undimmedText(line: IBufferLine, fromX = 0, trimRight = true): string {
let text = ''
for (let x = fromX; x < line.length; x += 1) {
const cell = line.getCell(x)
if (!cell || cell.isDim() || cell.getWidth() === 0) {
continue
}
text += cell.getChars() || ' '
}
return trimRight ? text.trimEnd() : text
}
function firstVisibleCellIsBold(line: IBufferLine): boolean {
for (let x = 0; x < line.length; x += 1) {
const cell = line.getCell(x)
if (!cell || cell.getWidth() === 0 || !cell.getChars().trim()) {
continue
}
return Boolean(cell.isBold())
}
return false
}
function firstVisibleCellHasCustomForeground(line: IBufferLine): boolean {
for (let x = 0; x < line.length; x += 1) {
const cell = line.getCell(x)
if (!cell || cell.getWidth() === 0 || !cell.getChars().trim()) {
continue
}
return !cell.isFgDefault()
}
return false
}
export function readTerminalCursorLineContext(
terminal: Terminal,
rowsAbove: number
): TerminalCursorContext | null {
const buffer = terminal.buffer.active
const cursorRow = buffer.baseY + buffer.cursorY
const cursorLine = buffer.getLine(cursorRow)
if (!cursorLine) {
return null
}
const rows: string[] = []
const typedRows: string[] = []
const promptGlyphBoldRows: boolean[] = []
const rowsWrapped: boolean[] = []
const start = Math.max(buffer.viewportY, cursorRow - Math.max(0, Math.floor(rowsAbove)))
for (let row = start; row <= cursorRow; row += 1) {
const line = buffer.getLine(row)
const nextLineIsWrapped = buffer.getLine(row + 1)?.isWrapped ?? false
rows.push(line?.translateToString(!nextLineIsWrapped) ?? '')
typedRows.push(line ? undimmedText(line, 0, !nextLineIsWrapped) : '')
promptGlyphBoldRows.push(line ? firstVisibleCellIsBold(line) : false)
rowsWrapped.push(line?.isWrapped ?? false)
}
const rowsBelow: string[] = []
const typedRowsBelow: string[] = []
const rowsBelowWrapped: boolean[] = []
const rowsBelowCustomForeground: boolean[] = []
const end = Math.min(
buffer.viewportY + terminal.rows - 1,
cursorRow + Math.max(0, Math.floor(rowsAbove))
)
for (let row = cursorRow + 1; row <= end; row += 1) {
const line = buffer.getLine(row)
const nextLineIsWrapped = buffer.getLine(row + 1)?.isWrapped ?? false
rowsBelow.push(line?.translateToString(!nextLineIsWrapped) ?? '')
typedRowsBelow.push(line ? undimmedText(line, 0, !nextLineIsWrapped) : '')
rowsBelowWrapped.push(line?.isWrapped ?? false)
rowsBelowCustomForeground.push(line ? firstVisibleCellHasCustomForeground(line) : false)
}
return {
rows,
typedRows,
promptGlyphBoldRows,
rowsWrapped,
rowsBelow,
typedRowsBelow,
rowsBelowWrapped,
rowsBelowCustomForeground,
beforeCursor: cursorLine.translateToString(true, 0, buffer.cursorX),
afterCursor: undimmedText(
cursorLine,
buffer.cursorX,
!(buffer.getLine(cursorRow + 1)?.isWrapped ?? false)
),
rawAfterCursor: cursorLine.translateToString(
!(buffer.getLine(cursorRow + 1)?.isWrapped ?? false),
buffer.cursorX
),
cursorHidden: !terminal.modes.showCursor,
cursorViewportRow: cursorRow - buffer.viewportY
}
}
+15
View File
@@ -13,6 +13,7 @@ import {
type Tray,
session
} from 'electron'
import { applyMacPressAndHoldDefaultAtStartup } from './macos-press-and-hold-default'
import { initTccPromptNotice, stopTccPromptNotice } from './macos-tcc-prompt-notice'
import { electronApp, is } from '@electron-toolkit/utils'
import {
@@ -318,6 +319,11 @@ import { browserCertificateTrustController, browserManager } from './browser/bro
import { RpcDispatcher } from './runtime/rpc/dispatcher'
import { OffscreenBrowserBackend } from './browser/offscreen-browser-backend'
import { initializeBrowserSessionsForApp } from './browser/browser-session-startup'
import {
installDocPreviewProtocolHandler,
registerDocPreviewSchemePrivileges
} from './browser/doc-preview-protocol'
import { registerDocPreviewGrantHandlers } from './ipc/doc-preview-grant-ipc'
import { initializeBrowserClientHostId } from './browser/browser-client-host-id'
import { setUnreadDockBadgeCount } from './dock/unread-badge'
import { AutomationService } from './automations/service'
@@ -938,6 +944,9 @@ if (hasSingleInstanceLock) {
installDevParentSignalQuit(shouldCoupleToDevParent)
// Why: run after configureDevUserDataPath but before app.setName('Orca') (whenReady), which changes the resolved path on case-sensitive filesystems.
initDataPath()
// Why here: initDataPath above gives the canonical userData path for the record file; the write
// itself lands for the next launch (see macos-press-and-hold-default.ts).
applyMacPressAndHoldDefaultAtStartup(getCanonicalUserDataPath())
// Why: use the canonical userData path — late app.getPath('userData') can resolve differently across restarts, defeating persistence.
initSessionParseCachePersistence({
filePath: join(getCanonicalUserDataPath(), 'ai-vault', 'session-parse-cache.json'),
@@ -958,6 +967,9 @@ if (hasSingleInstanceLock) {
if (shouldApplyPreReadyAppName(devInstanceIdentity)) {
app.setName(devInstanceIdentity.appName)
}
// Why: Electron freezes the privileged scheme table at ready, so the doc-preview
// scheme must be declared here or its webview loses fetch/secure-origin privileges.
registerDocPreviewSchemePrivileges()
// Why: must precede app.whenReady() so Crashpad is installed before the
// first renderer spawns; a CHECK before this point is still exit-code-only.
startCrashpadCapture()
@@ -2433,6 +2445,9 @@ void app.whenReady().then(async () => {
} catch {
console.warn('[proxy] Failed to apply network proxy settings')
}
// Why: the preview session is protocol-scoped, so the handler must exist before any preview webview attaches.
installDocPreviewProtocolHandler()
registerDocPreviewGrantHandlers()
// Why: browser sessions serve desktop webviews and runtime profile commands, so init at app startup rather than via a renderer IPC path.
initializeBrowserSessionsForApp({
orcaProfileId: activeOrcaProfile.profile.id,

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